# Unit Test

> 前端单元测试生成 - 基于 Vitest + RTL + MSW，支持 /ut 命令和自然语言两种触发方式。给组件/页面生成覆盖用户操作流程的单元测试。

- Skill: `migoxlab/unit-test` (Agent Skill)
- Install (CLI): `npx skillmds@latest add migoxlab/unit-test`
- Raw SKILL.md: https://api.skillmd.com/api/skills/migoxlab/unit-test/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: migoxlab (https://skillmd.com/u/migoxlab)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/migoxlab/unit-test

---


# 前端单元测试生成

本技能为 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` |

**规则：**
1. `--src` 是必填项。如果缺失，询问："请提供要生成单测的源文件路径"
2. `--coverage` 必须是 1-100 的整数。超出范围则回退到 60 并通知用户
3. 存储为 `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>`（禁止 mock `useNavigate`）
- **Providers:** 统一使用 `renderWithProviders` helper

---

## Hard Rules（不可违反）

1. **userEvent.setup()** — 每个 test 顶部调用，禁止使用 `fireEvent`
2. **MSW only** — 禁止 `vi.mock('axios')` / `vi.mock('@/services/*')`
3. **MemoryRouter** — 禁止 mock `useNavigate` / `useLocation`
4. **renderWithProviders** — 禁止直接 import `render` from RTL
5. **Antd 组件真实渲染** — 用 role-based queries
6. **Query priority:** `getByRole` > `getByLabelText` > `getByPlaceholderText` > `getByText` > `getByDisplayValue` > `getByTestId`
7. **Assertions: behavior-only** — 禁止 `toMatchSnapshot` / `toMatchInlineSnapshot`
8. **Test file location:** `tests/` 镜像 `src/` 结构（禁止 `__tests__`、禁止 co-locate）
9. **Naming:** `*.test.tsx` (组件) / `*.test.ts` (工具函数)
10. **Async:** 始终 `await` user interactions 和 queries
11. **One behavior = one test** — 禁止在一个 `it()` 中测多个行为

---

## 工作流

### Step 1 — 检查环境（一次性设置）

确认以下依赖和配置就绪，缺失则提示安装/创建：

**依赖（devDependencies）：**
- `vitest`, `jsdom`, `@vitest/coverage-v8`
- `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`
- `msw`

**配置文件：**
- `vitest.config.ts` — jsdom 环境、路径别名、setup 文件
- `tests/setup.ts` — jest-dom 匹配器 + MSW 生命周期 + jsdom polyfill
- `tests/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 — 运行测试

```sh
./node_modules/.bin/vitest run tests/<path>
```

失败则修复测试（不改源码），除非发现真实 bug。

### Step 5 — 覆盖率门控（自动迭代）

```sh
./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 — 场景审查（用户决策）

覆盖率达标后，列出所有用户场景的覆盖状态表，提供选项：
1. 补充全部未覆盖场景
2. 选择部分场景补充
3. 继续迭代覆盖率
4. 跳过

---

## File Templates

### `vitest.config.ts`

```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`

```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`

```ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
```

### `tests/msw/handlers.ts`

```ts
import type { HttpHandler } from 'msw'

export const handlers: HttpHandler[] = []
```

### `tests/utils/renderWithProviders.tsx`

```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

```sh
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** — 缺少 `await`
- **`window.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-message` DOM
- **`navigator.clipboard` mock 不生效** — 断言用户可见结果（toast）而非 spy

## What NOT to do

- `toMatchSnapshot` / `toMatchInlineSnapshot`
- `vi.mock('axios')` / `vi.mock('@/services/...')`
- Mock `useNavigate` / `useLocation`
- 在源码中添加 `data-testid` 而不先尝试 role/label/text
- 测试文件放在 `__tests__/` 或源码旁
- 跳过 `await`
- 一个 `it()` 测多个行为

---

## 输出格式

完成后报告：
1. **Files created / modified**
2. **Test cases generated**（每个 `it` 一行）
3. **Coverage numbers**（lines / statements / branches / functions）
4. **Scenario checklist**（覆盖状态表）
5. **User's decision**
6. **Deferred coverage**（属于其他测试文件的未覆盖代码）
7. **Source bugs flagged**（如有）
8. **Re-run command**

