前端测试
测试用户所见和所操作的内容,而不是组件的内部实现。与 naki-tdd 配合形成循环,与 naki-e2e-playwright 配合覆盖完整的浏览器流程,与 naki-accessibility 配合检查语义。如果项目有对应的项目规则技能(例如 naki-myapp),请遵循该技能。
何时使用
- 需要为新组件、Hook 或页面编写测试。
- 重构后测试失败,但行为未改变(表明与实现耦合)。
- 测试不稳定,到处使用
waitFor,或者模拟了半个应用。
核心规则
- 像用户一样查询。 优先级:
getByRole(配合name)、getByLabelText、getByPlaceholderText、getByText、getByDisplayValue。仅在没有任何可访问查询可用时使用getByTestId。 - 使用
userEvent进行交互,而非fireEvent。使用const user = userEvent.setup()创建,并等待每个调用。 - 对输出进行断言:可见文本、角色、表单值、对网络端点的调用。绝不对状态变量、Hook 内部实现、CSS 类名或组件实例方法进行断言。
- 模拟网络,而不是你的模块。 在 HTTP 层使用 MSW 处理器。仅对真正的外部边界(时间、随机数、jsdom 不支持的浏览器 API)模拟模块。
- 使用
findBy*查找稍后出现的内容。 将waitFor保留给非查询类的断言,并且绝不在waitFor中放置副作用。 - 每个测试只测一个行为,以句子形式命名:
shows an error when the server returns 500。 - 每个测试设置自己的状态。 不依赖顺序;在
afterEach中重置处理器、计时器和存储。 - 通过
renderWithProviders辅助函数提供真实的 Provider(路由器、关闭重试的查询客户端、主题),而不是模拟它们。 - 可访问性是可测试的。
getByRole("button", { name: "Save" })失败通常意味着真正的可访问性 bug。可以选择为关键屏幕添加vitest-axe检查。 - 覆盖率是信号,而非目标。 覆盖重要的分支:错误状态、空状态、权限。
查询速查表
| 元素 | 查询方式 |
|---|---|
| 按钮 | getByRole("button", { name: /save/i }) |
| 链接 | getByRole("link", { name: "Docs" }) |
| 带标签的文本输入 | getByRole("textbox", { name: "Email" }) 或 getByLabelText("Email") |
| 复选框 | getByRole("checkbox", { name: "Remember me" }) |
| 标题 | getByRole("heading", { level: 2, name: "Alerts" }) |
| 表格行 | getAllByRole("row") 然后使用 within(row) |
| 对话框 | getByRole("dialog", { name: "Delete alert" }) |
| 元素不存在 | expect(queryByText("Error")).not.toBeInTheDocument() |
工作流程
- 列出行为:渲染状态(加载中、空、数据、错误)和交互。
- 为组件调用的端点添加 MSW 处理器,默认为成功。
- 为每个行为编写一个测试,对于错误情况使用
server.use(...)覆盖处理器。 - 运行
npx vitest run path/to/file.test.tsx,然后运行整个测试套件。 - 故意破坏一次实现(注释掉一行代码),确认测试能够失败。
设置
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { cleanup } from "@testing-library/react";
import { server } from "./src/test/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => { server.resetHandlers(); cleanup(); });
afterAll(() => server.close());
onUnhandledRequest: "error" 会使缺失的处理器直接失败,而不是挂起。
示例
import { http, HttpResponse } from "msw";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { server } from "../test/server";
import { AlertList } from "./AlertList";
test("列出警报并按严重性筛选", async () => {
server.use(http.get("/api/alerts", () => HttpResponse.json([
{ id: "a1", title: "VPN 暴力破解", severity: "high" },
{ id: "a2", title: "新管理员登录", severity: "low" },
])));
const user = userEvent.setup();
render(<AlertList />);
expect(await screen.findByText("VPN 暴力破解")).toBeInTheDocument();
await user.selectOptions(screen.getByRole("combobox", { name: "Severity" }), "high");
expect(screen.queryByText("新管理员登录")).not.toBeInTheDocument();
});
test("加载失败时显示重试按钮", async () => {
server.use(http.get("/api/alerts", () => new HttpResponse(null, { status: 500 })));
render(<AlertList />);
expect(await screen.findByRole("button", { name: /重试/i })).toBeInTheDocument();
});
Hooks 和时间
- 通过一个使用 Hook 的小组件来测试 Hook,或者当 Hook 没有 UI 时使用
renderHook。 - 伪造计时器:
vi.useFakeTimers()和userEvent.setup({ advanceTimers: vi.advanceTimersByTime });在afterEach中使用vi.useRealTimers()恢复。 - 使用
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"))冻结日期,以使快照和标签保持稳定。
检查清单
- 查询基于角色或标签。
- 所有
userEvent调用都已 await。 - 使用 MSW 模拟网络;未处理的请求会失败。
- 覆盖了加载中、错误和空状态。
- 没有对内部实现或类名进行断言。
- 测试单独运行和任意顺序运行都能通过。
- 输出中没有
act(...)警告。
常见错误
| 错误 | 修复方法 |
|---|---|
到处使用 getByTestId | 使用角色/标签查询;如果组件缺少标签,则添加标签 |
await waitFor(() => getByText("x")) | 使用 await findByText("x") |
使用 fireEvent.change 模拟输入 | 使用 await user.type(input, "text") |
直接模拟 useAlerts Hook | 模拟 HTTP 端点 |
| 整个页面的大型快照 | 对关键内容进行显式断言 |
| 查询客户端开启了重试,导致错误测试缓慢 | 在测试 Provider 中设置 retry: false |
忽略 act 警告 | 使用 findBy* await 异步 UI 更新 |