很多人第一次接触前端测试,会同时看到 Vitest、Testing Library、jsdom、Mock、userEvent 等术语。真正困难的往往不是 API,而是不知道这些工具分别解决什么问题。
这篇文章从一个“文章阅读时间”功能出发,逐层建立完整的理解:先测试纯函数,再测试 React 组件,最后处理异步数据和失败状态。
一、测试的本质:把需求变成可执行代码
假设产品规则是:每 200 个单词算一分钟,不足一分钟也显示一分钟。
export function calculateReadingTime(words: number): number {
if (words < 0) {
throw new Error("words 不能是负数");
}
return Math.max(1, Math.ceil(words / 200));
}测试的基本结构是:
准备输入 → 执行代码 → 检查结果
Arrange Act Assert使用 Vitest 表达:
import { expect, test } from "vitest";
import { calculateReadingTime } from "./reading-time";
test("400 个单词需要 2 分钟", () => {
const words = 400;
const result = calculateReadingTime(words);
expect(result).toBe(2);
});Vitest 负责发现和执行测试,expect() 负责比较实际结果与预期结果。测试失败时,我们得到的不是模糊的“页面不对”,而是明确的输入、实际值和预期值。
二、怎样选择有效测试用例
输入可能有无数个,不可能逐一测试。更有效的方法是选择三类代表值:
正常值 + 边界值 + 异常值阅读时间以 200 为分界点,真正值得测试的是边界两侧:
| 单词数 | 预期分钟数 | 作用 |
|---|---|---|
| 0 | 1 | 产品约定的最小显示值 |
| 199 | 1 | 边界之前 |
| 200 | 1 | 正好位于边界 |
| 201 | 2 | 边界之后 |
| 400 | 2 | 普通整数结果 |
可以使用表格测试减少重复:
import { describe, expect, test } from "vitest";
describe("calculateReadingTime", () => {
test.each([
[0, 1],
[199, 1],
[200, 1],
[201, 2],
[400, 2]
])("%i 个单词返回 %i 分钟", (words, expected) => {
expect(calculateReadingTime(words)).toBe(expected);
});
test("负数输入应该抛出错误", () => {
expect(() => calculateReadingTime(-1)).toThrow(
"words 不能是负数"
);
});
});201 → 2 是很关键的用例。如果有人误把 Math.ceil() 改成 Math.floor(),199 和 200 仍可能因为 Math.max(1, ...) 而通过,201 却会立即暴露错误。
测试在这里也是一份可执行需求:0 → 1 不是数学定理,而是产品决定。需求改变时,测试和实现也应一起改变。
三、从函数测试走向组件测试
纯函数正确,并不代表 React 页面一定正确显示。
type ReadingTimeProps = {
words: number;
};
export function ReadingTime({ words }: ReadingTimeProps) {
const minutes = calculateReadingTime(words);
return <p>预计阅读 {minutes} 分钟</p>;
}组件测试需要三个角色协作:
Vitest → 启动、执行和报告测试
jsdom → 在 Node 中提供 document、window 和 DOM
Testing Library → 挂载 React,并按用户视角查询和操作页面测试代码:
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { ReadingTime } from "./ReadingTime";
test("显示计算后的阅读时间", () => {
render(<ReadingTime words={201} />);
expect(
screen.getByText("预计阅读 2 分钟")
).not.toBeNull();
});render() 默认创建一个容器并把它添加到 jsdom 的 document.body,再把 React 组件挂载进去。它不是 Vite 的构建,也没有启动真实浏览器或绘制真实像素。
jsdom 创建 DOM
↓
render 挂载 React
↓
组件生成 DOM 节点
↓
screen 查询 document.body四、getBy、queryBy 与 findBy
Testing Library 的三类查询分别表达不同时间语义:
getBy → 元素现在应该存在
queryBy → 元素现在应该不存在
findBy → 元素稍后应该出现getBy
screen.getByRole("button", { name: "加载文章" });找不到时立即抛错,适合查询已经存在的元素。
queryBy
expect(screen.queryByText("文章内容")).toBeNull();找不到时返回 null,适合确认元素不存在。
findBy
const article = await screen.findByText("文章内容");它返回 Promise,并在超时前重复查询,适合等待接口完成后的 DOM 更新。可以把它理解为 getBy 与 waitFor 的组合。
五、为什么用户操作需要 await
下面是一个可以展开全文的组件:
export function ExpandableArticle() {
const [expanded, setExpanded] = useState(false);
return (
<article>
<button onClick={() => setExpanded(true)}>
展开全文
</button>
{expanded && <p>这里是隐藏内容</p>}
</article>
);
}测试:
import userEvent from "@testing-library/user-event";
test("点击后显示隐藏内容", async () => {
const user = userEvent.setup();
render(<ExpandableArticle />);
expect(screen.queryByText("这里是隐藏内容")).toBeNull();
await user.click(
screen.getByRole("button", { name: "展开全文" })
);
expect(screen.getByText("这里是隐藏内容")).not.toBeNull();
});user.click() 会模拟一系列交互事件并返回 Promise。await 不会阻塞整个 JavaScript 线程,它只暂停当前异步测试函数;Promise 完成后,await 后面的代码通过微任务恢复执行。
需要精确区分:Promise 是异步结果的容器,不是微任务本身;.then() 回调以及 await 的后续代码才会通过微任务队列执行。
六、Mock:控制外部依赖
组件如果直接访问真实服务器,测试会受到网络、数据和后端状态影响。更稳定的做法是把加载函数作为依赖传入:
type ArticleLoaderProps = {
loadArticle: () => Promise<string>;
};成功测试:
test("加载成功后显示文章", async () => {
const user = userEvent.setup();
const loadArticle = vi
.fn()
.mockResolvedValue("这是文章内容");
render(<ArticleLoader loadArticle={loadArticle} />);
await user.click(
screen.getByRole("button", { name: "加载文章" })
);
expect(loadArticle).toHaveBeenCalledTimes(1);
expect(
await screen.findByText("这是文章内容")
).not.toBeNull();
});失败测试:
test("加载失败后显示错误", async () => {
const user = userEvent.setup();
const loadArticle = vi
.fn()
.mockRejectedValue(new Error("服务器不可用"));
render(<ArticleLoader loadArticle={loadArticle} />);
await user.click(
screen.getByRole("button", { name: "加载文章" })
);
expect(
await screen.findByText("文章加载失败")
).not.toBeNull();
});Mock 同时解决三个问题:控制返回结果、记录调用情况、隔离真实外部系统。不过 Mock 也可能与真实接口不一致,因此还需要少量集成测试或端到端测试验证系统连接。
七、用状态机避免矛盾状态
异步组件常见四种状态:
idle → loading → success
↘ error相比三个互相独立的布尔值,更可靠的表达是可辨识联合类型:
type LoadState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; content: string }
| { status: "error"; message: string };当代码判断:
if (state.status === "error") {
console.log(state.message);
}TypeScript 会排除其他成员,并保证 message 存在。这样就无法表达“同时加载成功和失败”之类的矛盾状态。
八、测试之间必须隔离
每个测试都应该满足:单独运行、一起运行、调整顺序、重复运行时结果一致。
初学阶段最简单的方式,是在每个测试内部创建新的 Mock。如果需要复用,可以在 beforeEach() 中重新创建。
三种常见清理方法:
mockClear → 清除调用记录,保留 Mock 实现
mockReset → 清除调用记录和 Mock 实现
mockRestore → 恢复被 spyOn 替换的真实实现如果只想清除调用次数,同时保留 mockResolvedValue(),应该使用 mockClear()。
九、表单测试应从用户视角出发
test("提交文章标题", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ArticleForm onSubmit={onSubmit} />);
await user.type(
screen.getByRole("textbox", { name: "文章标题" }),
"测试入门"
);
await user.click(
screen.getByRole("button", { name: "创建文章" })
);
expect(onSubmit).toHaveBeenCalledWith({
title: "测试入门"
});
});优先使用角色、标签和可见名称查询元素,而不是容易随重构变化的 CSS 类名。这种写法也会推动组件保持良好的可访问性。
总结
这套组件测试体系的职责可以压缩成四句话:
Vitest 执行测试。
jsdom 提供模拟 DOM。
render 把 React 挂载到 DOM。
Testing Library 按用户视角操作和查询组件。纯函数规则应由快速单元测试详细覆盖;组件测试负责验证状态、交互和界面连接。下一篇将继续讨论:什么时候模拟 DOM 已经不够,为什么关键用户流程需要 Playwright 和真实浏览器。

没有回应