测试是保障代码质量、降低维护成本的关键环节。JavaScript 生态提供了丰富的测试工具和框架,从单元测试到端到端测试,覆盖开发全流程。本文将带你系统学习 JavaScript 测试基础,包括测试类型、主流框架 Jest 的使用、异步测试、Mock、代码覆盖率以及最佳实践。通过 40 分钟的深入学习,你将能编写可靠、可维护的测试用例,为项目健壮性打下坚实基础。
测试基础概念
软件测试是在规定条件下检查程序功能的过程。在前端/Node.js 领域,主要包含以下层级:
// 测试类型金字塔
// 1. 单元测试:验证独立函数/模块
例: test('adds 1 + 2 to equal 3', () => {
expect(sum(1,2)).toBe(3);
});
// 2. 集成测试:测试模块间交互
例: test('API 请求返回正确数据', async () => {
const data = await fetchUser(1);
expect(data.name).toBe('Alice');
});
// 3. E2E 测试:模拟真实用户场景
例: cy.visit('/login'); cy.get('#username').type('test');
// 1. 单元测试:验证独立函数/模块
例: test('adds 1 + 2 to equal 3', () => {
expect(sum(1,2)).toBe(3);
});
// 2. 集成测试:测试模块间交互
例: test('API 请求返回正确数据', async () => {
const data = await fetchUser(1);
expect(data.name).toBe('Alice');
});
// 3. E2E 测试:模拟真实用户场景
例: cy.visit('/login'); cy.get('#username').type('test');
测试金字塔
单元测试 (70%)
集成测试 (20%)
E2E (10%)
越底层测试越轻量、运行越快
Jest 快速入门
Jest 是目前最流行的零配置测试框架,内置断言、Mock、覆盖率收集。
// 安装
npm install --save-dev jest
// package.json 添加脚本
"scripts": { "test": "jest" }
// 第一个测试 (sum.js)
function sum(a,b) { return a+b; }
module.exports = sum;
// sum.test.js
const sum = require('./sum');
test('1 + 2 = 3', () => {
expect(sum(1,2)).toBe(3);
});
npm install --save-dev jest
// package.json 添加脚本
"scripts": { "test": "jest" }
// 第一个测试 (sum.js)
function sum(a,b) { return a+b; }
module.exports = sum;
// sum.test.js
const sum = require('./sum');
test('1 + 2 = 3', () => {
expect(sum(1,2)).toBe(3);
});
PASS sum.test.js
✓ 1 + 2 = 3 2ms
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
常用匹配器 (Matchers)
Jest 提供丰富的匹配器让断言更具表达力。
// 精确相等
expect(value).toBe(42);
expect({name:'Alice'}).toEqual({name:'Alice'});
// 真值判断
expect(n).toBeNull();
expect(n).toBeDefined();
expect(n).toBeTruthy();
// 数字比较
expect(2.3).toBeGreaterThan(2);
expect(0.1+0.2).toBeCloseTo(0.3);
// 字符串/正则
expect('team').toMatch(/tea/);
// 数组/可迭代对象
expect([1,2,3]).toContain(2);
expect(value).toBe(42);
expect({name:'Alice'}).toEqual({name:'Alice'});
// 真值判断
expect(n).toBeNull();
expect(n).toBeDefined();
expect(n).toBeTruthy();
// 数字比较
expect(2.3).toBeGreaterThan(2);
expect(0.1+0.2).toBeCloseTo(0.3);
// 字符串/正则
expect('team').toMatch(/tea/);
// 数组/可迭代对象
expect([1,2,3]).toContain(2);
toBe
toEqual
toMatch
toContain
toThrow
在 sum.test.js 中尝试各种匹配器
测试异步代码
JavaScript 大量异步操作,Jest 支持回调、Promise 和 async/await。
// 回调方式 (done)
test('fetch data', done => {
fetchData((data) => {
expect(data).toBe('peanut butter');
done();
});
});
// Promise 方式
test('resolves to lemon', () => {
return expect(Promise.resolve('lemon')).resolves.toBe('lemon');
});
// async/await
test('with async/await', async () => {
const data = await fetchDataAsync();
expect(data).toBe('apple');
});
test('fetch data', done => {
fetchData((data) => {
expect(data).toBe('peanut butter');
done();
});
});
// Promise 方式
test('resolves to lemon', () => {
return expect(Promise.resolve('lemon')).resolves.toBe('lemon');
});
// async/await
test('with async/await', async () => {
const data = await fetchDataAsync();
expect(data).toBe('apple');
});
PASS async.test.js
✓ resolves to lemon (5ms)
✓ with async/await (3ms)
✓ resolves to lemon (5ms)
✓ with async/await (3ms)
异步测试通过
钩子函数 (Hooks)
beforeEach / afterEach / beforeAll / afterAll 用来设置测试前置/后置条件。
let cityDB;
beforeAll(() => {
cityDB = connectDatabase();
});
afterAll(() => {
cityDB.close();
});
beforeEach(() => {
cityDB.clear();
cityDB.add('北京');
});
test('has city Beijing', () => {
expect(cityDB.has('北京')).toBeTruthy();
});
beforeAll(() => {
cityDB = connectDatabase();
});
afterAll(() => {
cityDB.close();
});
beforeEach(() => {
cityDB.clear();
cityDB.add('北京');
});
test('has city Beijing', () => {
expect(cityDB.has('北京')).toBeTruthy();
});
beforeAll
→ 执行一次
beforeEach
→ 每个测试前
afterEach / afterAll
清理
Mock 函数与模拟
使用 jest.fn() 模拟函数行为,隔离外部依赖。
const mockCallback = jest.fn(x => 42 + x);
[1,2].forEach(mockCallback);
expect(mockCallback.mock.calls.length).toBe(2);
expect(mockCallback.mock.calls[0][0]).toBe(1);
expect(mockCallback.mock.results[0].value).toBe(43);
// 模拟模块 (axios)
jest.mock('axios');
axios.get.mockResolvedValue({ data: 'ok' });
[1,2].forEach(mockCallback);
expect(mockCallback.mock.calls.length).toBe(2);
expect(mockCallback.mock.calls[0][0]).toBe(1);
expect(mockCallback.mock.results[0].value).toBe(43);
// 模拟模块 (axios)
jest.mock('axios');
axios.get.mockResolvedValue({ data: 'ok' });
mockFn.mock.calls
mockFn.mock.results
mockFn.mock.instances
mockFn.mock.results
mockFn.mock.instances
检查调用次数、参数和返回值
测试 UI 组件 (Testing Library)
结合 @testing-library/react 测试组件行为而非实现。
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
test('点击按钮增加计数', async () => {
render( );
const button = screen.getByRole('button', {name: /increment/i});
await userEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
test('点击按钮增加计数', async () => {
render(
const button = screen.getByRole('button', {name: /increment/i});
await userEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
Count: 1
通过用户事件触发,验证 DOM 变化
代码覆盖率
Jest 内置覆盖率收集,通过 --coverage 生成报告。
// 运行测试并收集覆盖率
npm test -- --coverage
// 输出示例
-----------|---------|----------|---------|
File | % Stmts | % Branch | % Funcs |
-----------|---------|----------|---------|
math.js | 100 | 75 | 100 |
npm test -- --coverage
// 输出示例
-----------|---------|----------|---------|
File | % Stmts | % Branch | % Funcs |
-----------|---------|----------|---------|
math.js | 100 | 75 | 100 |
85%
语句: 85%
分支: 72%
函数: 91%
帮助发现未测试的代码路径
测试最佳实践
- 单一断言:每个 test 尽量只验证一个行为
- 描述清晰:test('should ...') 使用自然语言
- 避免测试实现细节:测试公共接口而非内部逻辑
- 模拟外部服务:使用 mock 避免真实网络请求
- 持续集成:在 CI 中自动运行测试
- 尊重用户偏好:对动画或定时器使用 jest.useFakeTimers()
// 使用 fake timers 控制时间
jest.useFakeTimers();
timerGame();
expect(setTimeout).toHaveBeenCalledTimes(1);
jest.useFakeTimers();
timerGame();
expect(setTimeout).toHaveBeenCalledTimes(1);