从单元测试到组件测试:Vitest、Testing Library 与 jsdom

从测试用例设计出发,理解 Vitest、React Testing Library、jsdom、异步查询与 Mock 的职责边界。

很多人第一次接触前端测试,会同时看到 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 为分界点,真正值得测试的是边界两侧:

单词数预期分钟数作用
01产品约定的最小显示值
1991边界之前
2001正好位于边界
2012边界之后
4002普通整数结果

可以使用表格测试减少重复:

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

四、getByqueryByfindBy

Testing Library 的三类查询分别表达不同时间语义:

getBy   → 元素现在应该存在
queryBy → 元素现在应该不存在
findBy  → 元素稍后应该出现

getBy

screen.getByRole("button", { name: "加载文章" });

找不到时立即抛错,适合查询已经存在的元素。

queryBy

expect(screen.queryByText("文章内容")).toBeNull();

找不到时返回 null,适合确认元素不存在。

findBy

const article = await screen.findByText("文章内容");

它返回 Promise,并在超时前重复查询,适合等待接口完成后的 DOM 更新。可以把它理解为 getBywaitFor 的组合。

五、为什么用户操作需要 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 和真实浏览器。

参考资料

没有回应

    发表回复

    您的邮箱地址不会被公开。 必填项已用 * 标注