JavaScript 测试策略与最佳实践

中等 🟡Js/Ts
7 个标签
预计阅读时间:31 分钟
JavaScript测试Jest单元测试VitestTDDE2E

JavaScript 测试策略与最佳实践

测试是保证代码质量的重要手段。它的本质是:用可执行的代码去验证另一段代码的行为是否符合预期。JavaScript 生态有非常丰富的测试工具和框架,从最小的单元测试到完整的端到端测试,构成了一整套质量防护网。

可以把测试想象成给代码买的"保险":写测试时要花一点成本,但一旦未来有人(包括未来的你自己)改坏了逻辑,测试会立刻报警,而不是等到线上用户投诉才发现。

为什么测试很重要

没有测试的代码是"信仰驱动开发"。 每次改动都要靠人肉回归、靠祈祷不出 bug。随着代码规模增长,这种方式会迅速崩溃。

真实数据可以说明问题:

| 阶段 | 发现一个 bug 的平均修复成本 | 说明 |

| --- | --- | --- |

| 编码阶段(本地测试拦截) | 1x | 改几行代码即可 |

| 代码评审 / CI 阶段 | 5x | 需要重新走流程 |

| 测试 / QA 阶段 | 10x | 需要提 bug、复现、回归 |

| 生产环境 | 30x~100x | 影响用户、可能需要热修复、回滚、公关 |

这就是著名的"缺陷放大效应":bug 发现得越晚,修复成本呈指数级上升。测试的核心价值就是把 bug 尽可能拦截在最左侧(编码阶段),这也是"测试左移(Shift Left)"理念的由来。

测试类型

不同层级的测试解决不同的问题,业界常用"测试金字塔"来描述它们的合理比例。

textCode
        /\
       /E2E\         少量(约 10%):慢、贵、脆弱,但最接近真实用户
      /------\
     /  集成  \       适量(约 20%):验证模块协作
    /----------\
   /   单元测试  \     大量(约 70%):快、稳、便宜
  /--------------\

单元测试(Unit Test):

测试单个函数、类或组件
隔离测试,不依赖数据库、网络等外部资源
执行极快(毫秒级),反馈迅速
是金字塔的基石,数量最多

集成测试(Integration Test):

测试多个模块协作,例如"Service 调用 Repository 再写入内存数据库"
模拟接近真实的场景
验证接口契约、数据流转是否正确

端到端测试(E2E Test):

从浏览器出发,模拟真实用户点击、输入、跳转
验证整条业务链路(前端 + 后端 + 数据库)
最接近用户体验,但也最慢、最容易因界面微调而"误报"

性能测试:

测量代码耗时、内存占用、吞吐量
识别性能瓶颈,设定性能基线(baseline)防止性能回退

安全测试:

检测 XSS、注入、依赖漏洞(如 npm audit)
确保代码不引入安全风险

主流测试工具

测试框架:

Jest:Facebook 开发,功能丰富。与 React 项目完美集成,提供零配置、快照测试、内置 mock、并行执行、代码覆盖率报告,是过去几年前端单元测试的行业标准,支持 TypeScript、Babel、Webpack 等工具链。
Vitest:基于 Vite 的新一代测试框架,提供与 Jest 高度兼容的 API,但利用 Vite 的 ES 模块与 esbuild 实现极快的启动和热重载。原生支持 TypeScript、JSX、ESM,是现代前端项目(尤其 Vite 项目)的首选。
Mocha:老牌框架,灵活、可定制性强,通常搭配 Chai 断言库和 Sinon mock 库使用。
Jasmine:BDD 风格,自带断言和 mock,配置简单。

断言库:

Jest / Vitest 内置的 `expect`
Chai:支持 `assert`、`expect`、`should` 三种风格
Node.js 内置 `assert` 模块

组件与 E2E 工具:

React Testing Library / Testing Library:以"用户视角"测试 UI,鼓励通过可见文本、角色(role)而非实现细节来查询元素
Cypress:交互式 E2E 测试,带时间旅行调试
Playwright:微软开发的 E2E 框架,支持 Chromium、Firefox、WebKit 全部现代浏览器内核,提供自动等待、网络拦截、截图、视频录制、并行执行等高级能力,API 简洁且原生支持 TypeScript,是现代 E2E 测试的首选。

模拟(Mock)工具:

Jest / Vitest 内置 mock
Sinon:函数 stub、spy、mock
MSW(Mock Service Worker):在网络层拦截请求,前后端都可复用同一套 mock

断言与 Matcher 速查

javascriptCode
// 相等性
expect(2 + 2).toBe(4);                    // 严格相等(Object.is)
expect({ a: 1 }).toEqual({ a: 1 });       // 深度相等
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }); // 部分匹配

// 真值判断
expect(value).toBeTruthy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// 数值
expect(0.1 + 0.2).toBeCloseTo(0.3);       // 浮点数比较必用
expect(10).toBeGreaterThan(5);

// 字符串 / 数组 / 对象
expect('hello world').toContain('world');
expect(['a', 'b']).toContain('a');
expect([1, 2, 3]).toHaveLength(3);
expect({ name: 'Alice' }).toHaveProperty('name');

// 异常
expect(() => { throw new Error('boom'); }).toThrow('boom');

// 取反
expect(1 + 1).not.toBe(3);

单元测试实战

javascriptCode
// math.js
export function sum(a, b) {
  return a + b;
}

export function divide(a, b) {
  if (b === 0) throw new Error('除数不能为 0');
  return a / b;
}
javascriptCode
// math.test.js
import { sum, divide } from './math';

describe('sum 函数', () => {
  test('两个正数相加', () => {
    expect(sum(1, 2)).toBe(3);
  });

  test('处理负数', () => {
    expect(sum(-1, -2)).toBe(-3);
  });

  test('处理零', () => {
    expect(sum(0, 0)).toBe(0);
  });
});

describe('divide 函数', () => {
  test('正常除法', () => {
    expect(divide(10, 2)).toBe(5);
  });

  // 边界与异常测试往往比正常路径更重要
  test('除数为 0 时抛出错误', () => {
    expect(() => divide(10, 0)).toThrow('除数不能为 0');
  });
});

AAA 模式:好测试的骨架

一个清晰的测试应遵循 AAA(Arrange-Act-Assert)三段式:

javascriptCode
test('购物车计算总价', () => {
  // Arrange:准备数据和环境
  const cart = new Cart();
  cart.add({ name: '书', price: 50, qty: 2 });
  cart.add({ name: '笔', price: 10, qty: 3 });

  // Act:执行被测行为
  const total = cart.getTotal();

  // Assert:验证结果
  expect(total).toBe(130);
});

异步代码测试

异步是 JavaScript 测试最容易出错的地方。核心原则:一定要 return 或 await 异步断言,否则测试会在断言完成前就"通过"

javascriptCode
// 被测代码
async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('用户不存在');
  return res.json();
}

// 正确写法:async/await
test('成功获取用户', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
});

// 测试异步异常
test('用户不存在时抛错', async () => {
  await expect(fetchUser(999)).rejects.toThrow('用户不存在');
});

// resolves / rejects 匹配器
test('Promise 解析出正确值', async () => {
  await expect(Promise.resolve('ok')).resolves.toBe('ok');
});

Mock 与依赖隔离

单元测试要"隔离",就必须把网络、时间、随机数等不确定因素 mock 掉。

javascriptCode
// 1. 模拟一个函数并断言调用
test('回调被正确调用', () => {
  const callback = jest.fn();
  [1, 2, 3].forEach(callback);

  expect(callback).toHaveBeenCalledTimes(3);
  expect(callback).toHaveBeenCalledWith(2, 1, [1, 2, 3]);
});

// 2. 模拟整个模块
jest.mock('./api');
import { getData } from './api';

test('使用 mock 的 API', async () => {
  getData.mockResolvedValue({ id: 1, name: 'Mock' });
  const result = await getData();
  expect(result.name).toBe('Mock');
});

// 3. 模拟定时器(避免真的等待)
test('防抖函数只触发一次', () => {
  jest.useFakeTimers();
  const fn = jest.fn();
  const debounced = debounce(fn, 1000);

  debounced();
  debounced();
  debounced();

  jest.advanceTimersByTime(1000); // 快进时间
  expect(fn).toHaveBeenCalledTimes(1);
  jest.useRealTimers();
});

用 MSW 模拟网络请求

MSW 在网络层拦截请求,比 mock fetch 更真实,且前端联调和测试可复用同一套 handler。

javascriptCode
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({ id: params.id, name: 'Alice' });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('从 API 获取用户', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
});

React 组件测试

React Testing Library 的哲学是"测试用户看到和操作的东西,而非组件内部状态"。

javascriptCode
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
import Counter from './Counter';

test('Button 渲染文本', () => {
  render(<Button>点击我</Button>);
  expect(screen.getByText('点击我')).toBeInTheDocument();
});

test('Button 点击时触发 onClick', () => {
  const handleClick = jest.fn();
  render(<Button onClick={handleClick}>点击我</Button>);
  fireEvent.click(screen.getByText('点击我'));
  expect(handleClick).toHaveBeenCalledTimes(1);
});

// 用 userEvent 模拟更真实的用户交互
test('计数器点击后数字增加', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  const button = screen.getByRole('button', { name: /增加/i });
  await user.click(button);
  await user.click(button);

  expect(screen.getByText('当前计数:2')).toBeInTheDocument();
});

端到端测试实战(Playwright)

javascriptCode
import { test, expect } from '@playwright/test';

test('用户登录流程', async ({ page }) => {
  await page.goto('https://example.com/login');

  await page.fill('input[name="email"]', 'alice@example.com');
  await page.fill('input[name="password"]', 'secret123');
  await page.click('button[type="submit"]');

  // Playwright 自动等待元素出现,无需手动 sleep
  await expect(page).toHaveURL(/dashboard/);
  await expect(page.getByText('欢迎,Alice')).toBeVisible();
});

测试覆盖率

覆盖率衡量"测试执行到了多少代码",但高覆盖率 ≠ 高质量。它只能告诉你哪些代码没被测到,无法保证被测到的代码断言充分。

| 指标 | 含义 | 说明 |

| --- | --- | --- |

| 语句覆盖率 | 执行了多少条语句 | 最基础的指标 |

| 分支覆盖率 | if/else、三元等分支覆盖情况 | 最能反映测试充分度 |

| 函数覆盖率 | 调用了多少个函数 | 发现"从未被测的函数" |

| 行覆盖率 | 执行了多少行 | 与语句覆盖率接近 |

经验值:核心业务逻辑建议 80% 以上分支覆盖率;工具函数可追求接近 100%;UI 展示层可适当放宽。盲目追求 100% 会导致大量低价值测试,反而拖慢迭代。

bashCode
# Jest 生成覆盖率报告
jest --coverage

# Vitest 生成覆盖率报告
vitest run --coverage

真实案例:一次线上事故的复盘

某电商结算模块曾发生"优惠券叠加后总价为负"的线上事故,原因是没有针对"优惠金额大于订单金额"这个边界写测试。复盘后补充的测试:

javascriptCode
describe('优惠券计算边界', () => {
  test('优惠金额不应使总价为负', () => {
    const order = { amount: 30 };
    const coupon = { discount: 50 };
    const finalPrice = applyCoupon(order, coupon);
    expect(finalPrice).toBeGreaterThanOrEqual(0);
    expect(finalPrice).toBe(0); // 封底为 0,而非 -20
  });
});

教训:正常路径谁都会写,真正拦住事故的往往是边界与异常测试。

测试生命周期钩子

几乎所有框架都提供四个生命周期钩子,用来做准备和清理。理解它们的执行顺序是写出干净、隔离测试的前提。

javascriptCode
describe('用户服务', () => {
  let db;

  beforeAll(async () => {
    // 整个 describe 只运行一次:建立数据库连接等昂贵操作
    db = await connectTestDB();
  });

  beforeEach(async () => {
    // 每个 test 前运行:重置数据,保证测试隔离
    await db.reset();
    await db.seed({ users: [{ id: 1, name: 'Alice' }] });
  });

  afterEach(() => {
    // 每个 test 后运行:清理副作用,如恢复 mock
    jest.restoreAllMocks();
  });

  afterAll(async () => {
    // 整个 describe 结束后运行一次:关闭连接、释放资源
    await db.close();
  });

  test('查询用户', async () => {
    const user = await getUser(db, 1);
    expect(user.name).toBe('Alice');
  });
});
// 执行顺序:beforeAll → (beforeEach → test → afterEach) × N → afterAll

核心原则:优先用 `beforeEach` 重置状态而非 `beforeAll`。 共享状态是 flaky test 的头号来源——一个测试的残留数据会污染下一个,且测试的通过与否会依赖执行顺序。

参数化测试:一次写法,多组数据

对同一逻辑用多组输入验证时,别复制粘贴,用参数化(table-driven)测试,报告更清晰、维护更省心。

javascriptCode
// Jest / Vitest 的 test.each
describe('isEmail 校验', () => {
  test.each([
    ['alice@example.com', true],
    ['bad-email', false],
    ['a@b.co', true],
    ['@nodomain.com', false],
    ['', false],
  ])('isEmail(%s) 应返回 %s', (input, expected) => {
    expect(isEmail(input)).toBe(expected);
  });
});

// 也支持对象数组 + 模板字符串,可读性更好
test.each([
  { a: 1, b: 2, sum: 3 },
  { a: -1, b: 1, sum: 0 },
  { a: 0, b: 0, sum: 0 },
])('sum($a, $b) = $sum', ({ a, b, sum }) => {
  expect(add(a, b)).toBe(sum);
});

快照测试:用与慎用

快照测试把某次运行的输出序列化存到文件,之后每次运行都和存档比对,适合防止 UI 结构或复杂对象意外变化。

javascriptCode
test('用户卡片渲染结构', () => {
  const { container } = render(<UserCard user={{ name: 'Alice', role: 'admin' }} />);
  expect(container).toMatchSnapshot();
});

// 内联快照:把快照直接写进测试文件,小对象更直观
test('格式化配置', () => {
  expect(formatConfig({ port: 8080 })).toMatchInlineSnapshot(`
    "port=8080"
  `);
});

快照测试的陷阱: 快照过大时没人真正 review,一句 `--updateSnapshot` 就盲目更新,等于没测。最佳实践是——只对小而稳定的输出快照,大组件优先用针对性断言。

测试替身(Test Doubles)五种类型

"mock"是个笼统说法,实际上测试替身有五种,用途各异。

| 类型 | 作用 | 典型场景 |

| --- | --- | --- |

| Dummy | 只为填参数,从不使用 | 占位一个必填参数 |

| Stub | 提供预设返回值 | 让依赖返回固定数据 |

| Spy | 记录调用信息,可保留真实实现 | 断言"某函数被调用过" |

| Mock | 预设期望并验证交互 | 断言调用次数与参数 |

| Fake | 有简化的真实实现 | 内存数据库替代真库 |

javascriptCode
// Spy:监视真实方法是否被调用,但不改变其行为
const spy = jest.spyOn(console, 'log');
logMessage('hello');
expect(spy).toHaveBeenCalledWith('[INFO] hello');
spy.mockRestore();

// Stub:替换实现返回固定值
const getConfig = jest.fn().mockReturnValue({ theme: 'dark' });

// Mock:设定期望并断言交互
const mailer = { send: jest.fn() };
notifyUser(mailer, user);
expect(mailer.send).toHaveBeenCalledTimes(1);
expect(mailer.send).toHaveBeenCalledWith('alice@example.com', expect.any(String));

测试可访问性与语义查询

Testing Library 提供一组按优先级排列的查询方法,优先用"用户能感知的"方式定位元素,这样测试既贴近真实使用,又顺带验证了可访问性。

javascriptCode
// 查询优先级(从高到低):
// 1. getByRole —— 最推荐,贴近辅助技术
screen.getByRole('button', { name: /提交/i });
screen.getByRole('heading', { level: 1 });
// 2. getByLabelText —— 表单元素首选
screen.getByLabelText('用户名');
// 3. getByPlaceholderText / getByText
screen.getByText('欢迎回来');
// 4. getByTestId —— 兜底,前面都不行时才用
screen.getByTestId('custom-widget');

// getBy 找不到会抛错;queryBy 找不到返回 null(用于断言"不存在")
expect(screen.queryByText('错误提示')).not.toBeInTheDocument();
// findBy 返回 Promise,用于等待异步出现的元素
expect(await screen.findByText('加载完成')).toBeInTheDocument();

处理定时器、日期与随机数

不确定因素是 flaky test 的根源,必须全部"钉死"。

javascriptCode
// 1. 假定时器
test('轮询每 5 秒触发一次', () => {
  jest.useFakeTimers();
  const cb = jest.fn();
  startPolling(cb, 5000);
  jest.advanceTimersByTime(15000); // 快进 15 秒
  expect(cb).toHaveBeenCalledTimes(3);
  jest.useRealTimers();
});

// 2. 固定系统时间
test('生成的时间戳固定', () => {
  jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z'));
  expect(createRecord().createdAt).toBe('2026-01-01T00:00:00.000Z');
  jest.useRealTimers();
});

// 3. 固定随机数
test('抽奖结果可预测', () => {
  jest.spyOn(Math, 'random').mockReturnValue(0.42);
  expect(drawLottery()).toBe('三等奖');
});

集成测试实战:API 层

集成测试验证多个模块协作。以 Express 接口为例,用 supertest 直接对应用发请求,配合内存数据库,无需真实网络。

javascriptCode
import request from 'supertest';
import { app } from './app';
import { db } from './db';

describe('POST /api/users', () => {
  beforeEach(() => db.reset());

  test('成功创建用户返回 201', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Bob', email: 'bob@example.com' });

    expect(res.status).toBe(201);
    expect(res.body).toMatchObject({ name: 'Bob' });
    // 验证真的写进了库
    const saved = await db.users.findByEmail('bob@example.com');
    expect(saved).not.toBeNull();
  });

  test('缺少邮箱返回 400', async () => {
    const res = await request(app).post('/api/users').send({ name: 'Bob' });
    expect(res.status).toBe(400);
  });
});

TDD 完整循环:红-绿-重构

测试驱动开发是"先写测试,再写实现"。看一个完整循环——实现一个 FizzBuzz。

javascriptCode
// 第 1 步 红:先写一个必然失败的测试
test('3 的倍数返回 Fizz', () => {
  expect(fizzbuzz(3)).toBe('Fizz'); // fizzbuzz 还不存在,测试报红
});

// 第 2 步 绿:写最简实现让它通过
function fizzbuzz(n) {
  if (n % 3 === 0) return 'Fizz';
  return String(n);
}

// 第 3 步 加测试逼出更多逻辑
test('5 的倍数返回 Buzz', () => expect(fizzbuzz(5)).toBe('Buzz'));
test('15 的倍数返回 FizzBuzz', () => expect(fizzbuzz(15)).toBe('FizzBuzz'));

// 第 4 步 重构:在测试保护下放心整理代码
function fizzbuzz(n) {
  let out = '';
  if (n % 3 === 0) out += 'Fizz';
  if (n % 5 === 0) out += 'Buzz';
  return out || String(n);
}

TDD 的价值不在测试本身,而在于它逼你先想清楚"这段代码到底该做什么",产出的代码天然可测、接口更清晰。研究表明 TDD 团队缺陷密度通常降低 40% 到 80%,代价是初期开发时间增加约 15% 到 35%。

在 CI 中运行测试

yamlCode
# GitHub Actions 示例:每次 PR 自动跑测试并卡覆盖率
name: test
on: [push, pull_request]
jobs:
  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: npm run lint
      - run: npm test -- --coverage --ci

配合覆盖率阈值配置,覆盖率下降会直接让 CI 失败:

javascriptCode
// jest.config.js
export default {
  coverageThreshold: {
    global: { branches: 80, functions: 80, lines: 85, statements: 85 },
  },
};

工具对比

| 维度 | Jest | Vitest | Mocha |

| --- | --- | --- | --- |

| 配置成本 | 低(零配置) | 极低(复用 Vite 配置) | 高(需自行组装) |

| 启动速度 | 中等 | 快(esbuild) | 中等 |

| ESM 支持 | 需额外配置 | 原生 | 需配置 |

| 内置断言/mock | 有 | 有 | 无(需 Chai/Sinon) |

| 快照测试 | 支持 | 支持 | 需插件 |

| 适用场景 | React / Next 等 | Vite 生态 | 高度自定义需求 |

常见坑

异步断言忘记 await/return:测试假通过,最隐蔽的 bug 来源。
测试之间共享状态:一个测试的副作用污染下一个,用 `beforeEach` 重置状态。
测试实现细节而非行为:一重构就红一片,测试变成负担。
过度 mock:把所有东西都 mock 掉,测试通过了但真实链路根本没验证。
依赖真实时间/随机数/网络:导致测试"时好时坏"(flaky test)。
一个测试断言太多:失败时看不出到底哪里坏了,保持"一个测试一个关注点"。

最佳实践

测试设计:

测试行为而非实现,关注"输入 → 输出"
遵循 AAA(准备-执行-断言)结构
每个测试保持独立,可任意顺序运行
用描述性命名,让测试报告像文档一样可读

测试维护:

把测试纳入 CI,PR 必须绿灯才能合并
修 bug 时先写一个能复现该 bug 的测试(红),再修(绿)
及时删除过时测试,避免"测试债"

测试与开发流程:

TDD(测试驱动开发):红 → 绿 → 重构
BDD(行为驱动开发):用自然语言描述行为
持续集成 / 持续部署(CI/CD)

性能考虑:

单元测试并行执行,E2E 分片并行
用 fake timer 替代真实等待
合理设置超时,避免挂死

总结

| 要点 | 核心结论 |

| --- | --- |

| 为什么测试 | bug 越晚发现成本越高(可达 100x),测试把缺陷拦在最左侧 |

| 测试金字塔 | 单元测试为主(70%),集成适量(20%),E2E 少量(10%) |

| 框架选择 | React 项目用 Jest,Vite 项目用 Vitest,E2E 用 Playwright |

| 好测试特征 | 独立、快速、可读、测行为不测实现、覆盖边界 |

| 覆盖率 | 参考而非目标,核心逻辑 80%+ 分支覆盖,别盲目追 100% |

| 最大陷阱 | 异步忘 await、共享状态、过度 mock、flaky test |

一句话:测试不是为了追求覆盖率数字,而是为了让你敢于随时重构和发布。

学习资源

Jest 官方文档
Vitest 官方文档
Testing Library 文档
Playwright 官方文档
《测试驱动开发》(Kent Beck)
前端测试实践博客与开源项目测试用例