单元测试的核心价值在于快速验证代码单元的逻辑正确性,而服务层(Service Layer)和工具函数(Utility Functions)是后端项目中最应该被单元测试覆盖的部分。服务层封装了核心业务规则,工具函数承载了可复用的纯逻辑,它们通常不直接依赖 HTTP 请求或数据库连接,因而最适合编写稳定、高效的单元测试。
本节以 Jest 作为测试框架,结合 Node.js 中常见的目录结构,讲解如何对服务层和工具函数进行高质量的单元测试。示例代码同样适用于 Vitest 等类 Jest 工具,只需少量适配即可。
1. 工具函数测试:纯逻辑的“无副作用”保障
工具函数通常是纯函数:给定相同的输入,始终返回相同的输出,不修改外部状态,也不依赖外部服务。这类函数最易于测试,也最应该做到 100% 覆盖。
示例:一个数据格式转换工具
// src/utils/format.js
function toFixedPrice(price, decimalPlaces = 2) {
if (typeof price !== 'number' || isNaN(price)) {
throw new TypeError('price must be a valid number');
}
return Number(price.toFixed(decimalPlaces));
}
function maskEmail(email) {
const [name, domain] = email.split('@');
if (!name || !domain) return email;
const maskedName = name.slice(0, 2) + '***' + name.slice(-1);
return `${maskedName}@${domain}`;
}
module.exports = { toFixedPrice, maskEmail };
对应的测试文件:
// src/utils/__tests__/format.test.js
const { toFixedPrice, maskEmail } = require('../format');
describe('toFixedPrice', () => {
test('正常小数格式化', () => {
expect(toFixedPrice(3.1415)).toBe(3.14);
expect(toFixedPrice(3.1415, 3)).toBe(3.142);
});
test('整数输入', () => {
expect(toFixedPrice(5)).toBe(5.0);
});
test('非数字输入抛出异常', () => {
expect(() => toFixedPrice('abc')).toThrow(TypeError);
expect(() => toFixedPrice(NaN)).toThrow(TypeError);
});
});
describe('maskEmail', () => {
test('标准邮箱脱敏', () => {
expect(maskEmail('john.doe@example.com')).toBe('jo***e@example.com');
});
test('短用户名', () => {
expect(maskEmail('ab@test.com')).toBe('ab***b@test.com');
});
test('无效邮箱返回原值', () => {
expect(maskEmail('invalid')).toBe('invalid');
});
});
关键原则:
- 覆盖正常、边界和异常情况。比如空字符串、极端数值、非法类型等。
- 不依赖外部环境。工具函数测试中不应出现文件读取、数据库调用、网络请求等。
- 使用
describe组织测试套,使输出报告清晰易读。
2. 服务层单元测试:剥离依赖,聚焦业务规则
服务层通常依赖数据访问层(如 Repository、Model)或外部服务。单元测试的目标是只测试服务本身的逻辑,因此需要将这些依赖替换为可控的模拟对象(Mock)。
示例:用户服务
// src/services/userService.js
class UserService {
constructor(userRepository, emailService) {
this.userRepo = userRepository;
this.emailService = emailService;
}
async registerUser(username, password) {
if (!username || username.length < 3) {
throw new Error('用户名至少需要3个字符');
}
const exists = await this.userRepo.findByUsername(username);
if (exists) {
throw new Error('用户名已存在');
}
const user = await this.userRepo.create({ username, password });
await this.emailService.sendWelcomeEmail(user.email);
return user;
}
}
module.exports = UserService;
对应的单元测试(使用 Jest mock):
// src/services/__tests__/userService.test.js
const UserService = require('../userService');
describe('UserService - registerUser', () => {
let userService;
let mockRepo;
let mockEmail;
beforeEach(() => {
// 创建符合接口的模拟对象
mockRepo = {
findByUsername: jest.fn(),
create: jest.fn(),
};
mockEmail = {
sendWelcomeEmail: jest.fn(),
};
userService = new UserService(mockRepo, mockEmail);
});
test('正常注册用户', async () => {
mockRepo.findByUsername.mockResolvedValue(null); // 用户不存在
mockRepo.create.mockResolvedValue({ id: 1, username: 'alice', email: 'alice@test.com' });
mockEmail.sendWelcomeEmail.mockResolvedValue();
const user = await userService.registerUser('alice', 'password123');
expect(user.id).toBe(1);
expect(mockRepo.findByUsername).toHaveBeenCalledWith('alice');
expect(mockRepo.create).toHaveBeenCalledWith({ username: 'alice', password: 'password123' });
expect(mockEmail.sendWelcomeEmail).toHaveBeenCalledWith('alice@test.com');
});
test('用户名过短时抛出错误', async () => {
await expect(userService.registerUser('ab', 'pwd')).rejects.toThrow('用户名至少需要3个字符');
expect(mockRepo.findByUsername).not.toHaveBeenCalled(); // 未调用数据库
});
test('用户名已存在时抛出错误', async () => {
mockRepo.findByUsername.mockResolvedValue({ id: 2, username: 'alice' });
await expect(userService.registerUser('alice', 'pwd')).rejects.toThrow('用户名已存在');
expect(mockRepo.create).not.toHaveBeenCalled(); // 不创建
});
});
关键实践:
- 构造函数注入依赖,而不是在服务内部直接
require具体实现。这是代码可测试性的重要前提。 - 使用
jest.fn()创建 Mock,并指定返回值(mockResolvedValue/mockReturnValue)。Mock 既替代了真实的数据库 IO,又能验证服务与依赖的交互行为。 - 对异步方法使用
async/await+expect().rejects,确保错误处理路径被测试。 - 验证行为而非实现:重点校验服务是否调用了依赖的正确方法、传入合适的参数,以及产出预期的返回值或副作用。
3. 处理服务中的复杂依赖与外部模块
当服务依赖了像 bcrypt、jsonwebtoken 等第三方模块时,有两种策略:
- Mock 整个模块:在测试文件顶部通过
jest.mock接管模块实现。 - 将外部模块抽象为可注入的服务:例如创建一个
HashService统一管理加解密,测试时只需 mockHashService实例。
示例:Mock 外部模块 bcrypt
// src/services/authService.js
const bcrypt = require('bcrypt');
class AuthService {
async hashPassword(plainText) {
return bcrypt.hash(plainText, 10);
}
}
module.exports = AuthService;
测试代码:
jest.mock('bcrypt', () => ({
hash: jest.fn().mockResolvedValue('hashed_value'),
}));
const bcrypt = require('bcrypt');
const AuthService = require('../authService');
describe('AuthService', () => {
test('加密密码', async () => {
const auth = new AuthService();
const result = await auth.hashPassword('secret');
expect(result).toBe('hashed_value');
expect(bcrypt.hash).toHaveBeenCalledWith('secret', 10);
});
});
注意:jest.mock 必须放在文件顶层,Jest 会在编译时自动提升。这种 Mock 方式适合全局替换那些难以通过依赖注入间接控制的模块。
4. 工具类函数测试中的其他注意点
- 随机相关的工具函数:如果函数使用了
Math.random(),应使用jest.spyOn(Math, 'random').mockReturnValue(0.5)固定其输出,保证测试稳定。 - 时间相关的工具函数:涉及
Date.now()或计时器时,使用jest.useFakeTimers()控制时间流逝。 - 文件操作工具:如果工具函数需要真正读写文件,那么它实际上已经是集成测试范围。纯粹的逻辑函数不应直接读写文件,可依赖注入文件内容或抽象文件系统接口。如果真的需要,可以使用
memfs这类内存文件系统来模拟 IO。
5. 运行与覆盖率要求
在 package.json 中配置测试脚本:
{
"scripts": {
"test": "jest --coverage"
},
"jest": {
"testEnvironment": "node",
"roots": ["<rootDir>/src"]
}
}
运行 npm test,Jest 会找到所有 *.test.js 文件并输出覆盖率报告。建议对服务层和工具函数设定较高的覆盖率阈值(如 90% 以上),因为这些代码集中承载了业务逻辑,覆盖不充分会在重构或变更时引入风险。
6. 服务层单元测试的实用边界
单元测试并非要覆盖每一行代码,如果强行对高度耦合数据库查增删改、错综复杂的交互进行 Mock,测试维护成本会急剧上升。对于这些场景,更适合用集成测试(使用真实或内存数据库)来验证完整流程。服务层的单元测试更应聚焦在:
- 业务规则的判断分支
- 数据转换逻辑
- 外部依赖的调用协调(是否调用了正确的接口,传入了正确的参数)
- 异常处理路径
通过合理划分单元测试与集成测试的边界,才能让测试体系既快速又可靠。
小结: 服务层和工具函数的单元测试是保证业务逻辑正确性的第一道防线。工具函数测试力求全面覆盖纯函数的各种输入场景;服务层测试则借助依赖注入与 Mock,剥离了 IO 延迟和外部服务的不确定性,让测试在毫秒级完成。良好的测试不会束缚开发,反而让重构和迭代更有信心。