Test xanh ở máy bạn, đỏ trên CI, chạy lại lần nữa thì lại xanh — mà code thì không đổi. Đó là flaky test, và nó tệ hơn cả test đỏ hẳn: đỏ hẳn thì bạn ngồi sửa, còn chập chờn thì cả team bắt đầu bấm “re-run” theo phản xạ.
Đến một lúc, một bug thật lẫn trong đám flaky sẽ lọt qua vì không ai còn tin vào màu đỏ nữa. Bài này mổ xẻ bốn nguyên nhân và cách đưa test về deterministic với Vitest.
1. Flaky test là gì và tại sao nó nguy hiểm
Flaky test là test cho kết quả khác nhau giữa các lần chạy dù input và code không hề đổi — lúc pass, lúc fail, không đoán trước được. Nó nguy hiểm hơn một test đỏ hẳn vì hai lý do:
- Che giấu bug thật. Khi suite đã có sẵn vài test hay đỏ vặt, một failure thật sẽ bị nhầm là “flaky quen thuộc” và bị bấm re-run cho qua.
- Bào mòn niềm tin. Dev bắt đầu mặc định “chạy lại là xanh”, và một suite không ai tin thì không còn tác dụng bảo vệ nữa.
View Mermaid diagram code
flowchart TD
Flaky[Test chập chờn] --> Rerun[Dev bấm re-run tới khi xanh]
Rerun --> Trust[Niềm tin vào suite giảm]
Trust --> Ignore[Bug thật bị bỏ qua vì lẫn trong flaky]
Ignore --> Flaky
style Flaky fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3
style Rerun fill:#2a2008,stroke:#ffc043,stroke-width:1.5px,color:#ffd98a
style Trust fill:#2a2008,stroke:#ffc043,stroke-width:1.5px,color:#ffd98a
style Ignore fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3Gốc rễ luôn là một điểm chưa deterministic: một await bị quên, một biến dùng chung, một Date.now() không kiểm soát. Bốn mục tiếp theo đi qua từng nguồn và cách bịt nó.
2. Nguyên nhân 1: async và timing
Đây là nguồn flaky phổ biến nhất: test đụng tới code bất đồng bộ nhưng không chờ đúng cách, nên assertion chạy trước khi việc async xong.
2.1. Quên await
Test dưới đây trông có vẻ đúng, nhưng expect chạy ngay sau loadConfig — trước khi promise bên trong resolve:
1
2
3
4
5
6
7
8
9
10
11
12
import { expect, test } from 'vitest'
async function loadConfig(store: { ready: boolean }) {
await Promise.resolve()
store.ready = true
}
test('quên await: assertion chạy trước khi promise resolve', () => {
const store = { ready: false }
loadConfig(store) // thiếu await
expect(store.ready).toBe(true)
})Chạy lên, test fail vì store.ready vẫn là false tại thời điểm assert:
1
2
3
× quên await: assertion chạy trước khi promise resolve
AssertionError: expected false to be true // Object.is equality
Tests 1 failed (1)Sửa bằng cách await chính hàm async đó — và đừng quên đánh dấu callback của test là async:
1
2
3
4
5
test('await đúng chỗ: test deterministic', async () => {
const store = { ready: false }
await loadConfig(store)
expect(store.ready).toBe(true) // PASS
})Trong ví dụ tối giản này lỗi fail đều đặn, nhưng ngoài thực tế cùng một cái quên await có thể lúc xanh lúc đỏ tùy việc microtask nào kịp chạy trước — đúng nghĩa flaky.
2.2. Dựa vào thời gian thật
Một mẹo hay gặp là “chờ” async xong bằng await new Promise(r => setTimeout(r, 500)). Cách này vừa chậm vừa mong manh: nếu CI tải nặng và callback mất hơn 500ms, test fail. Đằng sau đó là thứ tự microtask và macrotask, thứ dễ gây bất ngờ:
1
2
3
4
console.log('1: sync');
setTimeout(() => console.log('4: macrotask (setTimeout 0)'), 0);
Promise.resolve().then(() => console.log('3: microtask (Promise)'));
console.log('2: sync');Output:
1
2
3
4
1: sync
2: sync
3: microtask (Promise)
4: macrotask (setTimeout 0)setTimeout(0) không chạy “ngay” — nó xếp sau toàn bộ microtask (Promise). Bám vào thứ tự này bằng thời gian thật chính là nguồn flaky. Chi tiết cơ chế nằm ở hai bài Asynchronous JavaScript và Macro Task Queue và vì sao Promise chạy trước setTimeout(0). Cách xử lý gốc là ngừng dùng thời gian thật và kiểm soát nó — xem mục 4.
3. Nguyên nhân 2: phụ thuộc thứ tự và state dùng chung
Biến module-level, biến global, localStorage, hay dữ liệu trong DB dùng chung giữa các test là một nguồn flaky tiềm ẩn. Vitest cô lập theo file — mỗi file test chạy trong một môi trường riêng — nhưng trong cùng một file, state vẫn rò giữa các it nếu bạn không reset.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { describe, expect, it } from 'vitest'
let cart: string[] = []
describe('giỏ hàng — KHÔNG reset', () => {
it('thêm 1 sản phẩm', () => {
cart.push('áo')
expect(cart).toHaveLength(1)
})
it('giỏ hàng rỗng cho user mới', () => {
expect(cart).toHaveLength(0) // rò state từ test trên
})
})Test thứ hai fail vì cart vẫn còn 'áo' từ test thứ nhất:
1
2
3
4
✓ thêm 1 sản phẩm
× giỏ hàng rỗng cho user mới
AssertionError: expected [ 'áo' ] to have a length of +0 but got 1
Tests 1 failed | 1 passed (2)Điểm nguy hiểm: test thứ hai chỉ fail khi chạy sau test thứ nhất. Chạy riêng nó, hoặc đảo thứ tự, nó lại xanh — đó chính là dấu hiệu order-dependent. Cách sửa là reset state trong beforeEach để mỗi test khởi đầu từ trạng thái sạch:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { beforeEach, describe, expect, it } from 'vitest'
let cart: string[] = []
describe('giỏ hàng — reset trong beforeEach', () => {
beforeEach(() => {
cart = []
})
it('thêm 1 sản phẩm', () => {
cart.push('áo')
expect(cart).toHaveLength(1)
})
it('giỏ hàng rỗng cho user mới', () => {
expect(cart).toHaveLength(0) // luôn PASS
})
})Giờ cả hai test đều xanh bất kể thứ tự (Tests 2 passed). Nguyên tắc: mỗi test tự dựng state của mình, không dựa vào cái test trước để lại. Nếu test đụng tới localStorage, gọi localStorage.clear() trong beforeEach với đúng tinh thần đó.
4. Nguyên nhân 3: thời gian và số ngẫu nhiên
Date.now(), new Date() và Math.random() trả về giá trị khác nhau mỗi lần chạy, nên bất kỳ test nào phụ thuộc chúng đều có nguy cơ flaky. Vitest cho bạn kiểm soát cả ba.
4.1. Kiểm soát thời gian với fake timers
Hàm debounce là ví dụ kinh điển: nó gom nhiều lần gọi liên tiếp thành một, và chỉ chạy sau khoảng lặng delay. Hành vi đó phụ thuộc setTimeout:
1
2
3
4
5
6
7
8
9
10
11
12
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const save = debounce((value) => console.log('Đã lưu:', value), 100);
save('a');
save('b');
save('c'); // gọi 3 lần liên tiếp, chỉ lần cuối chạyOutput (sau 100ms):
1
Đã lưu: cNếu test hành vi này bằng setTimeout thật, bạn phải chờ 100ms mỗi lần và dễ flaky trên CI chậm. vi.useFakeTimers() thay timer thật bằng bản giả, còn vi.advanceTimersByTime() tua thời gian tức thì:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
function debounce<A extends unknown[]>(fn: (...a: A) => void, delay: number) {
let timer: ReturnType<typeof setTimeout>
return (...args: A) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
describe('debounce', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('chỉ gọi fn một lần sau delay', () => {
const fn = vi.fn()
const debounced = debounce(fn, 300)
debounced('a')
debounced('b')
debounced('c')
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith('c')
})
})Test chạy tức thì (Tests 1 passed) và không phụ thuộc đồng hồ thật. Luôn gọi vi.useRealTimers() trong afterEach — nếu không, fake timers sẽ rò sang các test sau và gây flaky ở chỗ khác.
4.2. setSystemTime cho code phụ thuộc Date
Với code đọc thẳng new Date(), dùng vi.setSystemTime() để ghim đồng hồ về một mốc cố định:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
function greeting(now = new Date()) {
const h = now.getHours()
if (h < 12) return 'Chào buổi sáng'
if (h < 18) return 'Chào buổi chiều'
return 'Chào buổi tối'
}
describe('greeting theo giờ', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('trả về lời chào buổi sáng lúc 9h', () => {
vi.setSystemTime(new Date(2026, 0, 15, 9, 0, 0))
expect(greeting()).toBe('Chào buổi sáng')
})
it('trả về lời chào buổi tối lúc 20h', () => {
vi.setSystemTime(new Date(2026, 0, 15, 20, 0, 0))
expect(greeting()).toBe('Chào buổi tối')
})
})Cả hai test đều xanh (Tests 2 passed). Không có setSystemTime, test greeting() sẽ pass hay fail tùy vào giờ mà CI tình cờ chạy — một test flaky chỉ đỏ khi CI chạy vào lúc 20h.
4.3. Seed cho Math.random()
Math.random() thì cố định bằng một spy trả giá trị biết trước:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { afterEach, expect, test, vi } from 'vitest'
function rollDice() {
return Math.floor(Math.random() * 6) + 1
}
afterEach(() => {
vi.restoreAllMocks()
})
test('rollDice deterministic khi cố định Math.random', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.5)
expect(rollDice()).toBe(4) // floor(0.5 * 6) + 1
})vi.spyOn(Math, 'random').mockReturnValue(0.5) biến random thành hằng số trong phạm vi test, còn vi.restoreAllMocks() trong afterEach trả Math.random về bản thật sau mỗi test. Chi tiết vi.spyOn khác vi.mock ra sao xem bài vi.spyOn vs vi.mock trong Vitest. Với dữ liệu random phức tạp, dùng một seeded PRNG thay cho Math.random để tái tạo được cùng chuỗi.
5. Nguyên nhân 4: network, animation và focus
Ba nguồn còn lại ít gặp hơn nhưng cũng đủ làm suite chập chờn:
- Network. Gọi API thật trong test là flaky vì latency, rate limit và dữ liệu thay đổi. Đừng spy thẳng
global.fetch; hãy mock ở tầng request bằng MSW (Mock Service Worker) để test không chạm network. Cách dựng MSW và query bất đồng bộ trong component xứng đáng một bài riêng về test React component với Vitest và Testing Library. - Animation. CSS transition,
element.animate()hayrequestAnimationFramekhiến assertion chạy trước khi hiệu ứng xong. Kiểm soát bằng fake timers, hoặc chờ eventtransitionendthay vì đoán thời lượng. - Focus.
document.activeElementvà:focusphụ thuộc việc cửa sổ có đang được focus không — điều mà CI headless thường không đảm bảo. Assert hành vi (hàm.focus()đã được gọi chưa) thay vì trạng thái visual.
6. Khắc phục: đưa test về deterministic
Gom lại, mỗi nguồn flaky có một cách bịt tương ứng:
| Nguồn flaky | Cách khắc phục |
|---|---|
| Async chưa chờ | await đúng chỗ; với UI dùng findBy*/waitFor thay vì getBy* |
| State dùng chung | reset trong beforeEach/afterEach; dọn mock |
| Thời gian, random | vi.useFakeTimers(), vi.setSystemTime(), seed random |
| Network | mock bằng MSW |
| Phụ thuộc thứ tự | mỗi test tự dựng state của mình |
Với test UI, một phần tử thường xuất hiện sau một bước async: getByText chạy đồng bộ nên fail ngay, còn findByText và waitFor sẽ thử lại tới khi thấy (hoặc hết timeout). Chi tiết query bất đồng bộ thuộc về bài test React component với Vitest và Testing Library nói trên.
Về việc dọn state, Vitest có ba hàm dễ nhầm:
| Hàm | Làm gì |
|---|---|
vi.clearAllMocks() | Xóa lịch sử gọi (mock.calls, mock.results); giữ nguyên implementation |
vi.resetAllMocks() | Xóa lịch sử và gỡ implementation (mock thành hàm rỗng) |
vi.restoreAllMocks() | Trả spy tạo bằng vi.spyOn về bản gốc |
Để khỏi phải nhớ gọi tay trong từng file, bật sẵn trong config — bớt hẳn một nguồn rò state giữa các test:
1
2
3
4
5
6
7
8
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
clearMocks: true, // clear mock.calls trước mỗi test
restoreMocks: true, // restore spyOn trước mỗi test
},
})7. Phát hiện và phòng ngừa flaky test
Sửa được flaky thì tốt, nhưng phát hiện nó sớm còn tốt hơn. Vài công cụ để chủ động lộ vấn đề:
- Chạy tuần tự hoặc tắt isolation (
vitest --no-isolate). Nếu một test chỉ pass khi được cô lập, nó đang dựa vào môi trường sạch mà thực tế không đảm bảo — đó là order-dependency. - Randomize thứ tự test. Bật
shuffleđể Vitest xáo trộn thứ tự, làm lộ phụ thuộc giữa các test sớm thay vì để CI phát hiện hộ:
1
2
3
4
5
6
import { describe, expect, test } from 'vitest'
describe('suite kiểm tra order-dependency', { shuffle: true }, () => {
test('a', () => expect(1).toBe(1))
test('b', () => expect(2).toBe(2))
})retrychỉ là biện pháp TẠM. Vitest cho phép thử lại test đã fail, nhưng nó che triệu chứng chứ không sửa nguyên nhân:
1
2
3
4
5
6
7
8
9
import { expect, test } from 'vitest'
let attempt = 0
// retry giúp test xanh, nhưng nó vẫn chưa deterministic
test('test tự phục hồi sau vài lần thử', { retry: 2 }, () => {
attempt++
expect(attempt).toBe(3)
})- Quarantine. Khi chưa kịp sửa, tách test flaky ra khỏi suite chính (skip kèm ghi chú và một issue theo dõi) để nó không chặn CI — rồi sửa dứt điểm, đừng để nó ở đó bào mòn niềm tin.
Flaky test không phải xui rủi — nó là tín hiệu có một điểm chưa deterministic: một await bị quên, một biến dùng chung, một Date.now() không kiểm soát. Lần tới khi bạn định thêm retry cho một test hay đỏ vặt, hãy dừng lại hỏi “cái gì đang không xác định ở đây?”. Sửa đúng chỗ đó, test sẽ xanh vì nó đúng, chứ không phải vì may.