前端单元测试
Vitest/Jest、组件测试直觉。
前端单元测试
这一课你将能做到
- 用 Vitest + Testing Library 写出组件「渲染」与「点击交互」测试,查询全走 role / 文本
- 用
vi.mock 隔离 API 模块与 next/navigation,不打真实 backend
- 对照 Java 侧 JUnit / Mockito,说清「测行为不测实现」在前端的具体形态
为什么要学
本站 platform 是 Next.js + React。backend 再绿,前端的状态分支(loading / error / 空列表)照样能把体验打穿:判题状态徽章把 wrong_answer 显示成 undefined,用户以为系统坏了。人话:组件测试验证用户看得见的行为——渲染了什么、点了什么、跳到哪里——而不是断言内部 useState 的变量名。
CI 里 platform job 跑 npm test(与 lint、build 并行),本课让你的本地命令与 CI 对齐。前端金字塔与 Java 同构:Vitest 组件测是底部(多),Playwright 是顶部(少,第 9 课)。运行时细节的姊妹课见 node-test。
先建立图像
用户看见的:判题徽章文案、按钮、链接目标
组件测试: render → 查询 role/文本 → userEvent 点击 → expect
不要测: 某 state 字段名、CSS class 是否叫 foo
要 mock: API 模块 / 路由 push / 第三方 SDK
| Java 侧 | 前端对应 |
|---|
JUnit @Test | test() / it() |
Mockito when(...).thenReturn(...) | vi.fn().mockResolvedValue(...) |
| MockMvc HTTP 契约 | MSW 或 mock API 模块 |
| Surefire 报告 | Vitest reporter |
Testing Library 的口号:像用户一样与页面交互——按 role、label、可见文本查询,而不是 CSS 选择器。重构 DOM 结构时行为测更抗碎,与后端「测公开行为不测私有实现」完全同构。
跟着做
步骤 1:确认与 CI 同款的测试命令
cd platform
cat package.json # 找 "test" 脚本
npm test
若仓库尚未配置,最小安装:
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
vitest.config.ts 至少要有 jsdom 环境(否则报 document is not defined):
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: "./vitest.setup.ts",
},
});
vitest.setup.ts:
import "@testing-library/jest-dom/vitest";
步骤 2:判题徽章的渲染测试
组件 VerdictBadge.tsx(把判题状态翻译成用户能看懂的徽章):
const LABELS: Record<string, string> = {
pending: "排队中",
accepted: "通过",
wrong_answer: "答案错误",
time_limit_exceeded: "超时",
};
export function VerdictBadge({ status }: { status: string }) {
const label = LABELS[status] ?? "未知";
return (
<span role="status" data-verdict={status}>
{label}
</span>
);
}
import { render, screen } from "@testing-library/react";
import { VerdictBadge } from "./VerdictBadge";
test("pending 显示为排队中", () => {
render(<VerdictBadge status="pending" />);
expect(screen.getByRole("status")).toHaveTextContent("排队中");
});
test("未知状态有兜底文案", () => {
render(<VerdictBadge status="mystery" />);
expect(screen.getByRole("status")).toHaveTextContent("未知");
});
应看到:绿。若查询失败,用 screen.debug() 看渲染输出,改查询方式,而不是退回断言 className。
步骤 3:mock API 模块,测成功 / 失败分支
组件 SubmissionList.tsx(拉取我的提交记录):
import { useEffect, useState } from "react";
import { fetchSubmissions } from "@/lib/api";
export function SubmissionList({ lessonId }: { lessonId: number }) {
const [items, setItems] = useState<{ id: number; status: string }[]>([]);
const [error, setError] = useState(false);
useEffect(() => {
fetchSubmissions(lessonId)
.then(setItems)
.catch(() => setError(true));
}, [lessonId]);
if (error) return <p>提交记录加载失败</p>;
return (
<ul>
{items.map((it) => (
<li key={it.id}>
提交 #{it.id}:<VerdictBadge status={it.status} />
</li>
))}
</ul>
);
}
import { vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { SubmissionList } from "./SubmissionList";
vi.mock("@/lib/api", () => ({
fetchSubmissions: vi.fn(),
}));
import { fetchSubmissions } from "@/lib/api";
test("成功时渲染提交记录", async () => {
vi.mocked(fetchSubmissions).mockResolvedValue([
{ id: 10, status: "pending" },
]);
render(<SubmissionList lessonId={5} />);
expect(await screen.findByText(/提交 #10/)).toBeInTheDocument();
expect(screen.getByText("排队中")).toBeInTheDocument();
});
test("失败时展示错误提示", async () => {
vi.mocked(fetchSubmissions).mockRejectedValue(new Error("network"));
render(<SubmissionList lessonId={5} />);
expect(await screen.findByText(/加载失败/)).toBeInTheDocument();
});
应看到:测试全程不访问 localhost:8080。vi.mock 是模块级的 Mockito——hoisting 让它在 import 前生效。
步骤 4:交互测试(userEvent + 路由 mock)
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
test("点击课程卡跳转到对应 track", async () => {
const user = userEvent.setup();
render(<TrackCard slug="testing-essentials" title="测试入门" />);
await user.click(screen.getByRole("link", { name: /测试入门/ }));
expect(mockPush).toHaveBeenCalledWith("/tracks/testing-essentials");
});
人话:把「跳转」变成可断言的函数调用或 href 属性,而不是真开浏览器。
步骤 5:watch 模式与 CI 对齐
npx vitest # watch 模式,保存即重跑
npm test # 单次全量,与 ci.yml 的 platform job 同命令
改组件文案时,按可见文本查询的测试会逼你同步更新——这是特性:它防止「测试测的是去年文案」。
讲清楚要点
- 用户不关心 state 变量名:查询用 role / 文本 / label,实现细节一变行为测仍稳;断言
data-verdict 之外的内部状态是脆弱测试的起点。
- 网络是边界,单元层必须 mock:
vi.mock 挡掉 API 模块,否则套件依赖 backend 起停,并行必炸。
- 异步 UI 用
findBy / waitFor:它们内置重试,固定 sleep(1000) 是 flaky 制造机。
- loading / error / 空列表值得专门用例:这些分支 E2E 很难稳定触发(要真断网),组件测是最便宜的守护位。
- 优先
getByRole:可访问性查询更接近用户感知,还顺带逼出更好的 a11y 语义(role="status" 就是这么来的)。
- CI 跑同一条
npm test:本地脚本别玩花样,与 ci.yml 的 platform job 严格一致。
你可能会懵的地方
-
Vitest 和 Jest 怎么选?
新 Vite/Next 项目常见 Vitest,启动快、API 与 Jest 高度同构:vi.fn ≈ jest.fn,vi.mock ≈ jest.mock。选团队已有的那个,别为风味迁库。
-
为什么不用快照(大段 DOM snapshot)当主断言?
一改 class 全红、审查时没人真看 diff。小快照(如一段纯文案)偶可用;主路径必须是行为断言。
-
Server Components 怎么测?
服务端组件策略因框架而异:抽可测纯函数(数据加工、状态映射)、对客户端交互岛做组件测、整页行为交给 E2E。别执着于「把 React Server Component 塞进 jsdom」。
-
和 Playwright 重复吗?
「首页有标题」这类断言两层都有就删掉一层。组件测守状态分支(mock 网络、断网、慢响应),E2E 守真实跨端路径——重叠面控制到最小。
-
mock 之后还能信吗?
与 Java 同理:mock 了 API 模块,测的是「拿到数据后 UI 怎么表现」,不测「接口真返回什么」。后者是 backend 契约测(MockMvc 课)的职责,两层拼起来才完整。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 断言内部 state / 私有函数 | 脆、不反映 UX | 测可见结果(role/文本) |
| 组件测打真 API | 慢、并行互踩、偶发红 | vi.mock / MSW |
用 sleep(1000) 等渲染 | flaky | findBy / waitFor |
| 只测渲染不测错误分支 | 线上白屏没人守 | 补 error / empty 用例 |
| 选择器绑 class 名 | 样式重构误伤 | getByRole / label |
卡点急救
- 报
document is not defined:没配 jsdom 环境,vitest.config.ts 加 test.environment: "jsdom"。
getByRole 找不到元素:screen.debug() 打印 DOM,多半是元素根本没渲染(异步未等)或语义缺失;异步用 findByRole,语义缺就改组件加 role/aria-label。
- vi.mock 不生效:确认
vi.mock 在文件顶层(会被提升)、mock 的路径与 import 路径逐字一致(别名 @/lib/api 两边要一样)。
面试常问
-
「测行为不测实现」在组件测试里怎么落地?
查询与断言全部面向用户感知:role、label、可见文本、路由调用;不碰组件内部 state、不绑 CSS class。重构(换 hooks、拆子组件)时行为测应该保持绿——红了就说明测试绑了实现。
-
Vitest 里怎么隔离网络请求?和后端 mock 思想有何对应?
模块级 vi.mock("@/lib/api") + vi.fn().mockResolvedValue/mockRejectedValue,对应 Java 的 Mockito when/thenReturn/thenThrow;想测更真实的 HTTP 层可上 MSW,对应后端的 MockMvc。关键一致点:单元层不碰真实跨进程依赖。
-
为什么 loading / error / 空状态要单测,而不是丢给 E2E?
这些分支需要受控的外部条件(慢响应、断网、空数据),E2E 制造这些条件又慢又脆;组件测里一行 mockRejectedValue 就能稳定复现。分层各取所长。
自测三问
- 「测行为不测实现」体现在查询方式上是什么?
vi.mock 对应 Java 侧的哪类手段?
- 为什么 loading / error 分支值得组件测而不是全丢给 E2E?
小结一句话
测用户看得见的交互,mock 边界外的 API——React 组件与 Java Service 一样需要快测守护回归。
延伸