人人都会AI编程

服务层、工具函数单元测试

更新时间:2026-07-10

单元测试的核心价值在于快速验证代码单元的逻辑正确性,而服务层(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. 处理服务中的复杂依赖与外部模块

当服务依赖了像 bcryptjsonwebtoken 等第三方模块时,有两种策略:

  1. Mock 整个模块:在测试文件顶部通过 jest.mock 接管模块实现。
  2. 将外部模块抽象为可注入的服务:例如创建一个 HashService 统一管理加解密,测试时只需 mock HashService 实例。

示例: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 延迟和外部服务的不确定性,让测试在毫秒级完成。良好的测试不会束缚开发,反而让重构和迭代更有信心。