自动化测试踩坑记录

这次接手一个中型 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 原则:

  1. 只 mock 真正需要隔离的外部依赖(API、数据库、第三方服务)
  2. 不要 mock 自己的模块,除非这个模块真的不依赖外部
  3. 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 测试只覆盖核心流程(登录、下单、支付),其他的别勉强

用一个简单的流程图说明:

graph TD A[测试金字塔] --> B[单元测试 70%] A --> C[集成测试 20%] A --> D[E2E测试 10%] B --> B1[核心逻辑与算法] B --> B2[工具函数] B --> B3[组件独立行为] C --> C1[API调用] C --> C2[模块间交互] C --> C3[数据流转] D --> D1[登录流程] D --> D2[下单流程] D --> D3[支付流程]

关键不是数字,而是覆盖率和维护成本的平衡。E2E 测试写多了,每次改个按钮位置,一堆测试挂掉,改起来比写代码还费劲。

覆盖率的真相

Jest 和 Vitest 都能生成覆盖率报告,一开始我看覆盖率指标看得比较重,想冲到 90% 以上。后来发现这是个坑:为了提高覆盖率,写了一堆测试"字符串长度是否正确"“空数组返回什么"这种没意义的用例。

覆盖率只是一个参考,不是目标。真正有用的是这些:

  1. 核心业务逻辑一定要有测试覆盖
  2. 复杂算法、边界情况要有测试覆盖
  3. 经常改的代码要有测试覆盖
# 生成覆盖率报告
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/) 转载或引用必须申明原指尖魔法屋来源及源地址!