같은 test 가 한 번은 통과 / 한 번은 실패. CI 신뢰가 천천히 무너짐 → "이번 fail 도 flaky겠지" → 진짜 버그 놓침. flaky test 의 근본 원인 5 가지, "그냥 retry" 가 잘못된 fix 인 이유, 체계적으로 잡는 방법 — 이 가이드는 정리한다.
Flaky 의 본질 — non-deterministic
정상 test:
input → 같은 output → 항상 같은 결과 (pass or fail)
flaky test:
input → 다른 output → 결과 변동
이유: test 안 어딘가에 non-deterministic 요소.
- 시간 (Date.now)
- random (Math.random, UUID v4)
- 동시성 (race condition)
- 외부 상태 (network, file, env)
- test 간 순서 의존 (shared state)
→ "test 가 random 하게 fail" 은 거짓 — 원인이 random 일 뿐, 항상 원인 있음.5 가지 원인
1. Race Condition (가장 흔함)
// 비동기 작업 완료 안 기다림
test("clicks button", async () => {
await user.click(button);
expect(screen.getByText("Success")).toBeVisible(); // ← race
});
문제: "Success" 가 render 되는 데 ms 단위 시간 걸림. 빠른 머신에서는 통과,
느린 CI / loaded runner 에서는 fail.
해결:
- waitFor 사용
await waitFor(() => expect(screen.getByText("Success")).toBeVisible());
- findBy* (자동 wait)
await screen.findByText("Success");
- 명시적 sleep (최후 수단, anti-pattern)2. Order Dependency
test("A creates user 1", () => {
db.users.insert({id: 1, name: "A"});
});
test("B finds user 1", () => {
const u = db.users.findById(1); // ← test A 가 먼저 돌아야
expect(u.name).toBe("A");
});
문제:
- test runner 가 parallel 또는 random order 면 B 가 먼저 → fail
- 한 test 의 setup 이 다른 test 에 leak
해결:
- 각 test 가 자기 setup + teardown
- beforeEach 의 DB reset
- transaction-rollback 패턴 (test 끝나면 rollback)
- test 격리 (jest --isolatedModules)3. Time-dependent
test("token expires after 1 hour", () => {
const token = createToken();
// 1 시간 wait... 불가능
// 또는 token.createdAt 을 직접 조작 (구현 결합)
expect(token.isValid()).toBe(false); // 어떻게?
});
문제:
- 실제 시간 의존하면 test 가 1 시간
- new Date() 사용 시 test 가 자정 / DST 경계에서 깨짐
해결:
- 시간 mock (jest.useFakeTimers, sinon.useFakeTimers)
jest.setSystemTime(new Date("2026-05-25"));
jest.advanceTimersByTime(60 * 60 * 1000 + 1);
expect(token.isValid()).toBe(false);
- 시간을 의존 주입 (function clock 인자)
createToken({now: () => "2026-05-25T00:00:00Z"});4. Network / External Service
test("fetches from API", async () => {
const data = await fetch("https://api.example.com/users").then(r => r.json());
expect(data.length).toBeGreaterThan(0);
});
문제:
- 외부 API 가 down 또는 느림 → fail
- API 의 응답 데이터 변동
- rate limit 걸림
해결:
- HTTP mock (nock, msw, MSW worker)
- 1 회 통합 test 만 real network, 나머지 mock
- contract test 로 spec 검증 별도
- timeout 명시 + retry 명확 (auto-retry 가 아닌 application 의도)5. Shared State / Pollution
// global module state
let cachedUser = null;
function getUser() { ... cachedUser = ...; }
test("A", () => { getUser(); /* cachedUser pollution */ });
test("B", () => { /* assumes empty cache */ }); // ← fail if A ran first
문제:
- module-level 변수, singleton, env 변수, file system 등
- 한 test 의 부작용이 다른 test 영향
해결:
- afterEach 의 reset (cache clear, env restore, file delete)
- jest 의 resetModules (각 test 가 fresh module instance)
- 단일 책임 (test 한 개가 자기 setup 만 의존)Auto-Retry 가 잘못된 이유
// CI config
retries: 3 // flaky 면 3 번 시도, 1 번 통과하면 OK
→ flaky test 의 효과적 "숨기기".
문제 1 — CI 신뢰 ↓:
"fail 했네? 그냥 retry 해 보자" 가 습관
진짜 버그도 retry 로 가려짐
→ "이 test fail 은 random" 정신 모델
→ 진짜 fail 무시
문제 2 — root cause 잡힘 X:
flaky 의 원인 = 진짜 버그 (race, leak, ...) 일 가능성 높음
retry 로 production 에서 같은 race 가 사용자에 발생
문제 3 — slow CI:
flaky 가 늘어날수록 CI 시간 ↑
→ retry 는 "기간 임시 조치" — 영구 사용은 안티.
올바른 방법:
1. fail 시 quarantine (별도 group 으로 격리, 본 test suite 영향 X)
2. flaky 의 root cause 찾기 (다음 섹션)
3. fix 후 quarantine 해제체계적으로 잡는 방법
1. Test 1000 회 실행
# 의심 test 만 1000 회
jest path/to/test.spec.ts --testNamePattern "flaky one" \
--runTestsByPath --maxWorkers=1
# 100 회 통과 / 5 회 fail 면 → 5% flaky
# fail 시점의 stack trace / log 비교
# Vitest 도 --repeat 옵션
vitest run --repeat 100 path/to/test2. Test 순서 무작위화
# jest --randomize, vitest 의 random seed
jest --randomize
→ order dependency 가 있으면 fail 패턴 발생.
찾는 도구: jest-circus, vitest 의 fileParallelism3. Worker 격리 / 줄이기
jest --maxWorkers=1 # 직렬 실행
jest --maxWorkers=4 # 4 worker 병렬
→ maxWorkers 변경 시 fail 패턴 변화 = 동시성 문제
→ maxWorkers=1 에서만 통과 = 다른 test 와 shared state4. CI specific
local 통과 / CI fail 면:
- env 변수 차이
- 리소스 (CPU/memory) 차이 → timing
- network 차이
- 시간대 (UTC vs local) 차이
CI 환경 재현: docker run 같은 image, 같은 env, --maxWorkers=4흔한 함정
- sleep 으로 race 해결 — 머신마다 timing 다름, flaky 의 새 원인. waitFor / findBy* 사용.
- flaky 무시 + retry — CI 신뢰 0 → 진짜 버그 놓침 → production 사고.
- shared DB — test 간 isolation 안 됨. transaction rollback 또는 schema per test.
- Math.random / Date.now 직접 의존 — mock / 주입.
- flakiness metrics 없음 — 어떤 test 가 얼마나 flaky 한지 모름. CI 에 retry rate dashboard.
마무리
Flaky test 는 단순 "test 가 random" 이 아니라 code 또는 test 의 진짜 버그. retry 는 임시 조치, 진짜 fix 는 5 원인 중 하나 식별 + 제거.
실용 — CI 에 retry rate dashboard, flaky 감지 시 즉시 quarantine + JIRA / GitHub issue. 1 회 retry 만 허용 (안 깨지면 silent recovery, 2 회 fail 이면 진짜 fail). Math.random / Date.now / 실제 network 는 의식적으로 mock.