FEE#011 - build | 单元测试
在现代前端开发中,单元测试已经成为确保代码质量和可维护性的重要环节。通过编写测试用例,我们可以验证代码的正确性、防止回归问题,并为重构提供安全网。在众多测试框架中,Jest 因其零配置、快速、功能丰富的特点,成为了前端单元测试的首选工具。
Jest:Facebook 出品的全能测试框架
Jest 是由 Facebook 开发的 JavaScript 测试框架,它的设计理念是提供一个"零配置"的测试体验。与其他测试框架相比,Jest 集成了测试运行器、断言库、模拟工具等多种功能,使得开发者可以专注于编写测试逻辑,而不必担心测试环境的搭建。
Jest 的核心特性
- 开箱即用:几乎零配置,安装后即可开始编写测试。
- 快速并行:Jest 可以并行执行测试,并且智能地优先运行失败的测试。
- 内置覆盖率报告:无需额外工具,即可生成详细的代码覆盖率报告。
- 强大的模拟能力:提供了简单易用的模拟(Mock)功能,可以模拟模块、函数、定时器等。
- 快照测试:可以捕获 UI 组件的渲染结果,并与之前的快照进行比对。
- 隔离环境:每个测试文件都在自己的环境中运行,避免测试间的相互影响。
开始使用 Jest
安装配置
首先,我们需要安装 Jest:
# 使用 npm
npm install --save-dev jest
# 或使用 yarn
yarn add --dev jest
然后,在 package.json
中添加测试脚本:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
如果你使用 TypeScript,还需要安装相关依赖:
npm install --save-dev ts-jest @types/jest
并创建 jest.config.js
文件:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
// 或者对于浏览器环境
// testEnvironment: 'jsdom',
};
编写第一个测试
Jest 使用 test
或 it
函数来定义测试用例,使用 describe
函数来组织相关的测试。下面是一个简单的例子:
// math.js
export function sum(a, b) {
return a + b;
}
// math.test.js
import { sum } from './math';
describe('数学函数测试', () => {
test('1 + 2 应该等于 3', () => {
expect(sum(1, 2)).toBe(3);
});
});
在这个例子中,expect
是 Jest 提供的断言函数,而 toBe
是一个匹配器,用于检查值是否严格相等。
Jest 常用匹配器详解
Jest 提供了丰富的匹配器(Matchers),用于验证不同类型的值。以下是一些常用的匹配器:
精确匹配
// 严格相等(使用 Object.is)
expect(value).toBe(expected);
// 对象内容相等(递归比较)
expect(object).toEqual(expected);
// 严格相等,但更适合用于浮点数
expect(value).toBeCloseTo(expected, numDigits);
真值检查
// 检查是否为 null
expect(value).toBeNull();
// 检查是否为 undefined
expect(value).toBeUndefined();
// 检查是否已定义(非 undefined)
expect(value).toBeDefined();
// 检查真值性(!!value === true)
expect(value).toBeTruthy();
// 检查假值性(!!value === false)
expect(value).toBeFalsy();
数字比较
// 大于
expect(value).toBeGreaterThan(expected);
// 大于或等于
expect(value).toBeGreaterThanOrEqual(expected);
// 小于
expect(value).toBeLessThan(expected);
// 小于或等于
expect(value).toBeLessThanOrEqual(expected);
字符串匹配
// 字符串包含子串
expect(string).toContain(substring);
// 字符串匹配正则表达式
expect(string).toMatch(/pattern/);
数组和可迭代对象
// 数组或可迭代对象包含特定项
expect(array).toContain(item);
// 数组或对象包含匹配特定条件的项
expect(array).toContainEqual(item);
// 数组长度
expect(array).toHaveLength(number);
对象属性
// 对象包含特定属性
expect(object).toHaveProperty(keyPath, value);
异常匹配
// 函数调用抛出异常
expect(() => { throw new Error('错误') }).toThrow();
// 函数调用抛出特定错误
expect(() => { throw new Error('特定错误') }).toThrow('特定错误');
expect(() => { throw new Error('特定错误') }).toThrow(/特定/);
否定匹配
所有匹配器都可以通过 .not
修饰符来进行否定:
expect(value).not.toBe(expected);
示例:综合使用匹配器
describe('用户验证函数', () => {
const user = {
name: '张三',
age: 28,
roles: ['user', 'admin'],
address: {
city: '北京',
zipCode: '100000'
}
};
test('用户对象应该包含正确的属性和值', () => {
// 检查对象属性
expect(user).toHaveProperty('name', '张三');
expect(user).toHaveProperty('age');
expect(user.age).toBeGreaterThan(18);
// 检查数组
expect(user.roles).toHaveLength(2);
expect(user.roles).toContain('admin');
// 检查嵌套对象
expect(user.address).toEqual({
city: '北京',
zipCode: '100000'
});
// 检查字符串
expect(user.address.city).toMatch(/北京/);
});
});
异步测试
在前端开发中,异步操作非常常见。Jest 提供了多种方式来测试异步代码:
Promise
// fetchUser.js
export function fetchUser(id) {
return fetch(`/api/users/${id}`)
.then(response => response.json());
}
// fetchUser.test.js
import { fetchUser } from './fetchUser';
test('用户数据应该包含正确的名称', () => {
// 返回 Promise,Jest 会等待它解决
return fetchUser(1).then(data => {
expect(data.name).toBe('张三');
});
});
// 或者使用 async/await
test('用户数据应该包含正确的名称', async () => {
const data = await fetchUser(1);
expect(data.name).toBe('张三');
});
// 测试 Promise 拒绝
test('无效 ID 应该导致错误', async () => {
expect.assertions(1); // 确保至少有一个断言被调用
try {
await fetchUser(-1);
} catch (e) {
expect(e).toMatch('Invalid ID');
}
});
// 或者使用 rejects
test('无效 ID 应该导致错误', () => {
return expect(fetchUser(-1)).rejects.toMatch('Invalid ID');
});
回调
对于使用回调的代码,可以使用 done
参数:
test('数据应该正确加载', done => {
function callback(data) {
try {
expect(data).toBe('数据');
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
模拟功能
Jest 提供了强大的模拟(Mock)功能,可以模拟函数、模块、定时器等。
模拟函数
test('回调函数应该被调用', () => {
// 创建模拟函数
const mockCallback = jest.fn();
// 使用模拟函数
forEach([1, 2], mockCallback);
// 检查调用次数
expect(mockCallback.mock.calls.length).toBe(2);
// 检查第一次调用的第一个参数
expect(mockCallback.mock.calls[0][0]).toBe(1);
});
// 模拟函数返回值
test('模拟函数应该返回特定值', () => {
const myMock = jest.fn();
// 设置返回值
myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);
console.log(myMock(), myMock(), myMock(), myMock());
// 输出: 10, 'x', true, true
});
模拟模块
// 模拟整个模块
jest.mock('./mathModule');
// 模拟特定方法
import * as mathModule from './mathModule';
mathModule.sum = jest.fn().mockReturnValue(10);
// 或者使用 spyOn
jest.spyOn(mathModule, 'sum').mockImplementation(() => 10);
模拟定时器
// 使用假定时器
jest.useFakeTimers();
test('定时器应该正确执行', () => {
const callback = jest.fn();
// 设置定时器
setTimeout(callback, 1000);
// 快进时间
jest.advanceTimersByTime(1000);
// 检查回调是否被调用
expect(callback).toHaveBeenCalled();
});
// 恢复真实定时器
jest.useRealTimers();
测试 React 组件
Jest 与 React Testing Library 或 Enzyme 配合使用,可以方便地测试 React 组件。
使用 React Testing Library
首先安装依赖:
npm install --save-dev @testing-library/react @testing-library/jest-dom
然后编写测试:
// Button.jsx
import React from 'react';
function Button({ onClick, children }) {
return (
<button onClick={onClick}>
{children}
</button>
);
}
// Button.test.jsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import Button from './Button';
test('按钮应该正确渲染文本', () => {
const { getByText } = render(<Button>点击我</Button>);
expect(getByText('点击我')).toBeInTheDocument();
});
test('点击按钮应该触发回调', () => {
const handleClick = jest.fn();
const { getByText } = render(
<Button onClick={handleClick}>点击我</Button>
);
fireEvent.click(getByText('点击我'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
快照测试
快照测试是 Jest 的一个特色功能,特别适合测试 UI 组件的渲染输出。
import React from 'react';
import { render } from '@testing-library/react';
import Card from './Card';
test('Card 组件应该正确渲染', () => {
const { container } = render(
<Card title="标题" description="描述" />
);
// 生成快照并与之前的快照比对
expect(container).toMatchSnapshot();
});
首次运行时,Jest 会创建一个快照文件。后续运行时,Jest 会将当前输出与快照进行比对,如果不一致,测试将失败。
测试覆盖率
Jest 内置了代码覆盖率报告功能,可以通过 --coverage
参数启用:
jest --coverage
这将生成一个详细的覆盖率报告,包括语句覆盖率、分支覆盖率、函数覆盖率和行覆盖率。
你也可以在配置文件中设置覆盖率阈值:
// jest.config.js
module.exports = {
// ...
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
测试最佳实践
测试行为,而非实现:关注函数或组件的输出和副作用,而不是内部实现细节。
一个测试只测一件事:每个测试应该专注于一个功能点,这样当测试失败时,能够快速定位问题。
使用有意义的测试描述:好的测试描述应该清晰地表达测试的意图,如"当用户名为空时,提交按钮应该被禁用"。
组织测试结构:使用
describe
嵌套来组织相关的测试,提高可读性。测试边界条件:不仅测试正常情况,还要测试边界情况和错误情况。
避免测试间的依赖:每个测试应该是独立的,不依赖于其他测试的状态。
模拟外部依赖:使用 Jest 的模拟功能来隔离被测代码与外部依赖。
保持测试简洁:测试代码也是代码,应该保持简洁、可读和可维护。
示例:一个完整的测试套件
下面是一个用户验证模块的完整测试套件示例:
// userValidator.js
export function validateUsername(username) {
if (!username) return '用户名不能为空';
if (username.length < 3) return '用户名长度不能小于 3 个字符';
if (username.length > 20) return '用户名长度不能超过 20 个字符';
if (!/^[a-zA-Z0-9_]+$/.test(username)) return '用户名只能包含字母、数字和下划线';
return null; // 验证通过
}
export function validatePassword(password) {
if (!password) return '密码不能为空';
if (password.length < 6) return '密码长度不能小于 6 个字符';
if (!/[A-Z]/.test(password)) return '密码必须包含至少一个大写字母';
if (!/[a-z]/.test(password)) return '密码必须包含至少一个小写字母';
if (!/[0-9]/.test(password)) return '密码必须包含至少一个数字';
return null; // 验证通过
}
// userValidator.test.js
import { validateUsername, validatePassword } from './userValidator';
describe('用户名验证', () => {
test('空用户名应该返回错误', () => {
expect(validateUsername('')).toBe('用户名不能为空');
});
test('用户名长度小于 3 应该返回错误', () => {
expect(validateUsername('ab')).toBe('用户名长度不能小于 3 个字符');
});
test('用户名长度大于 20 应该返回错误', () => {
const longUsername = 'a'.repeat(21);
expect(validateUsername(longUsername)).toBe('用户名长度不能超过 20 个字符');
});
test('用户名包含非法字符应该返回错误', () => {
expect(validateUsername('user@name')).toBe('用户名只能包含字母、数字和下划线');
});
test('有效的用户名应该返回 null', () => {
expect(validateUsername('valid_user123')).toBeNull();
});
});
describe('密码验证', () => {
test('空密码应该返回错误', () => {
expect(validatePassword('')).toBe('密码不能为空');
});
test('密码长度小于 6 应该返回错误', () => {
expect(validatePassword('Ab1')).toBe('密码长度不能小于 6 个字符');
});
test('密码缺少大写字母应该返回错误', () => {
expect(validatePassword('abcdef123')).toBe('密码必须包含至少一个大写字母');
});
test('密码缺少小写字母应该返回错误', () => {
expect(validatePassword('ABCDEF123')).toBe('密码必须包含至少一个小写字母');
});
test('密码缺少数字应该返回错误', () => {
expect(validatePassword('AbcdefGHI')).toBe('密码必须包含至少一个数字');
});
test('有效的密码应该返回 null', () => {
expect(validatePassword('Valid123Password')).toBeNull();
});
});