CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
软件测试入门/课时/前端单元测试
RawBlame
课时目录12 节

软件测试入门

  1. 01测试金字塔
  2. 02JUnit 5 基础
  3. 03Mockito 与 Mock
  4. 04Spring Boot 测试
  5. 05MockMvc 接口测试
  6. 06集成测试
  7. 07TDD 与可维护性
  8. 08前端单元测试
  9. 09E2E 与 Playwright
  10. 10覆盖率与质量门禁
  11. 11CI 中的测试
  12. 12综合练习

前端单元测试

Vitest/Jest、组件测试直觉。

35 分钟

前端单元测试

这一课你将能做到

  1. 用 Vitest + Testing Library 写出组件「渲染」与「点击交互」测试,查询全走 role / 文本
  2. 用 vi.mock 隔离 API 模块与 next/navigation,不打真实 backend
  3. 对照 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 @Testtest() / 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>
  );
}

测试——vi.mock 把网络边界挡在门外:

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 同命令

改组件文案时,按可见文本查询的测试会逼你同步更新——这是特性:它防止「测试测的是去年文案」。

讲清楚要点

  1. 用户不关心 state 变量名:查询用 role / 文本 / label,实现细节一变行为测仍稳;断言 data-verdict 之外的内部状态是脆弱测试的起点。
  2. 网络是边界,单元层必须 mock:vi.mock 挡掉 API 模块,否则套件依赖 backend 起停,并行必炸。
  3. 异步 UI 用 findBy / waitFor:它们内置重试,固定 sleep(1000) 是 flaky 制造机。
  4. loading / error / 空列表值得专门用例:这些分支 E2E 很难稳定触发(要真断网),组件测是最便宜的守护位。
  5. 优先 getByRole:可访问性查询更接近用户感知,还顺带逼出更好的 a11y 语义(role="status" 就是这么来的)。
  6. 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) 等渲染flakyfindBy / waitFor
只测渲染不测错误分支线上白屏没人守补 error / empty 用例
选择器绑 class 名样式重构误伤getByRole / label

卡点急救

  1. 报 document is not defined:没配 jsdom 环境,vitest.config.ts 加 test.environment: "jsdom"。
  2. getByRole 找不到元素:screen.debug() 打印 DOM,多半是元素根本没渲染(异步未等)或语义缺失;异步用 findByRole,语义缺就改组件加 role/aria-label。
  3. 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 就能稳定复现。分层各取所长。

自测三问

  1. 「测行为不测实现」体现在查询方式上是什么?
  2. vi.mock 对应 Java 侧的哪类手段?
  3. 为什么 loading / error 分支值得组件测而不是全丢给 E2E?

小结一句话

测用户看得见的交互,mock 边界外的 API——React 组件与 Java Service 一样需要快测守护回归。

延伸

  • Testing Library · Guiding Principles
  • Vitest 官方文档
  • 下一课:test-e2e 用 Playwright 把「注册 → 提交判题」的真实浏览器路径守起来。
加载本节练习…
← 上一课TDD 与可维护性下一课 →E2E 与 Playwright