前端测试

单元测试、组件测试、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 更新快照
快照测试不是"银弹"。 如果组件渲染的 HTML 经常变化,快照测试会产生大量误报。建议只对稳定的组件使用快照测试。

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按钮、链接、输入框等有角色的元素
2getByLabelText表单输入框(通过 label 关联)
3getByPlaceholder没有 label 的输入框
4getByText按钮文字、标题、段落
5getByTestId以上都不适用时的兜底方案
// 推荐写法
screen.getByRole("button", { name: "提交" });  // 最推荐
screen.getByLabelText("邮箱地址");
screen.getByPlaceholder("请输入密码");
screen.getByText("欢迎回来");
screen.getByTestId("submit-btn");  // 最后手段
为什么优先用 Role? 因为 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(); // 恢复真实定时器
});
什么时候该 Mock? 只 Mock 你无法控制的依赖(API、定时器、第三方库)。自己写的代码尽量用真实实现,否则测试就是在测 Mock 而不是测代码。

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% 覆盖率,而是让每一行测试都"值回票价"。

常见排错

测试不通过,先看断言,再看报错。 多数情况下是断言写错了,不是代码有 bug。

问题一: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 项目。

1 / X