Node.js 单元测试

学习目标

  • 理解单元测试的基本概念和重要性
  • 掌握常用的 Node.js 测试框架
  • 学会使用断言库进行测试验证
  • 了解测试覆盖率的概念和工具
  • 能够使用 mock 和 stub 技术
  • 编写高质量的单元测试
  • 实现一个完整的单元测试案例

什么是单元测试?

单元测试是对软件中最小可测试单元进行的测试,通常是指函数或方法。单元测试的目的是验证每个单元是否按照预期工作。

单元测试的重要性

  1. 提高代码质量:通过测试发现并修复潜在问题
  2. 减少回归错误:确保修改代码后不会破坏现有功能
  3. 改善代码设计:促进模块化和可测试的代码结构
  4. 简化调试:快速定位问题所在
  5. 提供文档:测试用例本身就是代码的使用文档
  6. 增强信心:对代码质量有更清晰的认识

单元测试的特点

  • 独立性:每个测试用例应该独立运行,不依赖于其他测试
  • 可重复性:相同的测试用例应该总是产生相同的结果
  • 快速执行:单元测试应该快速运行,以便频繁执行
  • 隔离性:测试应该隔离外部依赖,如数据库、网络等

常用的 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 -- --coverage

2. 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.js

3. 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 -- --coverage

4. 测试框架对比

框架 优点 缺点 适用场景
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=coverage

2. Istanbul/NYC

Istanbul 是一个强大的 JavaScript 代码覆盖率工具,NYC 是它的命令行接口。

npm install --save-dev nyc
# 在 package.json 中添加脚本
{
  "scripts": {
    "test": "mocha",
    "coverage": "nyc mocha"
  }
}

# 运行覆盖率测试
npm run coverage

3. 覆盖率报告解读

覆盖率报告通常包括:

  • 总体覆盖率统计
  • 每个文件的覆盖率详情
  • 未覆盖的代码行
  • 可视化的覆盖率报告(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

实现步骤

  1. 创建计算器模块
// 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;
  1. 创建用户模型
// 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;
  1. 创建用户服务
// 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;
  1. 编写计算器测试
// 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);
    });
  });
});
  1. 编写用户模型测试
// 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);
    });
  });
});
  1. 编写用户服务测试
// 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('获取用户列表失败');
    });
  });
});
  1. 运行测试
# 在 package.json 中添加脚本
{
  "scripts": {
    "test": "jest",
    "test:coverage": "jest --coverage"
  }
}

# 运行所有测试
npm test

# 运行测试并生成覆盖率报告
npm run test:coverage

代码优化建议

  1. 使用测试工具:选择适合项目的测试框架和工具
  2. 编写可测试的代码:遵循单一职责原则,使代码易于测试
  3. 隔离依赖:使用依赖注入和接口,便于 mock
  4. 使用测试替身:合理使用 mock、stub 和 spy
  5. 设置测试环境:使用环境变量和配置文件管理测试环境
  6. 并行测试:使用测试框架的并行运行功能提高测试速度
  7. 持续集成:在 CI/CD 流程中集成测试
  8. 监控测试覆盖率:定期检查和提高测试覆盖率
  9. 自动化测试:使用脚本和工具自动化测试过程
  10. 测试驱动开发:考虑使用 TDD 方法开发代码

学习总结

  1. 单元测试基础:单元测试的定义、重要性和特点
  2. 测试框架:Jest、Mocha、Vitest 等框架的使用
  3. 断言库:Jest 内置断言、Chai 等库的使用
  4. 测试覆盖率:覆盖率指标、工具和报告解读
  5. Mock 和 Stub:Jest 模拟函数和模块、Sinon 的使用
  6. 测试最佳实践:命名规范、测试结构、隔离、速度等
  7. 实战案例:完整的单元测试实现,包括计算器、用户模型和用户服务
  8. 代码优化:编写可测试代码的技巧和建议

动手练习

  1. 为一个简单的数学工具库编写单元测试
  2. 为一个 RESTful API 服务编写单元测试,使用 mock 模拟数据库操作
  3. 实现测试覆盖率达到 80% 以上的代码
  4. 使用 TDD 方法开发一个新功能
  5. 为现有项目添加单元测试

进阶学习

  • **测试驱动开发 (TDD)**:先写测试,再写代码
  • **行为驱动开发 (BDD)**:使用自然语言描述测试场景
  • 属性测试:基于属性的随机测试
  • 集成测试:测试多个模块的交互
  • 端到端测试:测试完整的用户流程
  • 测试自动化:在 CI/CD 中集成测试
  • 性能测试:测试代码的性能
  • 安全测试:测试代码的安全性

通过本教程的学习,你已经掌握了 Node.js 中单元测试的核心概念和实践方法,能够为你的应用编写高质量的单元测试。单元测试不仅可以提高代码质量,还可以使开发过程更加高效和可靠。

« 上一篇 Node.js 日志系统 下一篇 » Node.js 集成测试