前端单元测试生成
本技能为 React 18 + TypeScript + Antd + react-router-dom v6 项目生成基于用户操作流程的单元测试。
调用方式
方式一:/ut 命令(推荐)
结构化参数,适合明确知道目标文件和覆盖率要求的场景。
/ut --src=<源文件路径> --coverage=<覆盖率目标>
| 参数 | 是否必填 | 默认值 | 说明 |
|---|---|---|---|
--src |
必填 | - | 源文件或目录路径,相对于项目根目录 |
--coverage |
可选 | 60 | 覆盖率目标(1-100 的整数) |
示例:
/ut --src=src/pages/config-editor/Editor.tsx --coverage=80
/ut src/components/ConfigPreview 70
/ut src/pages/platform-management
方式二:自然语言
直接描述需求,自动识别并触发。
触发关键词:"单测"、"单元测试"、"生成测试"、"补单测"、"用户流程测试"、"流程用例"、"unit test"、"generate test"
示例:
给 src/components/lang/lang.tsx 生成单测
测一下:点击语言切换按钮 → 中文切到英文 → 再点一次切回中文
给 src/pages/platform-management 生成单测,覆盖率要求 80%
参数解析(MANDATORY — 首先执行)
Parse 用户输入提取两个参数:
| Parameter | Flag form | Positional form | Default |
|---|---|---|---|
| Source path | --src=<path> |
1st bare argument | (required) |
| Coverage threshold | --coverage=<number> |
2nd bare argument (a number) | 60 |
规则:
--src是必填项。如果缺失,询问:"请提供要生成单测的源文件路径"--coverage必须是 1-100 的整数。超出范围则回退到 60 并通知用户- 存储为
COVERAGE_THRESHOLD,贯穿整个工作流
解析后确认参数:
目标文件:
{src}覆盖率目标:{COVERAGE_THRESHOLD}%
技术栈(固定)
- Runner: Vitest +
environment: 'jsdom' - Rendering / queries:
@testing-library/react+@testing-library/jest-dom - User interactions:
@testing-library/user-event(v14+) - API mocking: MSW only(禁止
vi.mock('axios')) - Routing:
<MemoryRouter>(禁止 mockuseNavigate) - Providers: 统一使用
renderWithProvidershelper
Hard Rules(不可违反)
- userEvent.setup() — 每个 test 顶部调用,禁止使用
fireEvent - MSW only — 禁止
vi.mock('axios')/vi.mock('@/services/*') - MemoryRouter — 禁止 mock
useNavigate/useLocation - renderWithProviders — 禁止直接 import
renderfrom RTL - Antd 组件真实渲染 — 用 role-based queries
- Query priority:
getByRole>getByLabelText>getByPlaceholderText>getByText>getByDisplayValue>getByTestId - Assertions: behavior-only — 禁止
toMatchSnapshot/toMatchInlineSnapshot - Test file location:
tests/镜像src/结构(禁止__tests__、禁止 co-locate) - Naming:
*.test.tsx(组件) /*.test.ts(工具函数) - Async: 始终
awaituser interactions 和 queries - One behavior = one test — 禁止在一个
it()中测多个行为
工作流
Step 1 — 检查环境(一次性设置)
确认以下依赖和配置就绪,缺失则提示安装/创建:
依赖(devDependencies):
vitest,jsdom,@vitest/coverage-v8@testing-library/react,@testing-library/jest-dom,@testing-library/user-eventmsw
配置文件:
vitest.config.ts— jsdom 环境、路径别名、setup 文件tests/setup.ts— jest-dom 匹配器 + MSW 生命周期 + jsdom polyfilltests/msw/server.ts— MSW server 实例tests/msw/handlers.ts— 默认 handler 列表tests/utils/renderWithProviders.tsx— 统一渲染 helper
Step 2 — 分析目标
识别:
- Entry points(路由 / props)
- User actions(onClick、onChange、form onFinish 等)
- Side effects(HTTP 调用、navigate、message 提示)
- Async boundaries(loading 状态)
- Error branches(网络失败、校验错误、空响应)
Step 3 — 生成测试文件
- 路径:
tests/<mirror-of-src-path>/<base>.test.tsx - 结构:一个
describe,每个行为一个it - 每个
it包含:setup → server.use → render → interact → assert
Step 4 — 运行测试
./node_modules/.bin/vitest run tests/<path>
失败则修复测试(不改源码),除非发现真实 bug。
Step 5 — 覆盖率门控(自动迭代)
./node_modules/.bin/vitest run tests/<path> \
--coverage.enabled \
--coverage.include='src/<target-dir>/**' \
--coverage.reporter=json \
--coverage.reporter=text-summary
Gate: lines >= COVERAGE_THRESHOLD AND statements >= COVERAGE_THRESHOLD
未达标则:解析未覆盖行 → 识别对应行为 → 补充 it() → 重跑。循环直到达标或遇到合理上限。
合理上限(不强追):
- mocked 子组件的回调
- 重型 side-effect handlers(OSS 上传、文件下载)
- 第三方组件内部接线
Step 6 — 场景审查(用户决策)
覆盖率达标后,列出所有用户场景的覆盖状态表,提供选项:
- 补充全部未覆盖场景
- 选择部分场景补充
- 继续迭代覆盖率
- 跳过
File Templates
vitest.config.ts
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@tests': path.resolve(__dirname, 'tests'),
},
},
esbuild: {
jsx: 'automatic',
},
test: {
environment: 'jsdom',
globals: false,
setupFiles: ['./tests/setup.ts'],
css: false,
include: ['tests/**/*.test.{ts,tsx}'],
},
})
tests/setup.ts
import '@testing-library/jest-dom/vitest'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { cleanup } from '@testing-library/react'
import { server } from './msw/server'
if (typeof window !== 'undefined') {
if (!window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})
}
if (!(globalThis as any).ResizeObserver) {
;(globalThis as any).ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
}
}
if (!(globalThis as any).IntersectionObserver) {
;(globalThis as any).IntersectionObserver = class {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() { return [] }
}
}
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
cleanup()
server.resetHandlers()
})
afterAll(() => server.close())
tests/msw/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
tests/msw/handlers.ts
import type { HttpHandler } from 'msw'
export const handlers: HttpHandler[] = []
tests/utils/renderWithProviders.tsx
import { render, type RenderOptions } from '@testing-library/react'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { MemoryRouter } from 'react-router-dom'
import type { ReactElement, ReactNode } from 'react'
type Options = Omit<RenderOptions, 'wrapper'> & {
route?: string
routerEntries?: string[]
}
const createWrapper = ({ route, routerEntries }: Options) => {
const entries = routerEntries ?? (route ? [route] : ['/'])
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={entries}>
<ConfigProvider locale={zhCN} button={{ autoInsertSpace: false }}>
{children}
</ConfigProvider>
</MemoryRouter>
)
return Wrapper
}
export const renderWithProviders = (ui: ReactElement, options: Options = {}) => {
const { route, routerEntries, ...rtlOptions } = options
return render(ui, { wrapper: createWrapper({ route, routerEntries }), ...rtlOptions })
}
export { screen, waitFor, within, act } from '@testing-library/react'
export { default as userEvent } from '@testing-library/user-event'
Install command
pnpm add -D vitest@0.34.6 jsdom @vitest/ui@0.34.6 @vitest/coverage-v8@0.34.6 \
@testing-library/react @testing-library/jest-dom @testing-library/user-event \
msw
Common Pitfalls
act(...)warnings — 缺少awaitwindow.matchMedia is not a function— Antd 需要 jsdom polyfill(已在 setup.ts 中配置)- axios base URL — MSW handlers 用通配路径
'*/api/v1/...' - MSW v2 import —
import { http, HttpResponse } from 'msw'(不是rest) - Antd Modal — portal 到 body,用
screen.findByRole('dialog') - Fake timers + user-event — 需传
advanceTimers: vi.advanceTimersByTime给userEvent.setup - Antd Button with icon — accessible name 包含 icon 的 aria-label,用 regex query
- Antd message singleton 泄漏 —
afterEach中清理.ant-messageDOM navigator.clipboardmock 不生效 — 断言用户可见结果(toast)而非 spy
What NOT to do
toMatchSnapshot/toMatchInlineSnapshotvi.mock('axios')/vi.mock('@/services/...')- Mock
useNavigate/useLocation - 在源码中添加
data-testid而不先尝试 role/label/text - 测试文件放在
__tests__/或源码旁 - 跳过
await - 一个
it()测多个行为
输出格式
完成后报告:
- Files created / modified
- Test cases generated(每个
it一行) - Coverage numbers(lines / statements / branches / functions)
- Scenario checklist(覆盖状态表)
- User's decision
- Deferred coverage(属于其他测试文件的未覆盖代码)
- Source bugs flagged(如有)
- Re-run command