自动化测试踩坑记录
这次接手一个中型 React 项目的自动化测试体系建设,从零开始搭建,。
项目是一个 React + TypeScript 的管理后台,用了 Next.js 作为框架,前后端分离。
场景和问题
项目是一个 React + TypeScript 的管理后台,用了 Next.js 作为框架,前后端分离。接手时的情况:
- 几个散落的 Jest 单元测试,放在
__tests__目录下 - 没有 CI 集成,测试结果全靠本地跑
- 没有覆盖率要求,发版靠人工点一遍
- 每次改代码都要担心会不会引出其他问题
手工回归的问题很快暴露:改了一个按钮的文案,结果把某个弹窗的关闭功能搞挂了,但没人发现,直接发到生产。第二天用户反馈,回滚,修 bug,再发版。这一套下来折腾了半天,用户体验也差。
从单元测试开始
单元测试是最容易上手的,也是最容易被写废的。一开始我直接用项目里的 Jest,写了一堆"字符串相等"“数组长度正确"这种没什么意义的用例。跑起来都绿,覆盖率也好看,但对实际质量没什么帮助。
后来意识到一个问题:单元测试应该测试行为,不是测试实现。
比如一个计算折扣的函数:
// utils/discount.ts
export function calculateDiscount(price: number, rule: { type: 'percentage' | 'fixed', value: number }) {
if (price <= 0) throw new Error('Price must be positive');
if (rule.type === 'percentage') {
if (rule.value < 0 || rule.value > 100) throw new Error('Percentage must be between 0 and 100');
return price * (1 - rule.value / 100);
} else {
if (rule.value < 0) throw new Error('Fixed discount cannot be negative');
return Math.max(0, price - rule.value);
}
}
测试用例写成这样:
// utils/__tests__/discount.test.ts
import { calculateDiscount } from '../discount';
describe('calculateDiscount', () => {
describe('正常情况', () => {
it('百分比折扣正确计算', () => {
expect(calculateDiscount(100, { type: 'percentage', value: 20 })).toBe(80);
});
it('固定金额折扣正确计算', () => {
expect(calculateDiscount(100, { type: 'fixed', value: 30 })).toBe(70);
});
it('折扣不超过原价', () => {
expect(calculateDiscount(50, { type: 'fixed', value: 60 })).toBe(0);
});
});
describe('异常情况', () => {
it('负价格抛出错误', () => {
expect(() => calculateDiscount(-10, { type: 'percentage', value: 20 })).toThrow('Price must be positive');
});
it('百分比超出范围抛出错误', () => {
expect(() => calculateDiscount(100, { type: 'percentage', value: 120 })).toThrow('Percentage must be between 0 and 100');
});
});
});
这里的关键是:测试用例覆盖了正常路径和异常路径,用 describe 分组,用 it 描述具体行为,而不是只断言返回值。后续改实现细节时,只要行为不变,测试就不会失败。
从 Jest 到 Vitest
项目用的是 Vite,后来发现 Vitest 的速度确实比 Jest 快不少。迁移的过程不算复杂,但踩过一个坑:Jest 的 mock 函数用法和 Vitest 有细微差异。
// Jest 写法
jest.mock('../api', () => ({
fetchUser: jest.fn(() => Promise.resolve({ id: 1, name: 'Test' }))
}));
// Vitest 写法(在 vitest.config.ts 里配置)
import { vi } from 'vitest';
vi.mock('../api', () => ({
fetchUser: vi.fn(() => Promise.resolve({ id: 1, name: 'Test' }))
}));
还有一个常见的坑:测试里用了定时器,不处理的话会一直挂在那里。Jest 有 jest.useFakeTimers(),Vitest 有 vi.useFakeTimers(),用之前一定要看文档,否则 mock 了但时间不往前走,测试永远不结束。
import { vi } from 'vitest';
it('延迟 1 秒后显示成功消息', async () => {
vi.useFakeTimers();
const showMessage = vi.fn();
await doSomething(showMessage);
vi.advanceTimersByTime(1000);
expect(showMessage).toHaveBeenCalledWith('Success');
vi.useRealTimers(); // 恢复真实定时器,避免影响其他测试
});
集成测试和 mock 策略
单元测试搞到一定程度,开始碰到边界:组件之间、模块之间的交互,单测覆盖不到。比如一个表单提交按钮,点了之后会调用 API、显示 loading、刷新列表。这一串流程单测能测,但要 mock 很多东西,而且容易测得不够真实。
集成测试的思路是:让组件和它的依赖真正跑起来,但把外部服务(API、数据库、第三方 SDK)mock 掉。
一个常见的坑:mock 太彻底,导致测试跑过了,但真实环境跑挂了。比如 API 返回的字段结构变了,但测试里 mock 的数据还是旧结构,测试照样绿,线上直接报错。
后来定了几个 mock 原则:
- 只 mock 真正需要隔离的外部依赖(API、数据库、第三方服务)
- 不要 mock 自己的模块,除非这个模块真的不依赖外部
- mock 数据要尽可能接近真实响应,包括边界情况(空数组、null、异常码)
// 不错的 mock 写法:尽量接近真实响应
vi.mock('../api', () => ({
fetchOrders: vi.fn(() => Promise.resolve({
data: [],
total: 0,
code: 200,
message: 'Success'
})),
// 测试异常情况
fetchOrdersWithError: vi.fn(() => Promise.reject(new Error('Network error')))
}));
// 不太好的 mock 写法:太简单,缺少真实响应的细节
vi.mock('../api', () => ({
fetchOrders: vi.fn(() => Promise.resolve([]))
}));
E2E 测试的选型:Cypress 还是 Playwright
E2E 测试一直是个争议点。好处是真实模拟用户操作,从登录到下单,整个流程走一遍,发现问题快。坏处是慢、不稳定、维护成本高。
一开始用 Cypress,写起来确实顺滑,浏览器直接跑,能看得到每一步。但很快踩坑:异步加载的内容测不稳,有时候跑过了,有时候超时。Cypress 的 cy.wait(1000) 这种硬等非常不推荐,但有时候不用还真不行。
// Cypress 写法
describe('订单流程', () => {
it('从登录到下单成功', () => {
cy.visit('/login');
cy.get('[data-testid="username-input"]').type('testuser');
cy.get('[data-testid="password-input"]').type('password123');
cy.get('[data-testid="login-button"]').click();
cy.url().should('include', '/dashboard');
cy.get('[data-testid="orders-link"]').click();
cy.get('[data-testid="new-order-button"]').click();
// 这里容易超时,因为数据加载慢
cy.get('[data-testid="product-list"]', { timeout: 10000 }).should('be.visible');
});
});
后来换成 Playwright,稳定性好一些。Playwright 的 waitForSelector 比 Cypress 的 should('be.visible') 更可靠,而且支持多浏览器并行跑。
// Playwright 写法
import { test, expect } from '@playwright/test';
test('从登录到下单成功', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('username-input').fill('testuser');
await page.getByTestId('password-input').fill('password123');
await page.getByTestId('login-button').click();
await expect(page).toHaveURL(/\/dashboard/);
await page.getByTestId('orders-link').click();
await page.getByTestId('new-order-button').click();
// waitFor 比 should 稳定
await page.waitForSelector('[data-testid="product-list"]', { timeout: 10000 });
await expect(page.getByTestId('product-list')).toBeVisible();
});
E2E 测试的另一个坑是环境隔离。最好在专门的测试环境跑,用测试数据库,避免污染生产数据。一开始没注意,用同一个数据库,E2E 测试跑了半天,发现生产环境里多了一堆测试订单。
测试金字塔的实践落地
测试金字塔说得很多:单元测试最多,集成测试次之,E2E 测试最少。但落地的时候容易走偏:要么全写单元测试,要么指望 E2E 覆盖一切。
实际项目里,这个比例大概是 70/20/10:
- 单元测试覆盖核心逻辑、算法、工具函数,成本低,跑得快
- 集成测试覆盖模块间交互、API 调用、数据流转
- E2E 测试只覆盖核心流程(登录、下单、支付),其他的别勉强
用一个简单的流程图说明:
关键不是数字,而是覆盖率和维护成本的平衡。E2E 测试写多了,每次改个按钮位置,一堆测试挂掉,改起来比写代码还费劲。
覆盖率的真相
Jest 和 Vitest 都能生成覆盖率报告,一开始我看覆盖率指标看得比较重,想冲到 90% 以上。后来发现这是个坑:为了提高覆盖率,写了一堆测试"字符串长度是否正确"“空数组返回什么"这种没意义的用例。
覆盖率只是一个参考,不是目标。真正有用的是这些:
- 核心业务逻辑一定要有测试覆盖
- 复杂算法、边界情况要有测试覆盖
- 经常改的代码要有测试覆盖
# 生成覆盖率报告
npm run test:coverage
# 查看未覆盖的行,判断是否值得补测试
open coverage/lcov-report/index.html
有一个实用的技巧:在 CI 里配置覆盖率阈值,低于某个值就失败。但阈值要合理,不要一开始就定太高。我这边是先设 60%,稳定后慢慢提,最后停在 80% 左右。
# .github/workflows/test.yml
- name: Run tests
run: npm run test:coverage
- name: Check coverage
run: |
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage is below 80%"
exit 1
fi
踩过的坑和解决方案
1. 异步测试不稳定
现象:测试有时候通过,有时候超时,完全随机。
原因:没等异步操作完成就断言。
解决:用 async/await + 等待条件,而不是硬等时间。
// 错误写法
it('异步加载完成', () => {
loadData();
expect(screen.getByText('Success')).toBeInTheDocument(); // 可能还没加载完
});
// 正确写法
it('异步加载完成', async () => {
await loadData();
await waitFor(() => expect(screen.getByText('Success')).toBeInTheDocument());
});
2. Mock 失效
现象:明明 mock 了函数,但真实函数还是被调用了。
原因:mock 的位置不对,或者模块已经提前被加载。
解决:在模块被 import 之前就 mock。
// 错误写法:import 先执行,mock 无效
import { fetchUser } from '../api';
vi.mock('../api');
it('测试 fetchUser', () => {
// 这里调用的还是真实函数
});
// 正确写法:mock 在最前面
vi.mock('../api');
import { fetchUser } from '../api';
3. 环境变量处理
现象:测试跑不过,提示环境变量未定义。
原因:测试环境没加载 .env 文件。
解决:在测试配置里加载环境变量。
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import dotenv from 'dotenv';
dotenv.config({ path: '.env.test' });
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts']
}
});
4. 测试数据库隔离
现象:E2E 测试跑了半天,发现生产数据被污染。
原因:E2E 测试用了同一个数据库实例。
解决:用专门的测试数据库,每次跑完清理。
// playwright.config.ts
export default defineConfig({
use: {
baseURL: process.env.TEST_BASE_URL || 'http://localhost:3000',
// 其他配置
}
});
// 测试前置清理
test.beforeEach(async ({ page }) => {
await page.request.post('/api/test/reset-database');
});
收尾
这套自动化测试体系跑起来后,最大的感受是安心。改代码的时候,只要测试跑不过就说明有问题,不用猜。发版前跑一遍全量测试,15 分钟就能知道这次改动会不会搞出大问题。
但测试不是银弹,成本也得考虑。小项目、快速迭代的项目,全量测试可能跟不上节奏。关键是在成本和收益之间找个平衡:核心逻辑一定有测试,边缘情况看情况补,E2E 别写太多。
写测试确实要多花时间,但比起半夜起来修生产 bug,这个时间还是值得花的。
最后说一句,这篇文章里的代码和配置都基于实际项目改写,如果发现哪里不对,可能是当时环境不太一样。具体工具版本可以参考项目的 package.json,大致是 React 18、Next.js 14、Vitest 1.x、Playwright 1.x。
可用性说明:本文发布于 2021 年 1 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-自动化测试踩坑记录(https://blog.thinkmoon.cn/post/105-automated-testing-unit-e2e-practice-guide/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。