TypeScript 单元测试入门与实践(二十三)

在现代软件开发中,单元测试是确保代码质量和可靠性的重要手段。特别是对于 TypeScript 项目,其强大的类型系统与测试框架的结合,使得编写类型安全的测试代码变得更加容易。本文将详细介绍如何在 TypeScript 项目中进行单元测试,包括为什么需要单元测试、测试框架的配置、具体测试代码的编写以及一些最佳实践。

为什么需要单元测试

单元测试是验证代码正确性和稳定性的有效方法。通过编写单元测试,我们可以确保每个函数或类的行为符合预期,从而减少 bug 的出现。在 TypeScript 项目中,单元测试还具有以下优势:

  • 类型安全:TypeScript 的类型系统可以在编译时捕获类型错误,IDE 还能提供智能提示,使测试代码更加可靠。
  • 文档作用:测试代码本身就是最好的文档,通过阅读测试用例,开发者可以快速了解函数或类的预期行为。
  • 快速反馈:单元测试可以快速发现回归问题,确保代码改动不会破坏现有功能。

测试框架配置

Jest 是目前最流行的 JavaScript 和 TypeScript 测试框架之一,它提供了丰富的功能和良好的社区支持。接下来,我们将详细介绍如何在 TypeScript 项目中配置和使用 Jest。

安装 Jest

首先,我们需要安装 Jest 及其相关的 TypeScript 预处理器 ts-jest。ts-jest 允许 Jest 直接运行 TypeScript 文件,无需手动编译。

npm install --save-dev jest ts-jest @types/jest

配置 jest.config.js

接下来,我们需要配置 Jest 的测试环境。创建一个 jest.config.js 文件,并添加以下内容:

module.exports = {
  // 使用 ts-jest 预设
  preset: 'ts-jest',
  // 测试环境:node 或 browser
  testEnvironment: 'node',
  // 测试文件目录
  roots: ['
<rootDir>/src'],
  // 测试文件匹配模式
  testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
  // 支持的文件扩展名
  moduleFileExtensions: ['ts', 'js', 'json'],
  // 收集覆盖率的文件
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts' // 排除类型声明文件
  ]
};

配置说明

  • preset: 使用 ts-jest 预设,以便 Jest 能够处理 TypeScript 文件。
  • testEnvironment: 指定测试环境,通常选择 node。
  • roots: 指定测试文件的根目录。
  • testMatch: 指定测试文件的匹配模式,通常放在 tests 目录或以 .test.ts 结尾。
  • moduleFileExtensions: 指定支持的文件扩展名。
  • collectCoverageFrom: 指定需要收集代码覆盖率的文件,排除类型声明文件。

测试函数

让我们从一个简单的计算器类开始,编写相应的测试代码。

编写业务代码

首先,创建一个 src/utils/calculator.ts 文件,定义一个计算器类:

// 计算器类
export class Calculator {
  // 加法
  add(a: number, b: number): number {
    return a + b;
  }

  // 减法
  subtract(a: number, b: number): number {
    return a - b;
  }

  // 乘法
  multiply(a: number, b: number): number {
    return a * b;
  }

  // 除法
  divide(a: number, b: number): number {
    if (b === 0) {
      throw new Error("Cannot divide by zero");
    }
    return a / b;
  }
}

编写测试代码

接下来,创建一个 src/utils/calculator.test.ts 文件,编写测试用例:

import { Calculator } from './calculator';

// 测试套件:Calculator 类的测试
describe('Calculator', () => {
  let calculator: Calculator;

  // 每个测试前创建新的 Calculator 实例
  beforeEach(() => {
    calculator = new Calculator();
  });

  // 加法测试
  describe('add', () => {
    it('should add two numbers', () => {
      expect(calculator.add(2, 3)).toBe(5);
    });

    it('should handle negative numbers', () => {
      expect(calculator.add(-1, 1)).toBe(0);
    });
  });

  // 除法测试
  describe('divide', () => {
    it('should divide two numbers', () => {
      expect(calculator.divide(10, 2)).toBe(5);
    });

    it('should throw error when dividing by zero', () => {
      // 期望抛出错误
      expect(() => calculator.divide(10, 0)).toThrow();
    });
  });
});

运行测试

运行以下命令来执行测试:

npx jest

如果一切正常,你应该看到类似以下的输出:

Calculator
  add
    ✓ should add two numbers
    ✓ should handle negative numbers

  divide
    ✓ should divide two numbers
    ✓ should throw error when dividing by zero

测试 Service

接下来,我们来看一个更复杂的例子,测试 Service 层的业务逻辑。

编写业务代码

创建一个 src/services/userService.ts 文件,定义一个用户服务类:

// 用户类型
export interface User {
  id: number;
  name: string;
}

// 用户服务类
export class UserService {
  private users: User[] = [];
  private nextId = 1;

  // 创建用户
  createUser(name: string): User {
    const user = { id: this.nextId++, name };
    this.users.push(user);
    return user;
  }

  // 获取用户
  getUser(id: number): User | undefined {
    return this.users.find(u => u.id === id);
  }

  // 获取所有用户
  getAllUsers(): User[] {
    return [...this.users];
  }
}

编写测试代码

创建一个 src/services/userService.test.ts 文件,编写测试用例:

import { UserService } from './userService';

describe('UserService', () => {
  let service: UserService;

  beforeEach(() => {
    service = new UserService();
  });

  // 创建用户测试
  describe('createUser', () => {
    it('should create a user with id', () => {
      const user = service.createUser('Alice');
      expect(user.id).toBe(1);
      expect(user.name).toBe('Alice');
    });

    it('should increment id for each user', () => {
      const user1 = service.createUser('Alice');
      const user2 = service.createUser('Bob');
      expect(user2.id).toBe(user1.id + 1);
    });
  });

  // 获取用户测试
  describe('getUser', () => {
    it('should return user by id', () => {
      const created = service.createUser('Alice');
      const found = service.getUser(created.id);
      // 使用可选链和 toBe
      expect(found?.name).toBe('Alice');
    });

    it('should return undefined for non-existent id', () => {
      const found = service.getUser(999);
      expect(found).toBeUndefined();
    });
  });
});

运行测试

再次运行测试命令:

npx jest

你应该看到类似以下的输出:

UserService
  createUser
    ✓ should create a user with id
    ✓ should increment id for each user
  getUser
    ✓ should return user by id
    ✓ should return undefined for non-existent id

Mock

在单元测试中,我们经常需要模拟外部依赖,如外部 API、数据库等。Jest 提供了强大的 Mock 功能,可以帮助我们实现这一点。

使用 Mock 模拟函数

// Mock 函数:创建模拟函数
const mockCallback = jest.fn(x => x * 2);

// 使用模拟函数
[1, 2, 3].forEach(mockCallback);

// 验证函数被调用了 3 次
expect(mockCallback).toHaveBeenCalledTimes(3);

// 验证函数被调用时的参数
expect(mockCallback).toHaveBeenCalledWith(2);

使用 Mock 模拟模块

// Mock 模块:模拟整个模块
jest.mock('./api', () => ({
  fetchUser: jest.fn(() => Promise.resolve({ id: 1, name: 'Alice' }))
}));

// 使用模拟模块
import { fetchUser } from './api';

it('should fetch user', async () => {
  const user = await fetchUser();
  expect(user.id).toBe(1);
  expect(user.name).toBe('Alice');
});

运行结果

✓ should fetch user

注意事项

  • 测试文件位置:测试文件通常放在 tests 目录或使用 .test.ts 后缀。
  • 测试命名:使用描述性的测试名称,说明预期行为。
  • 独立测试:每个测试应该独立运行,不依赖其他测试。
  • 覆盖率:关注核心业务逻辑的测试覆盖率。

最佳实践

  • 快速、可靠:测试应该快速、可靠地运行。

  • 相互独立:每个测试用例应该独立,使用 beforeEach 确保每个测试都有干净的状态。

  • AAA 原则:遵循 Arrange(准备)、Act(执行)、Assert(断言)的原则。

总结

单元测试是确保 TypeScript 代码质量的重要手段。通过使用 Jest 测试框架,我们可以轻松地编写类型安全的测试代码。本文介绍了如何配置 Jest、编写测试代码以及一些最佳实践。希望这些内容能帮助你在 TypeScript 项目中更好地进行单元测试,确保代码的稳定性和可靠性。