Node.js 单元测试
学习目标
- 理解单元测试的基本概念和重要性
- 掌握常用的 Node.js 测试框架
- 学会使用断言库进行测试验证
- 了解测试覆盖率的概念和工具
- 能够使用 mock 和 stub 技术
- 编写高质量的单元测试
- 实现一个完整的单元测试案例
什么是单元测试?
单元测试是对软件中最小可测试单元进行的测试,通常是指函数或方法。单元测试的目的是验证每个单元是否按照预期工作。
单元测试的重要性
- 提高代码质量:通过测试发现并修复潜在问题
- 减少回归错误:确保修改代码后不会破坏现有功能
- 改善代码设计:促进模块化和可测试的代码结构
- 简化调试:快速定位问题所在
- 提供文档:测试用例本身就是代码的使用文档
- 增强信心:对代码质量有更清晰的认识
单元测试的特点
- 独立性:每个测试用例应该独立运行,不依赖于其他测试
- 可重复性:相同的测试用例应该总是产生相同的结果
- 快速执行:单元测试应该快速运行,以便频繁执行
- 隔离性:测试应该隔离外部依赖,如数据库、网络等
常用的 Node.js 测试框架
1. Jest
Jest 是 Facebook 开发的 JavaScript 测试框架,具有零配置、内置断言、mock 功能和代码覆盖率分析等特点。
安装
npm install --save-dev 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);
});
test('负数相加', () => {
expect(sum(-1, -2)).toBe(-3);
});
test('正数和负数相加', () => {
expect(sum(1, -2)).toBe(-1);
});运行测试
# 在 package.json 中添加脚本
{
"scripts": {
"test": "jest"
}
}
# 运行测试
npm test
# 运行特定测试文件
npm test sum.test.js
# 运行测试并生成覆盖率报告
npm test -- --coverage2. Mocha
Mocha 是一个灵活的 JavaScript 测试框架,支持多种断言库和测试风格。
安装
npm install --save-dev mocha chai基本使用
// sum.js
function sum(a, b) {
return a + b;
}
module.exports = sum;
// sum.test.js
const assert = require('chai').assert;
const sum = require('./sum');
describe('sum 函数', () => {
it('1 + 2 应该等于 3', () => {
assert.equal(sum(1, 2), 3);
});
it('负数相加', () => {
assert.equal(sum(-1, -2), -3);
});
it('正数和负数相加', () => {
assert.equal(sum(1, -2), -1);
});
});运行测试
# 在 package.json 中添加脚本
{
"scripts": {
"test": "mocha"
}
}
# 运行测试
npm test
# 运行特定测试文件
npm test sum.test.js3. Vitest
Vitest 是基于 Vite 的测试框架,具有快速的启动速度和热更新功能,API 与 Jest 兼容。
安装
npm install --save-dev vitest基本使用
// sum.js
function sum(a, b) {
return a + b;
}
export default sum;
// sum.test.js
import { describe, it, expect } from 'vitest';
import sum from './sum';
describe('sum 函数', () => {
it('1 + 2 应该等于 3', () => {
expect(sum(1, 2)).toBe(3);
});
it('负数相加', () => {
expect(sum(-1, -2)).toBe(-3);
});
it('正数和负数相加', () => {
expect(sum(1, -2)).toBe(-1);
});
});运行测试
# 在 package.json 中添加脚本
{
"scripts": {
"test": "vitest"
}
}
# 运行测试
npm test
# 运行测试并生成覆盖率报告
npm test -- --coverage4. 测试框架对比
| 框架 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Jest | 零配置、内置断言和 mock、代码覆盖率 | 相对较重,启动较慢 | 大型项目、React 应用 |
| Mocha | 灵活、可定制性高、支持多种断言库 | 配置复杂,需要额外安装断言库 | 需要高度定制的项目 |
| Vitest | 快速启动、热更新、Jest 兼容 API | 相对较新,生态可能不够成熟 | Vite 项目、快速迭代的项目 |
断言库
1. Jest 内置断言
Jest 内置了强大的断言库,提供了丰富的匹配器。
// 基本断言
expect(value).toBe(expected); // 严格相等
expect(value).toEqual(expected); // 深度相等
expect(value).toBeTruthy(); // 为真
expect(value).toBeFalsy(); // 为假
expect(value).toBeNull(); // 为 null
expect(value).toBeUndefined(); // 为 undefined
expect(value).toBeDefined(); // 已定义
// 数值断言
expect(value).toBeGreaterThan(expected); // 大于
expect(value).toBeGreaterThanOrEqual(expected); // 大于等于
expect(value).toBeLessThan(expected); // 小于
expect(value).toBeLessThanOrEqual(expected); // 小于等于
expect(value).toBeCloseTo(expected); // 接近(用于浮点数)
// 字符串断言
expect(string).toMatch(regexp); // 匹配正则表达式
expect(string).toContain(substring); // 包含子字符串
// 数组断言
expect(array).toContain(item); // 包含元素
expect(array).toHaveLength(length); // 长度
// 对象断言
expect(object).toHaveProperty(key); // 有属性
expect(object).toHaveProperty(key, value); // 有属性且值为
// 异常断言
expect(() => {
throw new Error('错误');
}).toThrow(); // 抛出异常
expect(() => {
throw new Error('错误');
}).toThrow('错误'); // 抛出特定异常2. Chai
Chai 是一个流行的断言库,支持多种断言风格。
安装
npm install --save-dev chai基本使用
const { assert, expect, should } = require('chai');
// 启用 should 风格
should();
// assert 风格
assert.equal(actual, expected);
assert.deepEqual(actual, expected);
assert.isTrue(value);
assert.isFalse(value);
assert.isNull(value);
assert.isUndefined(value);
assert.isDefined(value);
assert.isArray(value);
assert.isObject(value);
assert.isFunction(value);
// expect 风格
expect(actual).to.equal(expected);
expect(actual).to.deep.equal(expected);
expect(value).to.be.true;
expect(value).to.be.false;
expect(value).to.be.null;
expect(value).to.be.undefined;
expect(value).to.be.defined;
expect(value).to.be.an('array');
expect(value).to.be.an('object');
expect(value).to.be.a('function');
expect(array).to.include(item);
expect(string).to.include(substring);
expect(string).to.match(regexp);
// should 风格
value.should.equal(expected);
value.should.be.true;
value.should.be.false;
value.should.be.null;
value.should.be.undefined;
value.should.be.defined;
array.should.include(item);
string.should.include(substring);
string.should.match(regexp);3. Assert(Node.js 内置)
Node.js 内置了简单的断言模块。
const assert = require('assert');
assert.equal(actual, expected);
assert.strictEqual(actual, expected);
assert.deepEqual(actual, expected);
assert.deepStrictEqual(actual, expected);
assert.ok(value);
assert.throws(fn);
assert.throws(fn, error);
assert.doesNotThrow(fn);
assert.rejects(promise);
assert.rejects(promise, error);
assert.doesNotReject(promise);测试覆盖率
什么是测试覆盖率?
测试覆盖率是衡量测试用例覆盖代码程度的指标,通常以百分比表示。
常见的覆盖率指标
| 指标 | 描述 |
|---|---|
| 语句覆盖率 | 被执行的代码语句占总语句的比例 |
| 分支覆盖率 | 被执行的代码分支占总分支的比例 |
| 函数覆盖率 | 被执行的函数占总函数的比例 |
| 行覆盖率 | 被执行的代码行占总行数的比例 |
覆盖率工具
1. Jest 内置覆盖率报告
# 运行测试并生成覆盖率报告
npm test -- --coverage
# 运行测试并生成覆盖率报告到指定目录
npm test -- --coverage --coverageDirectory=coverage2. Istanbul/NYC
Istanbul 是一个强大的 JavaScript 代码覆盖率工具,NYC 是它的命令行接口。
npm install --save-dev nyc# 在 package.json 中添加脚本
{
"scripts": {
"test": "mocha",
"coverage": "nyc mocha"
}
}
# 运行覆盖率测试
npm run coverage3. 覆盖率报告解读
覆盖率报告通常包括:
- 总体覆盖率统计
- 每个文件的覆盖率详情
- 未覆盖的代码行
- 可视化的覆盖率报告(HTML 格式)
覆盖率目标
- 语句覆盖率:80%+ 是一个合理的目标
- 分支覆盖率:70%+ 是一个合理的目标
- 函数覆盖率:90%+ 是一个合理的目标
- 行覆盖率:80%+ 是一个合理的目标
Mock 和 Stub
什么是 Mock?
Mock 是模拟对象的行为,通常用于模拟外部依赖,如数据库、API 等。
什么是 Stub?
Stub 是替换函数的实现,通常用于控制函数的行为,如返回特定值或抛出异常。
Jest 中的 Mock
1. 模拟函数
// 创建模拟函数
const mockFn = jest.fn();
// 调用模拟函数
mockFn('参数1', '参数2');
// 验证调用
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('参数1', '参数2');
expect(mockFn).toHaveBeenCalledTimes(1);
// 设置返回值
const mockFn = jest.fn().mockReturnValue('返回值');
expect(mockFn()).toBe('返回值');
// 设置不同的返回值
const mockFn = jest.fn()
.mockReturnValueOnce('第一次返回值')
.mockReturnValueOnce('第二次返回值')
.mockReturnValue('默认返回值');
expect(mockFn()).toBe('第一次返回值');
expect(mockFn()).toBe('第二次返回值');
expect(mockFn()).toBe('默认返回值');
expect(mockFn()).toBe('默认返回值');
// 设置抛出异常
const mockFn = jest.fn().mockImplementation(() => {
throw new Error('错误');
});
expect(() => mockFn()).toThrow('错误');
// 模拟异步函数
const mockAsyncFn = jest.fn().mockResolvedValue('异步返回值');
// 或者
const mockAsyncFn = jest.fn().mockImplementation(async () => {
return '异步返回值';
});
test('测试异步函数', async () => {
const result = await mockAsyncFn();
expect(result).toBe('异步返回值');
});2. 模拟模块
// 模拟整个模块
jest.mock('./module', () => ({
function1: jest.fn().mockReturnValue('模拟返回值'),
function2: jest.fn().mockResolvedValue('模拟异步返回值')
}));
// 部分模拟模块
jest.mock('./module', () => {
const originalModule = jest.requireActual('./module');
return {
...originalModule,
functionToMock: jest.fn().mockReturnValue('模拟返回值')
};
});
// 模拟 Node.js 核心模块
jest.mock('fs', () => ({
readFileSync: jest.fn().mockReturnValue('文件内容')
}));Sinon
Sinon 是一个专门用于创建 stub、mock 和 spy 的库。
安装
npm install --save-dev sinon基本使用
const sinon = require('sinon');
// 创建 spy
const obj = { method: () => '原始返回值' };
const spy = sinon.spy(obj, 'method');
// 调用方法
obj.method('参数');
// 验证调用
console.log(spy.called); // true
console.log(spy.calledWith('参数')); // true
console.log(spy.callCount); // 1
// 恢复原始方法
spy.restore();
// 创建 stub
const stub = sinon.stub(obj, 'method').returns('模拟返回值');
// 调用方法
console.log(obj.method()); // '模拟返回值'
// 恢复原始方法
stub.restore();
// 创建 mock
const mock = sinon.mock(obj);
mock.expects('method').once().withArgs('参数').returns('模拟返回值');
// 调用方法
obj.method('参数');
// 验证所有期望
mock.verify();
// 恢复原始方法
mock.restore();
// 模拟时间
const clock = sinon.useFakeTimers();
// 执行依赖时间的代码
setTimeout(() => {
console.log('定时器执行');
}, 1000);
// 快进时间
clock.tick(1000);
// 恢复原始时间
clock.restore();测试最佳实践
1. 测试命名规范
- 测试文件命名:使用
.test.js或.spec.js后缀 - 测试套件命名:使用描述性的名称,如
describe('功能描述', () => { ... }) - 测试用例命名:使用清晰的描述,如
it('应该执行特定操作', () => { ... })
2. 测试结构
- Arrange:准备测试数据和环境
- Act:执行要测试的代码
- Assert:验证结果是否符合预期
test('测试用例', () => {
// Arrange
const input = 5;
const expected = 10;
// Act
const result = double(input);
// Assert
expect(result).toBe(expected);
});3. 测试隔离
- 每个测试用例独立:不依赖于其他测试的状态
- 隔离外部依赖:使用 mock 或 stub 模拟外部依赖
- 清理测试环境:确保测试结束后环境回到初始状态
4. 测试覆盖率
- 设置合理的覆盖率目标:根据项目需求设置
- 关注重要代码:优先测试核心功能和复杂逻辑
- 不要为了覆盖率而测试:确保测试有实际价值
5. 测试速度
- 保持测试快速:避免测试中的长时间操作
- 并行运行测试:使用测试框架的并行运行功能
- 合理组织测试:将测试分为单元测试、集成测试等
6. 测试维护
- 保持测试简洁:每个测试用例只测试一个功能
- 更新测试:当代码变更时,相应更新测试
- 删除无用测试:移除过时或重复的测试
实战案例:实现一个完整的单元测试
项目结构
unit-testing-example/
├── src/
│ ├── utils/
│ │ ├── calculator.js # 计算器模块
│ │ └── userService.js # 用户服务模块
│ └── models/
│ └── user.js # 用户模型
├── tests/
│ ├── utils/
│ │ ├── calculator.test.js # 计算器测试
│ │ └── userService.test.js # 用户服务测试
│ └── models/
│ └── user.test.js # 用户模型测试
├── package.json
└── README.md实现步骤
- 创建计算器模块
// src/utils/calculator.js
class Calculator {
/**
* 加法
* @param {number} a 第一个数
* @param {number} b 第二个数
* @returns {number} 和
*/
add(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('参数必须是数字');
}
return a + b;
}
/**
* 减法
* @param {number} a 被减数
* @param {number} b 减数
* @returns {number} 差
*/
subtract(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('参数必须是数字');
}
return a - b;
}
/**
* 乘法
* @param {number} a 第一个数
* @param {number} b 第二个数
* @returns {number} 积
*/
multiply(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('参数必须是数字');
}
return a * b;
}
/**
* 除法
* @param {number} a 被除数
* @param {number} b 除数
* @returns {number} 商
*/
divide(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('参数必须是数字');
}
if (b === 0) {
throw new Error('除数不能为零');
}
return a / b;
}
/**
* 平方根
* @param {number} a 被开方数
* @returns {number} 平方根
*/
sqrt(a) {
if (typeof a !== 'number') {
throw new TypeError('参数必须是数字');
}
if (a < 0) {
throw new Error('被开方数不能为负数');
}
return Math.sqrt(a);
}
}
module.exports = Calculator;- 创建用户模型
// src/models/user.js
class User {
constructor(id, name, email) {
this.id = id;
this.name = name;
this.email = email;
this.createdAt = new Date();
}
/**
* 验证用户信息
* @returns {boolean} 是否有效
*/
validate() {
if (!this.id || typeof this.id !== 'number') {
return false;
}
if (!this.name || typeof this.name !== 'string' || this.name.trim() === '') {
return false;
}
if (!this.email || typeof this.email !== 'string') {
return false;
}
// 简单的邮箱格式验证
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(this.email)) {
return false;
}
return true;
}
/**
* 获取用户信息
* @returns {object} 用户信息
*/
toJSON() {
return {
id: this.id,
name: this.name,
email: this.email,
createdAt: this.createdAt
};
}
}
module.exports = User;- 创建用户服务
// src/utils/userService.js
const User = require('../models/user');
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
/**
* 创建用户
* @param {object} userData 用户数据
* @returns {Promise<User>} 创建的用户
*/
async createUser(userData) {
const { id, name, email } = userData;
const user = new User(id, name, email);
if (!user.validate()) {
throw new Error('无效的用户数据');
}
try {
const createdUser = await this.userRepository.save(user);
return createdUser;
} catch (error) {
throw new Error('创建用户失败');
}
}
/**
* 获取用户
* @param {number} id 用户ID
* @returns {Promise<User>} 用户
*/
async getUserById(id) {
if (!id || typeof id !== 'number') {
throw new Error('无效的用户ID');
}
try {
const user = await this.userRepository.findById(id);
if (!user) {
throw new Error('用户不存在');
}
return user;
} catch (error) {
throw new Error('获取用户失败');
}
}
/**
* 获取所有用户
* @returns {Promise<Array<User>>} 用户列表
*/
async getAllUsers() {
try {
const users = await this.userRepository.findAll();
return users;
} catch (error) {
throw new Error('获取用户列表失败');
}
}
}
module.exports = UserService;- 编写计算器测试
// tests/utils/calculator.test.js
const Calculator = require('../../src/utils/calculator');
describe('Calculator', () => {
let calculator;
beforeEach(() => {
calculator = new Calculator();
});
describe('add 方法', () => {
it('应该正确计算两个正数的和', () => {
expect(calculator.add(1, 2)).toBe(3);
});
it('应该正确计算两个负数的和', () => {
expect(calculator.add(-1, -2)).toBe(-3);
});
it('应该正确计算正数和负数的和', () => {
expect(calculator.add(1, -2)).toBe(-1);
});
it('应该正确计算包含零的和', () => {
expect(calculator.add(0, 5)).toBe(5);
expect(calculator.add(5, 0)).toBe(5);
expect(calculator.add(0, 0)).toBe(0);
});
it('当参数不是数字时应该抛出 TypeError', () => {
expect(() => calculator.add('1', 2)).toThrow(TypeError);
expect(() => calculator.add(1, '2')).toThrow(TypeError);
expect(() => calculator.add('1', '2')).toThrow(TypeError);
});
});
describe('subtract 方法', () => {
it('应该正确计算两个正数的差', () => {
expect(calculator.subtract(5, 2)).toBe(3);
});
it('应该正确计算两个负数的差', () => {
expect(calculator.subtract(-1, -2)).toBe(1);
});
it('应该正确计算正数和负数的差', () => {
expect(calculator.subtract(1, -2)).toBe(3);
});
it('当参数不是数字时应该抛出 TypeError', () => {
expect(() => calculator.subtract('5', 2)).toThrow(TypeError);
expect(() => calculator.subtract(5, '2')).toThrow(TypeError);
});
});
describe('multiply 方法', () => {
it('应该正确计算两个正数的积', () => {
expect(calculator.multiply(2, 3)).toBe(6);
});
it('应该正确计算两个负数的积', () => {
expect(calculator.multiply(-2, -3)).toBe(6);
});
it('应该正确计算正数和负数的积', () => {
expect(calculator.multiply(2, -3)).toBe(-6);
});
it('应该正确计算包含零的积', () => {
expect(calculator.multiply(0, 5)).toBe(0);
expect(calculator.multiply(5, 0)).toBe(0);
});
it('当参数不是数字时应该抛出 TypeError', () => {
expect(() => calculator.multiply('2', 3)).toThrow(TypeError);
expect(() => calculator.multiply(2, '3')).toThrow(TypeError);
});
});
describe('divide 方法', () => {
it('应该正确计算两个正数的商', () => {
expect(calculator.divide(6, 2)).toBe(3);
});
it('应该正确计算两个负数的商', () => {
expect(calculator.divide(-6, -2)).toBe(3);
});
it('应该正确计算正数和负数的商', () => {
expect(calculator.divide(6, -2)).toBe(-3);
});
it('当除数为零时应该抛出 Error', () => {
expect(() => calculator.divide(6, 0)).toThrow('除数不能为零');
});
it('当参数不是数字时应该抛出 TypeError', () => {
expect(() => calculator.divide('6', 2)).toThrow(TypeError);
expect(() => calculator.divide(6, '2')).toThrow(TypeError);
});
});
describe('sqrt 方法', () => {
it('应该正确计算正数的平方根', () => {
expect(calculator.sqrt(4)).toBe(2);
expect(calculator.sqrt(9)).toBe(3);
});
it('应该正确计算零的平方根', () => {
expect(calculator.sqrt(0)).toBe(0);
});
it('当被开方数为负数时应该抛出 Error', () => {
expect(() => calculator.sqrt(-4)).toThrow('被开方数不能为负数');
});
it('当参数不是数字时应该抛出 TypeError', () => {
expect(() => calculator.sqrt('4')).toThrow(TypeError);
});
});
});- 编写用户模型测试
// tests/models/user.test.js
const User = require('../../src/models/user');
describe('User', () => {
describe('构造函数', () => {
it('应该正确创建用户对象', () => {
const user = new User(1, '张三', 'zhangsan@example.com');
expect(user.id).toBe(1);
expect(user.name).toBe('张三');
expect(user.email).toBe('zhangsan@example.com');
expect(user.createdAt).toBeInstanceOf(Date);
});
});
describe('validate 方法', () => {
it('当用户信息有效时应该返回 true', () => {
const user = new User(1, '张三', 'zhangsan@example.com');
expect(user.validate()).toBe(true);
});
it('当 id 无效时应该返回 false', () => {
const user1 = new User(null, '张三', 'zhangsan@example.com');
expect(user1.validate()).toBe(false);
const user2 = new User('1', '张三', 'zhangsan@example.com');
expect(user2.validate()).toBe(false);
});
it('当 name 无效时应该返回 false', () => {
const user1 = new User(1, null, 'zhangsan@example.com');
expect(user1.validate()).toBe(false);
const user2 = new User(1, 123, 'zhangsan@example.com');
expect(user2.validate()).toBe(false);
const user3 = new User(1, '', 'zhangsan@example.com');
expect(user3.validate()).toBe(false);
const user4 = new User(1, ' ', 'zhangsan@example.com');
expect(user4.validate()).toBe(false);
});
it('当 email 无效时应该返回 false', () => {
const user1 = new User(1, '张三', null);
expect(user1.validate()).toBe(false);
const user2 = new User(1, '张三', 123);
expect(user2.validate()).toBe(false);
const user3 = new User(1, '张三', 'invalid-email');
expect(user3.validate()).toBe(false);
});
});
describe('toJSON 方法', () => {
it('应该返回正确的用户信息对象', () => {
const user = new User(1, '张三', 'zhangsan@example.com');
const userJSON = user.toJSON();
expect(userJSON).toHaveProperty('id', 1);
expect(userJSON).toHaveProperty('name', '张三');
expect(userJSON).toHaveProperty('email', 'zhangsan@example.com');
expect(userJSON).toHaveProperty('createdAt');
expect(userJSON.createdAt).toBeInstanceOf(Date);
});
});
});- 编写用户服务测试
// tests/utils/userService.test.js
const UserService = require('../../src/utils/userService');
const User = require('../../src/models/user');
describe('UserService', () => {
let userService;
let mockUserRepository;
beforeEach(() => {
// 创建模拟的用户仓库
mockUserRepository = {
save: jest.fn(),
findById: jest.fn(),
findAll: jest.fn()
};
userService = new UserService(mockUserRepository);
});
describe('createUser 方法', () => {
it('应该成功创建用户', async () => {
const userData = {
id: 1,
name: '张三',
email: 'zhangsan@example.com'
};
const expectedUser = new User(userData.id, userData.name, userData.email);
mockUserRepository.save.mockResolvedValue(expectedUser);
const result = await userService.createUser(userData);
expect(result).toEqual(expectedUser);
expect(mockUserRepository.save).toHaveBeenCalledWith(expectedUser);
});
it('当用户数据无效时应该抛出错误', async () => {
const invalidUserData = {
id: '1', // 无效的 id
name: '张三',
email: 'zhangsan@example.com'
};
await expect(userService.createUser(invalidUserData)).rejects.toThrow('无效的用户数据');
expect(mockUserRepository.save).not.toHaveBeenCalled();
});
it('当保存失败时应该抛出错误', async () => {
const userData = {
id: 1,
name: '张三',
email: 'zhangsan@example.com'
};
mockUserRepository.save.mockRejectedValue(new Error('保存失败'));
await expect(userService.createUser(userData)).rejects.toThrow('创建用户失败');
});
});
describe('getUserById 方法', () => {
it('应该成功获取用户', async () => {
const userId = 1;
const expectedUser = new User(userId, '张三', 'zhangsan@example.com');
mockUserRepository.findById.mockResolvedValue(expectedUser);
const result = await userService.getUserById(userId);
expect(result).toEqual(expectedUser);
expect(mockUserRepository.findById).toHaveBeenCalledWith(userId);
});
it('当 id 无效时应该抛出错误', async () => {
await expect(userService.getUserById(null)).rejects.toThrow('无效的用户ID');
await expect(userService.getUserById('1')).rejects.toThrow('无效的用户ID');
expect(mockUserRepository.findById).not.toHaveBeenCalled();
});
it('当用户不存在时应该抛出错误', async () => {
const userId = 999;
mockUserRepository.findById.mockResolvedValue(null);
await expect(userService.getUserById(userId)).rejects.toThrow('用户不存在');
});
it('当查询失败时应该抛出错误', async () => {
const userId = 1;
mockUserRepository.findById.mockRejectedValue(new Error('查询失败'));
await expect(userService.getUserById(userId)).rejects.toThrow('获取用户失败');
});
});
describe('getAllUsers 方法', () => {
it('应该成功获取所有用户', async () => {
const expectedUsers = [
new User(1, '张三', 'zhangsan@example.com'),
new User(2, '李四', 'lisi@example.com')
];
mockUserRepository.findAll.mockResolvedValue(expectedUsers);
const result = await userService.getAllUsers();
expect(result).toEqual(expectedUsers);
expect(mockUserRepository.findAll).toHaveBeenCalled();
});
it('当查询失败时应该抛出错误', async () => {
mockUserRepository.findAll.mockRejectedValue(new Error('查询失败'));
await expect(userService.getAllUsers()).rejects.toThrow('获取用户列表失败');
});
});
});- 运行测试
# 在 package.json 中添加脚本
{
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage"
}
}
# 运行所有测试
npm test
# 运行测试并生成覆盖率报告
npm run test:coverage代码优化建议
- 使用测试工具:选择适合项目的测试框架和工具
- 编写可测试的代码:遵循单一职责原则,使代码易于测试
- 隔离依赖:使用依赖注入和接口,便于 mock
- 使用测试替身:合理使用 mock、stub 和 spy
- 设置测试环境:使用环境变量和配置文件管理测试环境
- 并行测试:使用测试框架的并行运行功能提高测试速度
- 持续集成:在 CI/CD 流程中集成测试
- 监控测试覆盖率:定期检查和提高测试覆盖率
- 自动化测试:使用脚本和工具自动化测试过程
- 测试驱动开发:考虑使用 TDD 方法开发代码
学习总结
- 单元测试基础:单元测试的定义、重要性和特点
- 测试框架:Jest、Mocha、Vitest 等框架的使用
- 断言库:Jest 内置断言、Chai 等库的使用
- 测试覆盖率:覆盖率指标、工具和报告解读
- Mock 和 Stub:Jest 模拟函数和模块、Sinon 的使用
- 测试最佳实践:命名规范、测试结构、隔离、速度等
- 实战案例:完整的单元测试实现,包括计算器、用户模型和用户服务
- 代码优化:编写可测试代码的技巧和建议
动手练习
- 为一个简单的数学工具库编写单元测试
- 为一个 RESTful API 服务编写单元测试,使用 mock 模拟数据库操作
- 实现测试覆盖率达到 80% 以上的代码
- 使用 TDD 方法开发一个新功能
- 为现有项目添加单元测试
进阶学习
- **测试驱动开发 (TDD)**:先写测试,再写代码
- **行为驱动开发 (BDD)**:使用自然语言描述测试场景
- 属性测试:基于属性的随机测试
- 集成测试:测试多个模块的交互
- 端到端测试:测试完整的用户流程
- 测试自动化:在 CI/CD 中集成测试
- 性能测试:测试代码的性能
- 安全测试:测试代码的安全性
通过本教程的学习,你已经掌握了 Node.js 中单元测试的核心概念和实践方法,能够为你的应用编写高质量的单元测试。单元测试不仅可以提高代码质量,还可以使开发过程更加高效和可靠。