测试的价值不是"证明代码对",而是让你敢改代码。判断要不要写测试的标准很实际:这段逻辑改错了,你多久能发现?
╱╲ E2E(Playwright / Cypress) 少、慢、最接近真实用户
╱ ╲ — 关键业务链路,5-20 条
╱────╲ 组件/集成测试(Vitest + RTL) 中、较快
╱ ╲ — 复杂交互、表单、状态流转
╱────────╲ 单元测试(Vitest) 多、快、最便宜
╱__________╲ — 工具函数、算法、纯逻辑
静态检查(TypeScript + ESLint)—— 成本最低,先做这个
倒金字塔是反模式:大量 E2E 会让 CI 跑到几十分钟且极不稳定(flaky)。正确顺序是先把静态检查做严,再补单测,最后只对核心链路写 E2E。
// utils/format.spec.ts
import { describe, it, expect } from "vitest";
import { formatDuration } from "./format";
describe("formatDuration", () => {
it("小于一分钟显示秒", () => {
expect(formatDuration(45_000)).toBe("45 秒");
});
it("跨越整分钟时进位", () => {
expect(formatDuration(60_000)).toBe("1 分钟");
});
it("非法输入返回空串", () => {
expect(formatDuration(-1)).toBe("");
expect(formatDuration(NaN)).toBe("");
});
});
该测什么:分支多的纯函数、边界条件(0、空数组、超长字符串、时区)、曾经出过 bug 的地方。
不该测什么:直白的 getter/setter、第三方库的行为、实现细节(私有方法)。
RTL 的核心原则:像用户一样查询和交互,测行为不测实现。
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
it("点击按钮后计数加一", async () => {
render(<Counter initial={0} />);
// ✓ 按可访问性角色查询,顺便验证了无障碍属性
await userEvent.click(screen.getByRole("button", { name: "增加" }));
expect(screen.getByText("计数:1")).toBeInTheDocument();
});
查询优先级(从高到低):
| 优先级 | 查询方式 | 说明 |
|---|---|---|
| 1 | getByRole | 最贴近用户与辅助技术 |
| 2 | getByLabelText | 表单元素 |
| 3 | getByPlaceholderText / getByText | 文本内容 |
| 4 | getByTestId | 兜底,尽量少用 |
getBy 找不到直接抛错,queryBy 返回 null(用于断言"不存在"),findBy 返回 Promise(用于异步出现)。
反模式:
// ✗ 测了实现细节:类名一改测试就红,但功能没坏
expect(container.querySelector(".counter-value")).toHaveTextContent("1");
// ✗ 直接改 state,跳过了用户操作路径
act(() => { result.current.setCount(5); });
不要真的发请求。两种主流做法:
// 1. mock 模块(适合简单场景)
vi.mock("./api", () => ({
fetchUser: vi.fn().mockResolvedValue({ id: "1", name: "张三" }),
}));
// 2. MSW:在网络层拦截,组件代码零改动,最推荐
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
http.get("/api/user/:id", ({ params }) =>
HttpResponse.json({ id: params.id, name: "张三" })
)
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
MSW 的好处:测试代码里不出现任何 mock 语法,组件照常用 fetch,将来换成真实后端也不用改测试。
import { test, expect } from "@playwright/test";
test("用户可以完成登录并看到工作台", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("邮箱").fill("test@example.com");
await page.getByLabel("密码").fill("123456");
await page.getByRole("button", { name: "登录" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading", { name: "工作台" })).toBeVisible();
});
稳定性要点(flaky 是 E2E 最大的敌人):
expect(...).toBeVisible() 自动等待,不要写 waitForTimeoutPlaywright 相比 Cypress 更适合跨浏览器与并行,Cypress 的调试体验更好。新项目默认选 Playwright。
| 指标 | 含义 |
|---|---|
| 行覆盖 | 执行到的代码行占比 |
| 分支覆盖 | if/else、三元、短路的分支覆盖 |
| 函数覆盖 | 被调用的函数占比 |
覆盖率高不等于测得对——只断言"没抛错"也能刷到 100%。实践中:
# .github/workflows/test.yml 片段
- run: npm ci
- run: npm run typecheck # tsc --noEmit
- run: npm run lint
- run: npm run test -- --coverage
- run: npx playwright test
顺序有讲究:先跑最快的、最容易失败的。类型检查 10 秒,E2E 10 分钟,不要反过来。
setTimeout 的逻辑?(vi.useFakeTimers() + vi.advanceTimersByTime())