TypeScript 单元测试
TypeScript 项目中的单元测试实践,确保代码质量。
单元测试可以验证代码的正确性,TypeScript 的类型系统与测试框架完美结合,可以编写类型安全的测试代码。
为什么需要单元测试
单元测试是保证代码质量的重要手段,它可以验证代码的正确性,防止 bug 出现。
TypeScript 项目中,测试代码同样受益于类型系统:类型错误会在编译时被发现,IDE 提供智能提示,测试代码更可靠。
此外,测试代码也是最好的文档,可以通过测试了解函数/类的预期行为。
质量保障:单元测试可以快速发现回归问题,确保代码改动不会破坏现有功能。
测试框架配置
Jest 是 TypeScript 项目最流行的测试框架。
安装 Jest
# 安装 Jest 和相关依赖
# - ts-jest: 让 Jest 能够运行 TypeScript
# - @types/jest: Jest 的类型定义
npm install --save-dev jest ts-jest @types/jest
# 初始化 Jest 配置
npx ts-jest config:init
# - ts-jest: 让 Jest 能够运行 TypeScript
# - @types/jest: Jest 的类型定义
npm install --save-dev jest ts-jest @types/jest
# 初始化 Jest 配置
npx ts-jest config:init
ts-jest:这是一个预处理器,让 Jest 能够直接运行 TypeScript 文件,无需手动编译。
配置 jest.config.js
配置 Jest 测试环境。
jest.config.js
module.exports = {
// 使用 ts-jest 预设
preset: 'ts-jest',
// 测试环境:node 或 browser
testEnvironment: 'node',
// 测试文件目录
roots: ['<rootDir>/src'],
// 测试文件匹配模式
testMatch: ['**/__tests__/**/*.ts'],
// 支持的文件扩展名
moduleFileExtensions: ['ts', 'js', 'json'],
// 收集覆盖率的文件
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts' // 排除类型声明文件
]
}
// 使用 ts-jest 预设
preset: 'ts-jest',
// 测试环境:node 或 browser
testEnvironment: 'node',
// 测试文件目录
roots: ['<rootDir>/src'],
// 测试文件匹配模式
testMatch: ['**/__tests__/**/*.ts'],
// 支持的文件扩展名
moduleFileExtensions: ['ts', 'js', 'json'],
// 收集覆盖率的文件
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts' // 排除类型声明文件
]
}
配置说明:测试文件通常放在 __tests__ 目录或以 .test.ts 结尾。
测试函数
首先编写需要测试的业务代码。
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;
}
}
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();
});
});
});
// 测试套件: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();
});
});
});
运行结果:
Calculator
add
✓ should add two numbers
✓ should handle negative numbers
divide
✓ should divide two numbers
✓ should throw error when dividing by zero
describe/it:describe 用于分组测试,it(或 test)用于定义单个测试用例。
测试 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];
}
}
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();
});
});
});
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();
});
});
});
运行结果:
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
测试隔离:每个测试用例应该独立,使用 beforeEach 确保每个测试都有干净的状态。
Mock
使用 Mock 模拟依赖,如外部 API、数据库等。
实例
// Mock 函数:创建模拟函数
const mockCallback = jest.fn(x => x * 2);
// 使用模拟函数
[1, 2, 3].forEach(mockCallback);
// 验证函数被调用了 3 次
expect(mockCallback).toHaveBeenCalledTimes(3);
// 验证函数被调用时的参数
expect(mockCallback).toHaveBeenCalledWith(2);
// Mock 模块:模拟整个模块
jest.mock("./api", () => ({
fetchUser: jest.fn(() => Promise.resolve({ id: 1, name: "Alice" }))
}));
const mockCallback = jest.fn(x => x * 2);
// 使用模拟函数
[1, 2, 3].forEach(mockCallback);
// 验证函数被调用了 3 次
expect(mockCallback).toHaveBeenCalledTimes(3);
// 验证函数被调用时的参数
expect(mockCallback).toHaveBeenCalledWith(2);
// Mock 模块:模拟整个模块
jest.mock("./api", () => ({
fetchUser: jest.fn(() => Promise.resolve({ id: 1, name: "Alice" }))
}));
运行结果:
✓ mock 函数被调用 3 次
Mock 用途:当测试的代码依赖外部系统时,使用 Mock 可以隔离依赖,只测试目标代码的逻辑。
注意事项
- 测试文件位置:放在 __tests__ 目录或使用 .test.ts 后缀
- 测试命名:使用描述性的测试名称,说明预期行为
- 独立测试:每个测试应该独立运行,不依赖其他测试
- 覆盖率:关注核心业务逻辑的测试覆盖率
最佳实践:测试应该快速、可靠、相互独立。遵循 AAA 原则:Arrange(准备)、Act(执行)、Assert(断言)。
总结
单元测试是保证 TypeScript 代码质量的重要手段。
- Jest:最流行的 TypeScript 测试框架
- describe:用于分组相关测试
- it/test:定义单个测试用例
- expect:断言测试结果
- Mock:模拟外部依赖
建议:为关键业务逻辑编写测试,确保代码改动不会引入 bug。
点我分享笔记