generate-user-flow-tests
Generate Vitest unit tests focused on user operation flows for this repo's React 18 + TypeScript + Antd + react-router-dom v6 stack.
Hard rules (do not deviate)
These rules were agreed with the project owner. Read them before generating anything.
- Runner: Vitest with
environment: 'jsdom'. - Rendering / queries:
@testing-library/react+@testing-library/jest-dommatchers. - User interactions:
@testing-library/user-event(v14+). UseuserEvent.setup()at the top of each test — do NOT usefireEventunless the interaction has nouser-eventequivalent (rare; document why in a one-line comment). - API mocking: MSW only. Never
vi.mock('axios')/vi.mock('@/services/*')for HTTP behavior. Write handlers that return the shape the component actually receives, including axios response envelope ({ data: ... }) if the service layer forwards it. - Routing: wrap the rendered tree in
<MemoryRouter initialEntries={[...]}>. Do NOT mockuseNavigateoruseLocation. To assert navigation, render a sibling route that displays a probe and check it appears. - Providers: always render via the
renderWithProvidershelper (see templates below). It injectsMemoryRouter, AntdConfigProvider, and any app-level context. Never importrenderfrom@testing-library/reactdirectly in test files. - Antd components: render for real. Use role-based queries:
Select:getByRole('combobox')to open; options appear viafindByRole('option', { name }).DatePicker: open via click, then assert on the cell rolegridcell.Modal/Drawer: assert viafindByRole('dialog')— they portal todocument.body, not the container.message/notification: usefindByTextondocument.bodyscope, not the container.
- Query priority (strict):
getByRole>getByLabelText>getByPlaceholderText>getByText>getByDisplayValue>getByTestId. Only fall back todata-testidwhen none of the above fit. If you add adata-testidto source code just to make a test pass, reconsider — usually the component is missing a proper label. - Assertions: behavior-only. No
toMatchSnapshot/toMatchInlineSnapshotanywhere — not even for "small" components. Assert text content, element presence, call arguments, role states, URL changes, request payloads. - Test file location: repo root
tests/mirroringsrc/. Example:src/pages/config-editor/Editor.tsx→tests/pages/config-editor/Editor.test.tsx. Never co-locate next to source; never use__tests__. - Naming:
*.test.tsxfor components,*.test.tsfor pure utilities. File name mirrors the source module's base name. - Async: always
awaituser interactions and queries. PreferfindBy*/waitForover manual timers. If the code usessetTimeout, fake timers withvi.useFakeTimers({ shouldAdvanceTime: true })and advance explicitly.
Input forms
Shorthand: prefer
/ut --src=<path> --coverage=<number>for structured invocation. This skill handles natural-language triggers.
The user will invoke this skill with either:
- A file path — e.g.
给 src/pages/config-editor/Editor.tsx 生成单测. Read the file, identify every user interaction (click handlers, form submits,useNavigatecalls, modal triggers, data-fetch effects), and produce tests for each. - A flow description — e.g.
测一下:登录页填邮箱密码 → 点登录 → 失败时展示错误 → 成功跳转 /home. Map the description to one test case per outcome (happy path + each failure branch). - Both — file path anchors the code; description narrows scope. Prefer the description's scope when they conflict.
- Coverage threshold — the user may specify a coverage target (e.g. "覆盖率80%", "coverage 70%"). Extract the number and use it as
COVERAGE_THRESHOLD. Default: 60%.
If the input is ambiguous (no path, vague description), ask ONE clarifying question before generating. Otherwise, proceed.
Workflow
Follow these steps in order.
Step 1 — Check the environment (one-time setup)
Run all these checks before writing any test file. If any artifact is missing, create it using the templates in the next section.
package.jsonhasvitest,@testing-library/react,@testing-library/jest-dom,@testing-library/user-event,msw,jsdom,@vitest/coverage-v8indevDependencies.vitest.config.tsexists at repo root withtest.environment: 'jsdom',test.setupFiles, and alias@ → src.tests/setup.tsexists and imports@testing-library/jest-dom+ wires MSW lifecycle.tests/msw/server.tsexists and exportsserver.tests/msw/handlers.tsexists (can be empty array initially — handlers are added per-test viaserver.use(...)).tests/utils/renderWithProviders.tsxexists and exportsrenderWithProviders+ re-exportsscreen,userEvent.package.jsonhas atestscript:"test": "vitest"and"test:ui": "vitest --ui"(optional).
If any are missing, report exactly which to the user, show the install command, and create the config files. Do not silently install packages — print the command and wait for user to run it, OR ask for permission to run pnpm add -D ....
Step 2 — Analyze the target
For each target (file or description), produce a short mental list:
- Entry points: what route / props the component is rendered with.
- User actions: every
onClick,onChange, formonFinish, keyboard handler, external trigger. - Side effects: HTTP calls (which endpoints, which services),
useNavigatecalls,message.success/errorcalls, modal open/close. - Async boundaries: where the UI shows a loading state and when it resolves.
- Error branches: network failure, validation error, empty response, permission denied.
Each item in the list maps to one it(...) block.
Step 3 — Generate the test file
- File path:
tests/<mirror-of-src-path>/<base>.test.tsx. - Top of file:
import { describe, it, expect, vi, beforeEach } from 'vitest',import { http, HttpResponse } from 'msw',import { server } from '@tests/msw/server',import { renderWithProviders, screen, userEvent } from '@tests/utils/renderWithProviders', plus the component. - Structure: one top-level
describe('<ComponentName>', ...), oneit(...)per behavior. Nestdescribefor sub-flows only if the list has >6 cases. - Each
it:const user = userEvent.setup()(unless using fake timers — thenuserEvent.setup({ advanceTimers: vi.advanceTimersByTime })).server.use(...)to register the request handlers this case needs.renderWithProviders(<Component ... />, { route: '/...' }).- Await user interactions.
- Behavioral assertions.
- Never group assertions for multiple behaviors into one
it. One behavior = one test.
Step 4 — Run and self-check
- Run
./node_modules/.bin/vitest run tests/<path-to-new-file>(orpnpm exec vitest run ...) to confirm the new tests pass. - If any fail, fix the test (not the source) unless the test surfaces a real bug. Ask the user before modifying source.
Step 5 — Measure coverage and iterate until ≥ COVERAGE_THRESHOLD (MANDATORY)
A "passing" test file is not done until coverage of the target source file meets the baseline gate. Do not stop at Step 4.
5a. Run coverage scoped to the target source
./node_modules/.bin/vitest run tests/<path-to-new-file> \
--coverage.enabled \
--coverage.include='src/<path-to-target-dir>/**' \
--coverage.reporter=json \
--coverage.reporter=text-summary
Use --coverage.include to scope to the file/directory under test — otherwise the report dilutes with unrelated files. Dot notation is required with Vitest 0.34 (--coverage.enabled, not --coverage).
5b. Parse the precise uncovered lines / branches / functions
The text report truncates long line lists with .... Always parse the JSON output to see everything:
node -e "
const d = require('./coverage/coverage-final.json');
const target = '/absolute/path/to/src/<target-file>.tsx';
const f = d[target];
if (!f) { console.log('file not in report'); process.exit(0); }
const uncoveredFns = [];
for (const [id, hit] of Object.entries(f.f)) {
if (hit === 0) { const fn = f.fnMap[id]; uncoveredFns.push('L' + fn.loc.start.line + ' ' + fn.name); }
}
console.log('UNCOVERED FUNCTIONS:');
uncoveredFns.forEach(x => console.log(' ' + x));
const uncoveredBranches = [];
for (const [id, hits] of Object.entries(f.b)) {
hits.forEach((h, idx) => {
if (h === 0) {
const loc = f.branchMap[id].locations[idx];
uncoveredBranches.push('L' + (loc?.start?.line ?? f.branchMap[id].loc.start.line) + ' ' + f.branchMap[id].type);
}
});
}
console.log('\nUNCOVERED BRANCHES:');
uncoveredBranches.forEach(x => console.log(' ' + x));
const lines = new Set();
for (const [id, hit] of Object.entries(f.s)) {
if (hit === 0) { const loc = f.statementMap[id]; for (let l = loc.start.line; l <= loc.end.line; l++) lines.add(l); }
}
const sorted = [...lines].sort((a,b)=>a-b);
let range = []; const ranges = [];
sorted.forEach(l => { if (!range.length || l === range[range.length-1] + 1) range.push(l); else { ranges.push('L' + range[0] + (range.length > 1 ? '-' + range[range.length-1] : '')); range = [l]; } });
if (range.length) ranges.push('L' + range[0] + (range.length > 1 ? '-' + range[range.length-1] : ''));
console.log('\nUNCOVERED LINE RANGES: ' + ranges.join(', '));
"
5c. Coverage gate — iterate until met
Primary gate (baseline): lines ≥ COVERAGE_THRESHOLD AND statements ≥ COVERAGE_THRESHOLD (default 60%).
Secondary signal: report branches and functions too; they are informational — no hard gate.
If the baseline gate is not met, iterate: for each uncovered line range, open the source at that range, identify the user-facing behavior it represents, and add one it(...) per behavior. Then re-run Step 5a. Repeat until the gate is met OR the remaining gap is legitimately out of scope (see 5d).
High-value gaps to target first:
- Error branches of handlers that already have a happy-path test (delete failed, update failed, save failed).
- Alternate sub-flows under a mode switch (e.g. "JSON mode" vs "manual mode" in a form).
- Cancel / close paths with side effects (warning toasts, state resets, confirm prompts).
- Conditional rendering you haven't triggered (empty states, permission-dependent UI).
Once the baseline gate (COVERAGE_THRESHOLD) is met, stop iterating coverage and proceed directly to Step 6 (scenario enumeration). The user will decide in Step 6 whether to supplement more cases to push coverage higher.
5d. Legitimate uncovered code — document, don't chase
Some lines will never be hit by this test file. Do NOT write contortion tests to reach them. List them explicitly in the final report as "deferred to ". Common examples:
- Callbacks of mocked child components. If you mocked
@/components/SchemaEditor, itsonChange/ref.format()/ref.validate()in the parent will never fire. Those lines belong inSchemaEditor.test.tsx. - Heavy side-effect handlers. OSS uploads, file downloads,
window.open— cover in service-level unit tests, not page flow tests. - Third-party component internal wiring.
DynamicConfigForm.onSubmit/onChangeprop callbacks — cover in the child's own test.
If the baseline gate is blocked ONLY by these, report the ceiling honestly: "lines 58% — remaining gap is X/Y/Z which belong in A/B/C.test.tsx" and stop.
5e. Flag real source bugs surfaced by coverage
While iterating you will occasionally hit a branch that reveals a bug (JSON.parse(undefined), a missing await, an incorrect error envelope). Do NOT silently fix source. Add a one-line entry to the final report: <file>:<line> — <what's wrong> — suggested fix: <one-line suggestion>. Let the user decide whether to patch.
Step 6 — Scenario enumeration & human review (user-gated)
Coverage is a proxy, not the goal. A file at COVERAGE_THRESHOLD lines can still miss meaningful user scenarios — combinations of inputs, edge cases on empty/max states, interactions between features — because a single it(...) can touch many lines without asserting anything about them. This step closes that gap by listing user-facing scenarios (not code branches) and letting the human decide whether more cases are needed.
Run this step as soon as Step 5's baseline gate (COVERAGE_THRESHOLD) is met. It is user-gated: the human decides what happens next.
6a. Enumerate scenarios from the source
Re-read the target source with a scenario lens (different from the coverage-line lens in Step 5c). For every feature in the file, list the observable user scenarios — each should be a sentence a PM or QA could write without looking at code.
Categories to walk through systematically:
- Every user interaction × every outcome. Click submit → success. Click submit → server 500. Click submit → validation blocked. Click submit while another submit is in flight.
- Every input field × edge cases. Empty. Max length. Whitespace-only. Paste vs type. Special characters. IME composition (for Chinese input).
- Every conditional render. Empty list. Single item. Many items → pagination. Loading. Error with retry. Permission-denied.
- Every mode / toggle combination. Create vs edit. List vs detail. Manual vs auto. Normal vs read-only.
- Every async race. Component unmount during fetch. Second click before first resolves. Debounced input with stale response.
- Every keyboard path. Enter to submit. Esc to close. Tab order. Focus trap in modal.
- Cross-feature effects. Search + pagination resets to page 1. Delete last item on a page → navigate back. Edit → cancel discards unsaved input.
Aim for 15–40 scenarios for a typical page. Component-level test files usually have 5–15.
6b. Cross-reference with existing it(...) blocks
For each enumerated scenario, check whether an existing test asserts its observable outcome (not just "executes the code"). Mark:
- ✅ Covered — an
it(...)exists whose assertions would fail if this scenario broke - 🟡 Partially covered — touched by a test but not asserted (e.g. search flow tested once, but only with a matching query — not with no matches)
- ❌ Not covered — no test exercises this scenario
Be honest on "partially covered". Coverage tools count it as hit; a reviewer would not.
6c. Present the table to the user
Output a markdown table with three columns: # / Scenario / Status. Group by feature area (list / create / edit / delete / permission / etc.) with small subheadings. Keep each scenario to one line.
Then call AskUserQuestion with one question: should I add cases for the uncovered / partially-covered scenarios? Offer:
- Add all uncovered scenarios — generate cases for every ❌ and 🟡
- Add selected scenarios — user picks which (follow up with the list)
- Continue iterating coverage — keep adding tests to push coverage higher (toward 80%+) before stopping
- Skip — accept the current coverage and scenario state; close the task
Do NOT auto-iterate. This step is explicitly human-gated. If the user says "skip", the task is done.
6d. If user chose "add selected", "add all", or "continue iterating coverage"
- Add all / Add selected: For each approved scenario, add one
it(...). Re-run Step 4 (all pass) and Step 5a (coverage unchanged or improved). Present the final summary. - Continue iterating coverage: Go back to Step 5c and keep adding tests for uncovered lines/branches until the user is satisfied or coverage plateaus at the legitimate ceiling (5d). After each iteration, report updated coverage and ask if the user wants to continue or stop.
If the user chose "skip", proceed to the Output section below and explicitly list the scenarios that were enumerated but not covered — so the skipped items are on record and a future engineer can pick them up.
File templates
Use these verbatim when creating missing infrastructure. Adjust imports/paths if the repo's TS config differs.
vitest.config.ts
Important: this project pins Vite 4.x. Vitest 1.0+ requires Vite 5+, Vitest 4.0+ requires Vite 7+. Use Vitest 0.34.6 until the project upgrades Vite.
Do NOT put
@vitejs/plugin-reactin this config — the project's plugin version (3.1.0) injects a dev-only Fast Refresh preamble that crashes Vitest's module loader. esbuild's automatic JSX transform is enough for tests.
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'
// Antd 在 jsdom 下需要的全局 stub
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() // 必须手动调用——globals: false 时 RTL 不会自动清理 DOM
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'
// 默认 handler 列表;每个 case 用 server.use(...) 注册自己的。
export const handlers: HttpHandler[] = []
tests/utils/renderWithProviders.tsx
Important:
button: { autoInsertSpace: false }is required. Antd 默认在中文按钮文本里插 (如"保存"渲染成"保 存"),导致getByRole('button', { name: '保存' })失败。测试环境里关掉它,断言保持直观。
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'
package.json script additions
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
Install command (one-time)
Vite 4 constraint: pin Vitest to 0.34.6. Later versions require Vite 5+/7+.
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
Run tests via
./node_modules/.bin/vitest run <path>orpnpm exec vitest run <path>rather thanpnpm vitest—pnpm vitesttriggers a preflightpnpm installthat can fail on unapproved build scripts in this repo.
Test templates
Component-level interaction test
import { describe, it, expect, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '@tests/msw/server'
import { renderWithProviders, screen, userEvent, waitFor } from '@tests/utils/renderWithProviders'
import { ConfigForm } from '@/components/ConfigForm'
describe('<ConfigForm />', () => {
it('submits the form with trimmed name when required fields are filled', async () => {
const
const user = userEvent.setup()
renderWithProviders(<ConfigForm />)
await user.type(screen.getByLabelText('配置名称'), ' hello ')
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ name: 'hello' }))
})
it('shows a validation error when name is empty', async () => {
const user = userEvent.setup()
renderWithProviders(<ConfigForm />)
await user.click(screen.getByRole('button', { name: '保存' }))
expect(await screen.findByText('请输入配置名称')).toBeInTheDocument()
})
})
Page-level flow test (with MSW + routing)
import { describe, it, expect } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '@tests/msw/server'
import { renderWithProviders, screen, userEvent } from '@tests/utils/renderWithProviders'
import { Routes, Route } from 'react-router-dom'
import ConfigEditorPage from '@/pages/config-editor'
const renderApp = (route = '/config-editor/1') =>
renderWithProviders(
<Routes>
<Route path="/config-editor/:id" element={<ConfigEditorPage />} />
<Route path="/config-list" element={<div>配置列表页</div>} />
</Routes>,
{ route },
)
describe('config editor flow', () => {
it('loads config, edits name, saves, and navigates back to list', async () => {
server.use(
http.get('/api/v1/configs/1', () =>
HttpResponse.json({ data: { id: 1, name: '原始名称', schema: {} } }),
),
http.put('/api/v1/configs/1', async ({ request }) => {
const body = (await request.json()) as { name: string }
expect(body.name).toBe('新名称')
return HttpResponse.json({ data: { success: true } })
}),
)
const user = userEvent.setup()
renderApp()
// Wait for loaded config to appear
expect(await screen.findByDisplayValue('原始名称')).toBeInTheDocument()
const nameInput = screen.getByLabelText('配置名称')
await user.clear(nameInput)
await user.type(nameInput, '新名称')
await user.click(screen.getByRole('button', { name: '保存' }))
// Navigation probe
expect(await screen.findByText('配置列表页')).toBeInTheDocument()
})
it('shows an error toast when save fails', async () => {
server.use(
http.get('/api/v1/configs/1', () =>
HttpResponse.json({ data: { id: 1, name: 'X', schema: {} } }),
),
http.put('/api/v1/configs/1', () => HttpResponse.json({ message: '保存失败' }, { status: 500 })),
)
const user = userEvent.setup()
renderApp()
await screen.findByDisplayValue('X')
await user.click(screen.getByRole('button', { name: '保存' }))
// Antd message portals to body, so query the full document
expect(await screen.findByText('保存失败')).toBeInTheDocument()
})
})
Antd Select / DatePicker pattern
// Open select and pick an option
await user.click(screen.getByRole('combobox', { name: '环境' }))
await user.click(await screen.findByRole('option', { name: '生产环境' }))
expect(screen.getByRole('combobox', { name: '环境' })).toHaveTextContent('生产环境')
// DatePicker
await user.click(screen.getByPlaceholderText('选择日期'))
await user.click(await screen.findByRole('gridcell', { name: /^15$/ }))
Clipboard copy / document.execCommand pattern
Components that copy text to clipboard typically use navigator.clipboard.writeText with an execCommand('copy') fallback. In jsdom 29, navigator.clipboard cannot be reliably mocked to propagate into the component's runtime scope. Test the user-visible outcome (Antd toast messages) instead of spying on writeText.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderWithProviders, screen, userEvent } from '@tests/utils/renderWithProviders'
import MyComponent from '@/components/MyComponent'
describe('copy behavior', () => {
beforeEach(() => {
// Antd message singleton leaks between tests — must clean up
document.querySelectorAll('.ant-message').forEach(el => el.remove())
})
afterEach(() => {
document.querySelectorAll('.ant-message').forEach(el => el.remove())
})
it('shows success toast when copy button is clicked', async () => {
const user = userEvent.setup()
renderWithProviders(<MyComponent data="hello" />)
await user.click(screen.getByRole('button', { name: /复制/ }))
// Assert on the visible outcome, not on navigator.clipboard.writeText
const msgs = await screen.findAllByText('已复制到剪贴板')
expect(msgs.length).toBeGreaterThanOrEqual(1)
})
it('shows error toast when copy fails', async () => {
// Use vi.spyOn to intercept the prototype method (direct assignment is silently ignored)
const execSpy = vi.spyOn(document, 'execCommand').mockReturnValue(false)
// Also make clipboard API unavailable
Object.defineProperty(Object.getPrototypeOf(navigator), 'clipboard', {
value: undefined,
configurable: true,
})
const user = userEvent.setup()
renderWithProviders(<MyComponent data="hello" />)
await user.click(screen.getByRole('button', { name: /复制/ }))
const msgs = await screen.findAllByText('复制失败,请手动复制')
expect(msgs.length).toBeGreaterThanOrEqual(1)
execSpy.mockRestore()
})
})
Key rules for clipboard tests:
- Never assert on
navigator.clipboard.writeTextspy — the mock does not propagate to the component runtime in jsdom 29. - Always use
vi.spyOn(document, 'execCommand')— never direct-assigndocument.execCommand = vi.fn(). - Always clean up
document.querySelectorAll('.ant-message')inbeforeEach+afterEach. - Always use
findAllByText(notfindByText) for Antd message assertions —rc-motionmay render duplicate nodes.
Common pitfalls (check before shipping a test)
act(...)warnings: usually means a missingawait. Addawaitto the user action orfindBy*query.TypeError: window.matchMedia is not a function: Antd needs it in jsdom. Add totests/setup.ts:Object.defineProperty(window, 'matchMedia', { value: () => ({ matches: false, addListener: () => {}, removeListener: () => {} }) })ResizeObserver is not defined: stub it intests/setup.ts:globalThis.ResizeObserver = class { observe(){} unobserve(){} disconnect(){} }.- axios base URL: vite uses a proxy on
/api/v1, but axios in node has no proxy. Either make MSW handlers match the absolute URL the service uses, or configure axios to use a relative base URL. Verify withonUnhandledRequest: 'error'— it surfaces mismatches immediately. - MSW v2 import style:
import { http, HttpResponse } from 'msw'(notrest). v1 syntax is obsolete. Prefer wildcard paths ('*/api/v1/...') so handlers match regardless of jsdom's origin. - Forgetting
server.resetHandlers(): already handled bytests/setup.ts. Do not re-register global handlers inside tests — useserver.use(...)for per-test overrides. - Querying Antd Modal inside the container: it portals to body. Use
screen.findByRole('dialog')(default scope is the whole document). - Fake timers + user-event: must pass
advanceTimers: vi.advanceTimersByTimetouserEvent.setup, otherwiseuser.clickhangs. - Antd Button with icon — accessible name includes the icon's
aria-label:<Button icon={<PlusOutlined />}>新建配置</Button>has accessible name"plus 新建配置", not"新建配置". Always use a regex query:getByRole('button', { name: /新建配置/ }). - Antd Select in jsdom renders duplicate option nodes: the real option + a virtual-scroll placeholder may both contain the same text. Use
getAllByText(label)and click the last match, or scope the query tofindByRole('listbox')first.getByRole('option', { name })is unreliable because of virtualization. - Antd Modal exit animation does not complete in jsdom:
rc-motionwaits for a CSStransitionendevent that jsdom never fires, so the dialog DOM node remains afteropen=false. Do NOT assert the dialog disappears. Instead assert the behavior that should follow: success toast, state change, request payload, etc. - Antd
autoInsertSpaceon buttons: already disabled inrenderWithProviders. If you see"保 存"in failing assertions, check you're rendering via the helper and notrenderdirectly. - Production-side module-level caches (e.g.
cachedPlatformOptions): if the component fetches options once and caches the result in a module variable, an empty response in one test may poison every subsequent test. InbeforeEach, register a handler that returns a non-empty result so every test starts from the same cached state. handleSubmitthat callsJSON.parse(error.message)in its catch: form validation errors have nomessage, so the catch itself throwsSyntaxError: "undefined" is not valid JSONinto the test log. The behavior-level assertion (validation errors visible) still works — but flag this to the user as a real code smell; do not fix source without permission.navigator.clipboardmock does not propagate to component scope in jsdom 29: direct assignment (navigator.clipboard = { writeText: vi.fn() }) andObject.definePropertyon the instance both set the property at the test level, but the component's runtime may resolvenavigator.clipboardthrough a different lookup path (vitest module sandbox or jsdom prototype chain). Do NOT try to spy onnavigator.clipboard.writeText. Instead, test clipboard behavior by asserting on the user-visible outcome only — the Antd toast message (findAllByText('已复制到剪贴板')) — without asserting on the spy's call count or arguments. To test the fallback (execCommand) path, usevi.spyOn(document, 'execCommand')which correctly intercepts the prototype method.document.execCommandmock — usevi.spyOn, not direct assignment: jsdom 29 definesexecCommandonDocument.prototype. Direct assignmentdocument.execCommand = vi.fn()creates an own property, but jsdom may still call the prototype method internally. Always usevi.spyOn(document, 'execCommand').mockReturnValue(true/false)— this correctly replaces the prototype method. Remember toexecSpy.mockRestore()or let vitest's auto-restore handle cleanup.- Antd
messagesingleton leaks across tests: Antd'smessage.success/error/inforenders into a singleton<div class="ant-message">appended directly todocument.body, outside React's render tree.cleanup()from RTL only unmounts the component tree — it does NOT remove the Antd message container. Messages from previous tests bleed into subsequent tests, causingfindByTextto match stale elements. Fix: add cleanup inafterEach(orbeforeEach) in every test file that triggers Antd messages:document.querySelectorAll('.ant-message').forEach(el => el.remove()). For message assertions, always usefindAllByText(notfindByText) and assertlength >= 1, since Antd may render duplicate nodes duringrc-motionanimation.
What NOT to do (skill is wrong if it does any of these)
- Write
expect(...).toMatchSnapshot()ortoMatchInlineSnapshot(). vi.mock('axios')orvi.mock('@/services/...')for HTTP — always use MSW.- Mock
useNavigate/useLocation— always useMemoryRouter. - Add
data-testidto source code without first trying role/label/text queries. - Place test files next to source or under
__tests__/. - Skip
awaiton user interactions. - Bundle multiple behaviors into one
it(...). - Generate tests for a file it could not read — if the source is missing, stop and ask.
Output format when done
After generating + meeting the coverage baseline (COVERAGE_THRESHOLD) + completing Step 6 (scenario review), report to the user:
- Files created / modified (full paths).
- Test cases generated (bullet list, one line per
it). - Coverage numbers for the target file: lines / statements / branches / functions. Call out explicitly whether the baseline gate (
lines ≥ COVERAGE_THRESHOLD+statements ≥ COVERAGE_THRESHOLD) was met. - Scenario checklist from Step 6b — the table of ✅ / 🟡 / ❌ scenarios, grouped by feature area.
- User's decision on next steps (add all / add selected / continue iterating coverage / skip) and, if skipped, the list of scenarios that remain uncovered — so they are on record for a future pass.
- Deferred coverage — uncovered code that legitimately belongs in other test files, with the suggested target.
- Source bugs flagged (if any) —
file:line+ what's wrong + one-line suggested fix. Do not patch without user approval. - Command to re-run them:
./node_modules/.bin/vitest run tests/<path>(bare run) and the--coverage.enabled --coverage.include=...variant for future verification.