← Back to Index

Flaky Test 不稳定测试指南

目录

什么是 Flaky Test

定义:同样的代码、同样的测试,不做任何修改,运行多次结果不一致——有时通过有时失败。

就像一个摇摆不定(flaky)的开关,你没碰它,它自己时开时关。

第1次运行: ✅ PASS ✅ PASS ✅ PASS ✅ PASS ✅ PASS 第2次运行: ✅ PASS ✅ PASS ❌ FAIL ✅ PASS ✅ PASS ← Flaky! 第3次运行: ✅ PASS ✅ PASS ✅ PASS ✅ PASS ✅ PASS 第4次运行: ✅ PASS ❌ FAIL ✅ PASS ✅ PASS ❌ FAIL ← Flaky!

区别于真正的 bug(每次都失败)和真正通过的测试(每次都通过),flaky test 处于一种"薛定谔"状态。

四大常见原因

1. 并发竞态 最常见

多个测试文件并行执行,共享同一个全局资源(MSW server、数据库连接、全局变量等)。执行顺序不确定,A 文件的 cleanup 可能在 B 文件请求的中途发生。

时间线(并行执行): 文件A: [setup] [test1] [test2] [cleanup: resetHandlers] 文件B: [setup] [test1] [test2——handler被A清掉——❌] [cleanup] ↑ MSW 拦截失败,请求打到真实后端
// vitest.setup.js 中的 MSW 生命周期
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());  // ← 并发时可能影响其他文件
afterAll(() => server.close());

2. 异步时序依赖 常见

测试假设某个异步操作会在固定时间内完成,但实际耗时受 CPU 负载、IO 等影响。

// ❌ 脆弱:CI 机器慢一点就挂
await sleep(100);
expect(element).toBeVisible();

// ✅ 稳健:轮询等待,直到条件满足或超时
await waitFor(() => expect(element).toBeVisible());

3. 隐式全局状态 常见

测试之间通过全局变量、localStorage、单例对象等产生耦合。A 测试修改了全局状态但没清理,B 测试在某些执行顺序下看到了脏数据。

// ❌ 测试间共享状态
let counter = 0;
test('increment', () => { counter++; expect(counter).toBe(1); });
test('check initial', () => { expect(counter).toBe(0); }); // ❌ 顺序依赖

// ✅ 每个测试独立初始化
beforeEach(() => { counter = 0; });

4. 外部依赖 偶发

测试依赖了网络请求、真实数据库、系统时间、文件系统等不可控因素。

外部依赖不稳定原因解决方案
真实 API网络抖动、服务宕机MSW / nock mock
数据库残留数据、连接池耗尽事务回滚 / 内存数据库
系统时间Date.now() 不确定vi.useFakeTimers()
随机数Math.random() 不确定固定 seed / mock

实际案例:Vitest + MSW 并发竞态

项目中发现的问题(2026-02-06):33 个集成测试文件并行执行时,board.integration.test.tsxstores.integration.test.tsx 间歇性失败,但单独运行总是通过。

失败链路分析

Step 1:MSW 拦截失败
并发执行时,某个文件的 server.resetHandlers() 影响了其他文件的请求匹配
Step 2:请求打到真实后端
fetch('/api/employees') 未被 MSW 拦截,直接发到 localhost:3000
Step 3:收到 401 Unauthorized
测试环境没有真实 JWT token,后端返回 401
Step 4:Token refresh 崩溃
api.ts 尝试 fetch('/api/auth/employee/refresh'),相对 URL 在 jsdom 中报 ERR_INVALID_URL
Step 5:组件渲染不完整
handleAuthFailure 尝试 window.location.href = ...,jsdom 报 Not implemented: navigation
Step 6:测试超时失败
搜索框/Tab 按钮没渲染出来 → findByPlaceholderText(/search/) 超时 → FAIL

关键证据

// 日志中出现了真实 Express 响应头 —— 说明 MSW 没拦截住
{
  'x-powered-by': 'Express',
  'x-trace-id': '1770417141324-48dbb4'  // 真实后端生成的 trace ID
}

// Token refresh 用相对 URL,jsdom 无法解析
TypeError: Failed to parse URL from /api/auth/employee/refresh
  cause: TypeError: Invalid URL

修复方案

方案做法优缺点
快速修复 vitest.integration.config.js 加 fileParallelism: false,串行执行 ✅ 1 分钟搞定
❌ 测试速度变慢(8s → ~25s)
根治修复 确保 api.ts 的 baseURL 在测试环境下是完整 URL(http://localhost:3000),让 MSW 能正确匹配 ✅ 保持并行速度
❌ 需改动生产代码

为什么 Flaky Test 特别有害

Google 的研究表明:约 16% 的测试失败是由 flaky test 引起的,而非真实 bug。Flaky test 是测试基础设施中最大的生产力杀手之一。
  1. 难定位 — 开发者本地跑通了,CI 偶尔挂,很难复现和调试
  2. 信任侵蚀 — 团队开始习惯性 "re-run failed jobs",真正的 bug 也被当成 flaky 忽略
  3. 滚雪球效应 — 一个 flaky test 没修,大家开始容忍更多,最终测试套件失去信号价值
  4. 浪费 CI 资源 — 重跑失败的 pipeline 消耗额外的计算时间和成本

如何识别 Flaky Test

快速验证方法

# 连续跑 5 次,看是否一致
for i in 1 2 3 4 5; do
  npx vitest run --config vitest.integration.config.js 2>&1 | grep 'Tests ';
done

# 单独跑可疑文件
npx vitest run --config vitest.integration.config.js src/__tests__/pages/appointments/board.integration.test.tsx

# 如果单独通过但全量失败 → 并发竞态
# 如果单独也偶尔失败 → 异步时序或状态问题

诊断决策树

测试间歇性失败 │ ├── 单独跑也失败? │ ├── 是 → 异步时序 / 全局状态问题 │ └── 否 → 并发竞态问题 │ ├── 只在 CI 失败? │ ├── 是 → 资源/性能相关(CI 机器慢) │ └── 否 → 代码层面的不确定性 │ └── 失败的测试文件每次不同? ├── 是 → 共享资源竞争(MSW/DB/全局变量) └── 否 → 特定测试的时序问题

修复策略

针对并发竞态

// 方案1:串行化(简单但慢)
// vitest.integration.config.js
export default defineConfig({
  test: {
    fileParallelism: false,  // 所有文件串行执行
  }
});

// 方案2:隔离 MSW 实例(每个文件独立 server)
// 在每个测试文件中:
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterAll(() => server.close());

针对异步时序

// ❌ 硬编码等待时间
await new Promise(r => setTimeout(r, 500));
expect(screen.getByText('Done')).toBeInTheDocument();

// ✅ 条件等待
await waitFor(() => {
  expect(screen.getByText('Done')).toBeInTheDocument();
}, { timeout: 5000 });

针对全局状态

// ✅ 每个测试前重置所有共享状态
beforeEach(() => {
  vi.clearAllMocks();
  localStorage.clear();
  // 重置所有单例/全局变量
});

// ✅ 测试后清理副作用
afterEach(() => {
  cleanup();  // React Testing Library
  server.resetHandlers();  // MSW
});

预防措施

最佳实践清单:

创建时间:2026-02-06 | 背景:项目集成测试套件 33 个文件并行执行时发现 flaky test