前端测试
单元测试、组件测试、E2E 测试——守护代码质量的三道防线
🎯 学习目标
学完本章,你将能够:
- 能够使用 Vitest 编写单元测试与组件测试
- 能够使用 Testing Library 进行用户交互测试与查询
- 能够使用 vi.mock 和 vi.spyOn 进行模块替换与函数监听
- 能够配置 Playwright 编写端到端(E2E)测试
- 能够理解测试金字塔与覆盖率指标,制定合理的测试策略
你有没有想过
改了一个按钮的颜色,购物车结算就挂了——怎么才能提前发现?
当你只有 10 个组件时,手动点一遍就能验证。但当你有 100 个组件、50 个页面时,每次改动手动验证就不现实了。自动化测试就是让计算机替你"点一遍"。
前端测试分三层:单元测试测函数、组件测试测交互、E2E 测试测完整流程。本章逐一讲清楚。
测试金字塔
底层多、顶层少——速度快、成本低
/\
/ \ E2E 测试(少量,慢)
/ \ —— Playwright / Cypress
/ \
/ 集成 \ 组件测试(适量,中速)
/ 测试 \ —— Testing Library
/____________\
单元测试(大量,快)
—— Vitest / Jest
测试什么
- 纯函数 / 工具函数 → 单元测试
- 组件渲染、交互 → 组件测试
- 页面流程、关键路径 → E2E 测试
测试原则
- 测试行为,而非实现细节
- 测试用户看到的内容
- 改代码不需要改测试
- 好的测试:快速、独立、可重复
Part 1: 测试配置
安装 Vitest + Testing Library,搭好测试环境。
测试的第一步不是写测试代码,而是搭好运行环境。配置对了,后面写测试才能丝滑。这里我们选择 Vitest——它和 Vite 共享配置,几乎零成本上手。
Vitest 配置
Vite 生态的测试框架,配置几乎零成本
$ npm install -D vitest @testing-library/react @testing-library/jest-dom
$ npm install -D jsdom
$ npm install -D @testing-library/user-event
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom", // 模拟浏览器 DOM
globals: true, // 全局 API(describe, it, expect)
setupFiles: "./test/setup.ts", // 测试初始化文件
css: true, // 处理 CSS
},
});
// test/setup.ts
import "@testing-library/jest-dom";
测试脚本
四个命令覆盖测试全流程
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
| 命令 | 作用 |
|---|---|
npm test | 运行所有测试(监听模式) |
npm run test:ui | 可视化测试界面 |
npm run test:coverage | 生成覆盖率报告 |
Part 2: 单元测试
最底层、最快速——测试纯函数和工具函数。
单元测试是测试金字塔的基础。它的对象是"纯函数"——输入确定、输出确定、没有副作用的函数。运行速度极快(毫秒级),所以你可以放心写很多。
单元测试
输入已知,输出可验证
// utils/format.ts
export function formatPrice(price: number): string {
return price < 0
? `-¥${Math.abs(price).toFixed(2)}`
: `¥${price.toFixed(2)}`;
}
export function truncateText(text: string, maxLen: number): string {
return text.length > maxLen
? text.slice(0, maxLen) + "..."
: text;
}
// utils/format.test.ts
import { describe, it, expect } from "vitest";
import { formatPrice, truncateText } from "./format";
describe("formatPrice", () => {
it("格式化正数", () => {
expect(formatPrice(1999)).toBe("¥1999.00");
});
it("格式化零", () => {
expect(formatPrice(0)).toBe("¥0.00");
});
it("格式化负数", () => {
expect(formatPrice(-100)).toBe("-¥100.00");
});
});
describe("truncateText", () => {
it("短文本不截断", () => {
expect(truncateText("hello", 10)).toBe("hello");
});
it("长文本截断并加省略号", () => {
expect(truncateText("这是一段很长的文本", 5)).toBe("这是一段很...");
});
});
快照测试
捕获渲染输出,任何变化都会被检测到
import { render } from "@testing-library/react";
import UserCard from "./UserCard";
it("匹配快照", () => {
const { container } = render(
<UserCard name="张三" age={25} />
);
expect(container).toMatchSnapshot();
});
快照测试的工作原理:
- 首次运行:生成
.snap文件,记录渲染的 HTML 结构 - 后续运行:比较当前输出与快照,任何差异都会报错
- UI 有意更改:运行
vitest -u更新快照
Part 3: 组件测试
测试金字塔的中间层——验证组件渲染与交互。
组件测试是连接单元测试和 E2E 测试的桥梁。它比单元测试更接近真实用户体验,又比 E2E 测试更快、更稳定。我们把组件测试放在中间层来学,因为它是前端测试中性价比最高的一层。
组件渲染测试
验证组件"画"出了正确的内容
// UserCard.tsx
interface UserCardProps {
name: string;
role: string;
}
export function UserCard({ name, role }: UserCardProps) {
return (
<div className="user-card">
<h2>{name}</h2>
<span data-testid="role">{role}</span>
</div>
);
}
// UserCard.test.tsx
import { render, screen } from "@testing-library/react";
import { UserCard } from "./UserCard";
describe("UserCard", () => {
it("渲染用户名和角色", () => {
render(<UserCard name="张三" role="管理员" />);
// screen 是全局查询入口
expect(screen.getByText("张三")).toBeInTheDocument();
expect(screen.getByTestId("role")).toHaveTextContent("管理员");
});
it("传入不同 props 显示不同内容", () => {
render(<UserCard name="李四" role="编辑" />);
expect(screen.getByText("李四")).toBeInTheDocument();
expect(screen.getByTestId("role")).toHaveTextContent("编辑");
});
});
render() 把组件挂载到一个虚拟 DOM 中。screen 是查询这个虚拟 DOM 的统一入口,所有查询方法都挂在它上面。
用户交互测试
模拟点击、输入,验证组件响应用户操作
// Counter.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
describe("Counter", () => {
it("点击按钮递增计数", async () => {
const user = userEvent.setup();
render(<Counter />);
// 初始值是 0
expect(screen.getByText("计数: 0")).toBeInTheDocument();
// 点击 +1 按钮
await user.click(screen.getByRole("button", { name: "+1" }));
expect(screen.getByText("计数: 1")).toBeInTheDocument();
// 再点一次
await user.click(screen.getByRole("button", { name: "+1" }));
expect(screen.getByText("计数: 2")).toBeInTheDocument();
});
it("点击递减按钮不能小于 0", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "-1" }));
expect(screen.getByText("计数: 0")).toBeInTheDocument();
});
});
await。userEvent 的操作都是异步的,如果不 await,断言会在状态更新前执行,导致测试失败。
Counter 完整示例
被测试的组件长什么样
// Counter.tsx
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => Math.max(0, c - 1));
return (
<div>
<span>计数: {count}</span>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
</div>
);
}
测试要点回顾:
- 初始状态:渲染后验证默认值
- 交互后状态:点击后验证值变化
- 边界条件:计数不能小于 0
- 连续操作:多次点击后结果仍正确
条件渲染测试
验证组件在不同条件下的显示差异
// LoginButton.tsx
export function LoginButton({ isLoggedIn, userName }: Props) {
if (isLoggedIn) {
return <span>欢迎, {userName}</span>;
}
return <button>登录</button>;
}
// LoginButton.test.tsx
import { render, screen } from "@testing-library/react";
import { LoginButton } from "./LoginButton";
it("未登录时显示登录按钮", () => {
render(<LoginButton isLoggedIn={false} />);
expect(screen.getByRole("button", { name: "登录" }))
.toBeInTheDocument();
expect(screen.queryByText("欢迎")).not.toBeInTheDocument();
});
it("已登录时显示用户名", () => {
render(<LoginButton isLoggedIn={true} userName="张三" />);
expect(screen.getByText("欢迎, 张三")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
getBy 找不到会报错,queryBy 找不到返回 null。验证"不存在"时用 queryBy。
查询优先级
选对查询方式,测试更健壮
Testing Library 推荐按以下优先级选择查询方式:
| 优先级 | 查询方式 | 适用场景 |
|---|---|---|
| 1(最高) | getByRole | 按钮、链接、输入框等有角色的元素 |
| 2 | getByLabelText | 表单输入框(通过 label 关联) |
| 3 | getByPlaceholder | 没有 label 的输入框 |
| 4 | getByText | 按钮文字、标题、段落 |
| 5 | getByTestId | 以上都不适用时的兜底方案 |
// 推荐写法
screen.getByRole("button", { name: "提交" }); // 最推荐
screen.getByLabelText("邮箱地址");
screen.getByPlaceholder("请输入密码");
screen.getByText("欢迎回来");
screen.getByTestId("submit-btn"); // 最后手段
getByRole 同时验证了元素的可访问性。如果你的按钮测试通过了,说明屏幕阅读器也能识别这个按钮——一举两得。
Part 4: Vue 测试
Vue 生态的组件测试方案——Test Utils + Testing Library。
如果你用的是 Vue 而不是 React,测试思路完全一样,只是工具不同。Vue 官方推荐 @vue/test-utils 作为底层工具,配合 @testing-library/vue 可以获得更好的查询体验。两者可以按项目需要选择。
Vue 测试配置
安装依赖,配置 Vitest 支持 Vue
$ npm install -D @vue/test-utils @testing-library/vue
$ npm install -D @vitejs/plugin-vue jsdom
// vitest.config.ts
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
test: {
environment: "jsdom",
globals: true,
setupFiles: "./test/setup.ts",
},
});
@vue/test-utils
- Vue 官方维护
- 底层 API,灵活度高
mount()挂载组件wrapper.find()查找元素
@testing-library/vue
- Testing Library 家族
- 面向用户行为的 API
render()渲染组件screen.getBy*查询元素
挂载方式
mount 渲染子组件,shallowMount 不渲染
import { mount, shallowMount } from "@vue/test-utils";
import UserPage from "./UserPage.vue";
// mount:完整渲染,包含子组件
const wrapper1 = mount(UserPage);
// UserPage 内部的 UserCard 也会被渲染
// shallowMount:浅渲染,子组件被替换为占位符
const wrapper2 = shallowMount(UserPage);
// UserCard 不会被渲染,只保留 stub
| 方式 | 子组件 | 速度 | 适用场景 |
|---|---|---|---|
mount | 完整渲染 | 稍慢 | 集成测试,验证组件协作 |
shallowMount | 替换为 stub | 更快 | 单元测试,隔离子组件 |
mount。 shallowMount 会隐藏子组件的 bug,可能导致测试通过但实际运行出错。只有在子组件特别重或特别复杂时才用 shallowMount。
Vue 测试示例
测试一个搜索输入组件
<!-- SearchInput.vue -->
<template>
<div>
<input
v-model="query"
placeholder="搜索..."
@keyup.enter="$emit('search', query)"
/>
<button @click="$emit('search', query)">搜索</button>
<p v-if="query">正在搜索: {{ query }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const query = ref("");
</script>
// SearchInput.test.ts
import { mount } from "@vue/test-utils";
import SearchInput from "./SearchInput.vue";
it("输入文字后显示搜索提示", async () => {
const wrapper = mount(SearchInput);
// 找到 input 并输入文字
const input = wrapper.find("input");
await input.setValue("Vue测试");
// 验证提示文字
expect(wrapper.text()).toContain("正在搜索: Vue测试");
});
it("点击搜索按钮触发 search 事件", async () => {
const wrapper = mount(SearchInput);
await wrapper.find("input").setValue("关键词");
await wrapper.find("button").trigger("click");
// 验证 emit 的事件
expect(wrapper.emitted()).toHaveProperty("search");
expect(wrapper.emitted("search")[0]).toEqual(["关键词"]);
});
it("按回车也触发 search 事件", async () => {
const wrapper = mount(SearchInput);
await wrapper.find("input").setValue("回车搜索");
await wrapper.find("input").trigger("keyup.enter");
expect(wrapper.emitted("search")[0]).toEqual(["回车搜索"]);
});
Part 5: Mock
隔离外部依赖,让测试只关注"自己的代码"。
测试组件时,你不想真的发 HTTP 请求、不想真的读数据库、不想等 setTimeout。Mock 就是给这些外部依赖放一个"替身",让测试跑得快、跑得稳。
vi.mock 模块替换
整个模块替换为假实现
// api.ts —— 真实的 API 模块
export async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// UserProfile.test.tsx
import { vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { UserProfile } from "./UserProfile";
// 整个 api 模块被替换为假实现
vi.mock("./api", () => ({
fetchUser: vi.fn().mockResolvedValue({
id: "1",
name: "张三",
email: "zhangsan@test.com",
}),
}));
it("展示用户信息", async () => {
render(<UserProfile userId="1" />);
// 等待异步数据加载
await waitFor(() => {
expect(screen.getByText("张三")).toBeInTheDocument();
});
});
vi.mock() 会拦截模块导入。写在那里的假函数会在所有测试中生效,除非你在单个测试中用 mockResolvedValueOnce 覆盖。
vi.fn 与 vi.spyOn
创建假函数、监听真函数的调用
// vi.fn() —— 创建一个空的间谍函数
const onClick = vi.fn();
render(<Button onClick={onClick} />);
await user.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalled(); // 被调用了
expect(onClick).toHaveBeenCalledTimes(1); // 调用了一次
// vi.spyOn() —— 监听真实对象上的方法
import { logger } from "./logger";
it("错误时记录日志", () => {
const logSpy = vi.spyOn(logger, "error");
doSomethingBad();
expect(logSpy).toHaveBeenCalledWith("发生了错误");
expect(logSpy).toHaveBeenCalledTimes(1);
logSpy.mockRestore(); // 恢复原始实现
});
vi.fn()
- 创建全新的假函数
- 不依赖任何真实代码
- 适合作为 props 传入
vi.spyOn()
- 监听已有的真实方法
- 记录调用但不改变行为
- 可以 mockReturn 修改返回值
Mock 进阶技巧
模拟 API 调用与定时器
// Mock fetch —— 拦截所有网络请求
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ token: "fake-jwt" }),
});
});
afterEach(() => {
vi.restoreAllMocks(); // 每个测试后恢复
});
// vi.useFakeTimers —— 控制时间
import { vi } from "vitest";
it("3 秒后显示提示", () => {
vi.useFakeTimers();
render(<Toast message="保存成功" />);
// 刚渲染时提示可见
expect(screen.getByText("保存成功")).toBeInTheDocument();
// 快进 3000ms
vi.advanceTimersByTime(3000);
// 提示消失
expect(screen.queryByText("保存成功")).not.toBeInTheDocument();
vi.useRealTimers(); // 恢复真实定时器
});
Part 6: 异步测试
前端到处是异步——API 请求、定时器、动画回调。
异步代码的测试和同步不一样。你不能在数据还没回来时就断言。Vitest 和 Testing Library 提供了专门的工具来处理异步场景。
waitFor 与 findBy
等待异步操作完成后再断言
// waitFor —— 轮询直到断言通过
import { waitFor } from "@testing-library/react";
it("加载完成后显示数据", async () => {
render(<UserList />);
// 初始状态:显示加载中
expect(screen.getByText("加载中...")).toBeInTheDocument();
// 等待数据出现(最多等 1 秒,每 50ms 检查一次)
await waitFor(() => {
expect(screen.getByText("张三")).toBeInTheDocument();
expect(screen.getByText("李四")).toBeInTheDocument();
});
});
// findBy —— 异步版的 getBy,自带等待
it("findBy 用法", async () => {
render(<UserList />);
// findBy 本身就是 Promise,自动等待元素出现
const user = await screen.findByText("张三");
expect(user).toBeInTheDocument();
});
findBy = getBy + waitFor 的简写。如果只等一个元素出现,用 findBy 更简洁。如果要等多个条件或做复杂断言,用 waitFor。
异步测试实战
测试加载状态、成功、失败三种情况
// UserList.test.tsx
import { vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserList } from "./UserList";
// Mock API
vi.mock("./api", () => ({
fetchUsers: vi.fn(),
}));
import { fetchUsers } from "./api";
it("加载中 → 成功显示用户列表", async () => {
vi.mocked(fetchUsers).mockResolvedValue([
{ id: 1, name: "张三" },
{ id: 2, name: "李四" },
]);
render(<UserList />);
// 1. 加载状态
expect(screen.getByText("加载中...")).toBeInTheDocument();
// 2. 数据加载完成
await waitFor(() => {
expect(screen.getByText("张三")).toBeInTheDocument();
expect(screen.getByText("李四")).toBeInTheDocument();
});
// 3. 加载提示消失
expect(screen.queryByText("加载中...")).not.toBeInTheDocument();
});
it("API 失败时显示错误信息", async () => {
vi.mocked(fetchUsers).mockRejectedValue(new Error("网络错误"));
render(<UserList />);
await waitFor(() => {
expect(screen.getByText("加载失败,请重试"))
.toBeInTheDocument();
});
});
Part 7: 覆盖率
量化测试质量——知道哪些代码还没被测到。
覆盖率是度量指标,不是目标。写覆盖率报告是为了发现遗漏,而不是为了凑数字。
覆盖率指标
四种指标从不同角度衡量测试充分度
| 指标 | 含义 | 示例 |
|---|---|---|
| 行覆盖率 | 执行到的代码行 | 100 行代码跑了 80 行 → 80% |
| 分支覆盖率 | 执行到的 if/else 分支 | 2 个分支跑了 1 个 → 50% |
| 函数覆盖率 | 被调用的函数 | 10 个函数调用了 8 个 → 80% |
| 语句覆盖率 | 执行到的语句数 | 类似行覆盖率,但更精确 |
// vitest.config.ts 中配置覆盖率
export default defineConfig({
test: {
coverage: {
provider: "istanbul", // 或 "v8"
reporter: ["text", "html", "lcov"],
thresholds: {
lines: 80,
branches: 75,
functions: 80,
statements: 80,
},
// 只统计 src 目录下的业务代码
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/types/**"],
},
},
});
80% 规则:大多数项目把覆盖率门槛设在 80%。核心模块(支付、权限)可以要求 95%+,工具类可以 100%。但不要为了数字而写无意义的测试。
Part 8: E2E 测试
模拟真实用户操作,测试完整页面流程。
E2E 测试站在用户视角,从打开浏览器到完成操作,全程自动化。它最接近真实使用场景,但也最慢、最脆弱。所以只给关键路径写 E2E 测试。
Playwright 配置
微软出品的 E2E 测试框架,开箱即用
$ npm init playwright@latest
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
baseURL: "http://localhost:3000",
use: { browserName: "chromium" },
webServer: {
command: "npm run dev",
port: 3000,
reuseExistingServer: true,
},
});
Playwright 会自动启动开发服务器、打开浏览器、运行测试、关闭浏览器。整个过程全自动。
E2E 测试示例
模拟用户登录的完整流程
// e2e/login.spec.ts
import { test, expect } from "@playwright/test";
test("用户登录流程", async ({ page }) => {
// 1. 导航到登录页
await page.goto("/login");
// 2. 填写表单
await page.fill('[data-testid="email"]', "test@example.com");
await page.fill('[data-testid="password"]', "password123");
// 3. 点击登录按钮
await page.click('[data-testid="login-button"]');
// 4. 验证跳转到首页
await expect(page).toHaveURL("/dashboard");
await expect(page.locator('[data-testid="welcome"]'))
.toContainText("欢迎");
});
test("登录失败显示错误信息", async ({ page }) => {
await page.goto("/login");
await page.fill('[data-testid="email"]', "wrong@example.com");
await page.fill('[data-testid="password"]', "wrong");
await page.click('[data-testid="login-button"]');
// 验证错误提示
await expect(page.locator('[data-testid="error"]'))
.toBeVisible();
await expect(page.locator('[data-testid="error"]'))
.toContainText("邮箱或密码错误");
});
data-testid
给元素加上测试用的"身份证号"
// 组件中添加 data-testid
<input data-testid="email" type="email" />
<button data-testid="login-button">登录</button>
<div data-testid="error" role="alert">错误信息</div>
为什么用 data-testid 而不是 class 或 id?
| 选择器 | 优点 | 缺点 |
|---|---|---|
data-testid | 专为测试设计,不会被误改 | 需要手动添加 |
| class | 不需要额外属性 | 样式重构时会频繁变化 |
| id | 唯一性好 | 一个页面只能有一个 |
| 文本内容 | 最接近用户视角 | 多语言项目会变化 |
CI 中的测试
每次提交代码自动跑测试,拦截问题于上线前
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx vitest --run # 非监听模式
- run: npx vitest --coverage
# 可上传 coverage 到 Codecov
e2e-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test # CI 模式运行
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
关键配置
vitest --run:CI 用非监听模式npm ci:按 lock 文件安装,结果可复现cache: "npm":缓存依赖加速构建
失败时保存证据
- Playwright 自动截图和录屏
- 测试失败时上传 report artifact
- 覆盖率报告上传到 Codecov
测试策略总结
核心业务必须有测试,非核心按需覆盖
必须有测试
- 核心业务逻辑(支付、计算)
- 关键用户流程(登录、注册、下单)
- 复杂的工具函数(格式化、校验)
按需测试
- 简单的展示组件
- 纯静态页面
- 第三方库的封装层
目标:测试金字塔——底层单元测试多而快,顶层 E2E 测试少而全。不要追求 100% 覆盖率,而是让每一行测试都"值回票价"。
常见排错
问题一:expect(element).toBeInTheDocument() 报"不是函数"
原因:没有引入 @testing-library/jest-dom。
修复:在 test/setup.ts 中添加 import "@testing-library/jest-dom";。
问题二:E2E 测试报"page.goto timeout"
原因:开发服务器没有启动,或者端口配置不对。
修复:确认 playwright.config.ts 中的 webServer 配置正确,端口和启动命令匹配。
问题三:快照测试失败但 UI 没变
原因:可能是组件的 class 名或属性发生了变化(比如 CSS Modules 的哈希值变了)。
修复:检查差异内容,确认是有意更改后运行 vitest -u 更新快照。
问题四:screen.getByText 报"找到多个匹配"
原因:页面上有多个相同文本的元素。
修复:改用 screen.getAllByText 获取数组,或者用更精确的查询如 getByRole。
问题五:userEvent.click 后断言失败
原因:忘记 await,userEvent 操作是异步的。
修复:所有 userEvent 调用前加 await,确保用 userEvent.setup() 初始化。
问题六:waitFor 超时
原因:异步操作耗时太长,或者 Mock 没有正确设置导致数据永远不来。
修复:检查 Mock 是否正确返回了数据,必要时增加超时时间 waitFor(fn, { timeout: 5000 })。
试一试
练习一
任务:为以下 capitalize 函数编写单元测试,覆盖空字符串、单个字符、普通字符串三种情况。
function capitalize(str: string): string {
if (!str) return "";
return str[0].toUpperCase() + str.slice(1);
}
// 你的测试代码:
参考答案
import { describe, it, expect } from "vitest";
describe("capitalize", () => {
it("空字符串返回空字符串", () => {
expect(capitalize("")).toBe("");
});
it("单个字符转大写", () => {
expect(capitalize("a")).toBe("A");
});
it("普通字符串首字母大写", () => {
expect(capitalize("hello")).toBe("Hello");
});
});
练习二
任务:用 Playwright 编写一个 E2E 测试,验证首页能正常加载,并且包含标题文字。
// 提示:使用 page.goto 和 expect(page.locator)
// 你的测试代码:
参考答案
import { test, expect } from "@playwright/test";
test("首页正常加载", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).toBeVisible();
});
练习三
任务:为以下 TodoItem 组件编写组件测试,验证:点击复选框调用 onToggle、done 为 true 时文字有删除线、点击删除按钮调用 onDelete。
interface Props {
text: string; done: boolean;
onToggle: () => void; onDelete: () => void;
}
export function TodoItem({ text, done, onToggle, onDelete }: Props) {
return (
<li>
<input type="checkbox" checked={done}
onChange={onToggle} />
<span style={{ textDecoration:
done ? "line-through" : "none" }}>
{text}
</span>
<button onClick={onDelete}>删除</button>
</li>
);
}
参考答案
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
it("点击复选框调用 onToggle", async () => {
const onToggle = vi.fn();
render(<TodoItem text="学测试" done={false}
onToggle={onToggle} onDelete={vi.fn()} />);
await userEvent.click(screen.getByRole("checkbox"));
expect(onToggle).toHaveBeenCalledOnce();
});
it("done=true 时文字有删除线", () => {
render(<TodoItem text="完成" done={true}
onToggle={vi.fn()} onDelete={vi.fn()} />);
expect(screen.getByText("完成"))
.toHaveStyle("text-decoration: line-through");
});
it("点击删除调用 onDelete", async () => {
const onDelete = vi.fn();
render(<TodoItem text="删除" done={false}
onToggle={vi.fn()} onDelete={onDelete} />);
await userEvent.click(
screen.getByRole("button", { name: "删除" })
);
expect(onDelete).toHaveBeenCalledOnce();
});
📝 要点总结
本章核心知识点
测试金字塔
单元测试 → 集成测试 → E2E 测试,成本与信心权衡
Vitest
describe/it/expect 结构、断言方法、快照测试
组件测试
render、fireEvent、查询优先级、waitFor 异步
Mock 技巧
vi.fn 模拟函数、vi.mock 模块替换、vi.spyOn 监听
E2E 与 CI
Playwright 配置、data-testid、CI 集成测试
下一章
测试搞定了。下一章学习 UI 框架集成——Tailwind CSS、Ant Design、Element Plus,把 UI 框架优雅地接入 TS 项目。