跳到主要内容

TypeScript 测试

测试是保证代码质量的重要手段。TypeScript 的类型系统在编译时捕获错误,但运行时测试仍然是必不可少的。本章介绍如何在 TypeScript 项目中编写和运行测试,包括单元测试、集成测试和端到端测试。

测试类型概述

单元测试

单元测试测试独立的代码单元(函数、类、模块),确保它们按预期工作。

// src/utils/calculator.ts
export function add(a: number, b: number): number {
return a + b;
}

export function divide(a: number, b: number): number {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}

// tests/calculator.test.ts
import { add, divide } from '@/utils/calculator';

describe('Calculator', () => {
describe('add', () => {
it('should add two positive numbers', () => {
expect(add(1, 2)).toBe(3);
});

it('should add negative numbers', () => {
expect(add(-1, -2)).toBe(-3);
});

it('should handle zero', () => {
expect(add(0, 5)).toBe(5);
});
});

describe('divide', () => {
it('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5);
});

it('should throw error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
});

集成测试

集成测试测试多个组件之间的交互。

// tests/integration/user.test.ts
import { UserService } from '@/services/userService';
import prisma from '@/lib/prisma';

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

beforeAll(async () => {
// 连接测试数据库
userService = new UserService();
});

afterAll(async () => {
// 清理并断开连接
await prisma.$disconnect();
});

beforeEach(async () => {
// 清理数据库
await prisma.user.deleteMany();
});

it('should create and retrieve a user', async () => {
// 创建用户
const createData = {
email: '[email protected]',
name: 'Test User',
password: 'password123'
};

const created = await userService.create(createData);

// 验证创建结果
expect(created.email).toBe(createData.email);
expect(created.name).toBe(createData.name);

// 检索用户
const found = await userService.findById(created.id);
expect(found).not.toBeNull();
expect(found?.email).toBe(createData.email);
});
});

Jest 配置

安装和初始化

npm install -D jest ts-jest @types/jest

配置文件

jest.config.js

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
// 使用 ts-jest 预设
preset: 'ts-jest',

// 测试环境
testEnvironment: 'node',

// 测试文件位置
roots: ['<rootDir>/src', '<rootDir>/tests'],

// 测试文件匹配模式
testMatch: [
'**/__tests__/**/*.ts',
'**/*.test.ts',
'**/*.spec.ts'
],

// 路径映射
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},

// 忽略的文件
testPathIgnorePatterns: ['/node_modules/', '/dist/'],

// 覆盖率配置
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/index.ts',
'!src/types/**'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70
}
},

// 设置文件
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],

// 全局配置
globals: {
'ts-jest': {
isolatedModules: true
}
}
};

测试设置文件

tests/setup.ts

// 扩展 Jest 匹配器
import { expect, beforeAll, afterAll } from '@jest/globals';

// 设置测试超时
jest.setTimeout(10000);

// 全局设置
beforeAll(() => {
// 初始化测试数据库连接等
});

afterAll(() => {
// 清理资源
});

// 自定义匹配器
expect.extend({
toBeValidEmail(received: string) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const pass = emailRegex.test(received);

return {
pass,
message: () =>
pass
? `expected ${received} not to be a valid email`
: `expected ${received} to be a valid email`
};
}
});

// 声明自定义匹配器类型
declare global {
namespace jest {
interface Matchers<R> {
toBeValidEmail(): R;
}
}
}

package.json 脚本

{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --maxWorkers=2"
}
}

Vitest 配置

Vitest 是一个现代化的测试框架,速度更快,与 Vite 项目无缝集成。

安装

npm install -D vitest

配置文件

vitest.config.ts

import { defineConfig } from 'vitest/config';
import path from 'path';

export default defineConfig({
test: {
// 启用全局 API
globals: true,

// 测试环境
environment: 'node',

// 测试文件匹配模式
include: ['tests/**/*.test.ts', 'src/**/*.test.ts'],

// 排除文件
exclude: ['node_modules', 'dist'],

// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/index.ts'
]
},

// 设置文件
setupFiles: ['./tests/setup.ts'],

// 测试超时
testTimeout: 10000,

// 并行执行
pool: 'threads',
poolOptions: {
threads: {
singleThread: false
}
}
},

// 路径映射
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
});

Vitest 测试示例

// tests/user.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { UserService } from '@/services/userService';
import prisma from '@/lib/prisma';

// Mock Prisma
vi.mock('@/lib/prisma', () => ({
default: {
user: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
}
}
}));

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

beforeEach(() => {
service = new UserService();
vi.clearAllMocks();
});

describe('findAll', () => {
it('should return all users', async () => {
const mockUsers = [
{ id: '1', name: 'User 1', email: '[email protected]' },
{ id: '2', name: 'User 2', email: '[email protected]' }
];

vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers);

const result = await service.findAll();

expect(result).toEqual(mockUsers);
expect(prisma.user.findMany).toHaveBeenCalledTimes(1);
});
});
});

Mock 和 Stub

使用 Jest Mock

// tests/userService.test.ts
import { UserService } from '@/services/userService';
import { EmailService } from '@/services/emailService';
import prisma from '@/lib/prisma';

// Mock 整个模块
jest.mock('@/lib/prisma');

// Mock 特定函数
const mockSendEmail = jest.fn();
jest.mock('@/services/emailService', () => ({
EmailService: jest.fn().mockImplementation(() => ({
sendEmail: mockSendEmail
}))
}));

describe('UserService', () => {
let userService: UserService;
let emailService: EmailService;

beforeEach(() => {
jest.clearAllMocks();
emailService = new EmailService();
userService = new UserService(emailService);
});

it('should send welcome email after creating user', async () => {
const userData = {
name: 'Test User',
email: '[email protected]',
password: 'password123'
};

vi.mocked(prisma.user.create).mockResolvedValue({
id: '1',
...userData,
createdAt: new Date(),
updatedAt: new Date()
});

await userService.create(userData);

expect(mockSendEmail).toHaveBeenCalledWith(
userData.email,
expect.stringContaining('Welcome'),
expect.any(String)
);
});
});

Spy 函数

// tests/logger.test.ts
import { Logger } from '@/utils/logger';

describe('Logger', () => {
let logger: Logger;
let consoleSpy: jest.SpyInstance;

beforeEach(() => {
logger = new Logger();
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
});

afterEach(() => {
consoleSpy.mockRestore();
});

it('should log info message', () => {
logger.info('Test message');

expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[INFO]'),
'Test message'
);
});
});

手动 Mock

// tests/__mocks__/prisma.ts
const mockPrisma = {
user: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn()
},
$connect: jest.fn(),
$disconnect: jest.fn()
};

export default mockPrisma;

测试异步代码

Promise 测试

describe('Async Operations', () => {
it('should resolve with correct value', async () => {
const result = await asyncFunction();
expect(result).toBe('expected value');
});

it('should reject with error', async () => {
await expect(failingAsyncFunction()).rejects.toThrow('Expected error');
});

// 使用 resolves/rejects 匹配器
it('should resolve using resolves matcher', () => {
return expect(Promise.resolve('value')).resolves.toBe('value');
});
});

回调测试

describe('Callback Tests', () => {
it('should call callback with result', (done) => {
functionWithCallback((error, result) => {
expect(error).toBeNull();
expect(result).toBe('expected');
done();
});
});

// 使用 promisify 包装回调
import { promisify } from 'util';

it('should work with promisified callback', async () => {
const promisifiedFunction = promisify(functionWithCallback);
const result = await promisifiedFunction();
expect(result).toBe('expected');
});
});

定时器测试

describe('Timer Tests', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('should call callback after delay', () => {
const callback = jest.fn();

setTimeout(callback, 1000);

expect(callback).not.toHaveBeenCalled();

// 快进时间
jest.advanceTimersByTime(1000);

expect(callback).toHaveBeenCalledTimes(1);
});

it('should handle setInterval', () => {
const callback = jest.fn();

setInterval(callback, 100);

jest.advanceTimersByTime(350);

expect(callback).toHaveBeenCalledTimes(3);
});
});

测试 Express 应用

测试路由

// tests/routes/userRoutes.test.ts
import request from 'supertest';
import express from 'express';
import userRoutes from '@/routes/userRoutes';
import { UserService } from '@/services/userService';

// Mock UserService
jest.mock('@/services/userService');

describe('User Routes', () => {
let app: express.Application;

beforeEach(() => {
app = express();
app.use(express.json());
app.use('/api/users', userRoutes);
jest.clearAllMocks();
});

describe('GET /api/users', () => {
it('should return all users', async () => {
const mockUsers = [
{ id: '1', name: 'User 1', email: '[email protected]' }
];

(UserService as jest.Mock).mockImplementation(() => ({
findAll: jest.fn().mockResolvedValue(mockUsers)
}));

const response = await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200);

expect(response.body).toEqual(mockUsers);
});
});

describe('POST /api/users', () => {
it('should create a new user', async () => {
const newUser = {
name: 'New User',
email: '[email protected]',
password: 'password123'
};

const createdUser = {
id: '1',
...newUser,
createdAt: expect.any(String),
updatedAt: expect.any(String)
};

(UserService as jest.Mock).mockImplementation(() => ({
create: jest.fn().mockResolvedValue(createdUser)
}));

const response = await request(app)
.post('/api/users')
.send(newUser)
.expect('Content-Type', /json/)
.expect(201);

expect(response.body.name).toBe(newUser.name);
expect(response.body.email).toBe(newUser.email);
});

it('should validate input', async () => {
const invalidUser = {
name: '',
email: 'invalid-email'
};

const response = await request(app)
.post('/api/users')
.send(invalidUser)
.expect(400);

expect(response.body.error).toBeDefined();
});
});
});

测试中间件

// tests/middleware/auth.test.ts
import request from 'supertest';
import express, { Response } from 'express';
import { authenticate } from '@/middleware/auth';
import { AuthRequest } from '@/types/express';

describe('Auth Middleware', () => {
let app: express.Application;

beforeEach(() => {
app = express();
app.use(express.json());
});

it('should reject request without token', async () => {
app.get('/protected', authenticate, (req: AuthRequest, res: Response) => {
res.json({ message: 'success' });
});

const response = await request(app)
.get('/protected')
.expect(401);

expect(response.body.error).toBe('No token provided');
});

it('should accept valid token', async () => {
app.get('/protected', authenticate, (req: AuthRequest, res: Response) => {
res.json({ user: req.user });
});

const validToken = 'valid-token';

const response = await request(app)
.get('/protected')
.set('Authorization', `Bearer ${validToken}`)
.expect(200);

expect(response.body.user).toBeDefined();
});
});

快照测试

快照测试捕获组件或数据的快照,当快照发生变化时通知开发者。

// tests/snapshot.test.ts
describe('Snapshot Tests', () => {
it('should match user object snapshot', () => {
const user = {
id: '1',
name: 'Test User',
email: '[email protected]',
createdAt: '2024-01-01T00:00:00.000Z'
};

expect(user).toMatchSnapshot();
});

it('should match API response snapshot', async () => {
const response = await fetch('/api/users');
const data = await response.json();

expect(data).toMatchSnapshot();
});

// 内联快照
it('should match inline snapshot', () => {
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000
};

expect(config).toMatchInlineSnapshot(`
{
"apiUrl": "https://api.example.com",
"timeout": 5000
}
`);
});

// 属性匹配器
it('should match with property matchers', () => {
const user = {
id: expect.any(String),
name: 'Test User',
createdAt: expect.any(Date),
updatedAt: expect.any(Date)
};

expect(user).toMatchSnapshot();
});
});

更新快照

# 更新所有快照
npm test -- --updateSnapshot

# 只更新失败的快照
npm test -- --updateSnapshot --testPathPattern="snapshot"

测试覆盖率

配置覆盖率阈值

// jest.config.js
module.exports = {
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
},
// 针对特定文件设置不同阈值
'./src/services/': {
branches: 90,
functions: 90
},
'./src/utils/': {
statements: 100
}
}
};

覆盖率报告

运行覆盖率测试后,会生成详细报告:

npm run test:coverage

覆盖率报告包括:

  • Statements:语句覆盖率
  • Branches:分支覆盖率
  • Functions:函数覆盖率
  • Lines:行覆盖率

提高覆盖率的技巧

  1. 测试边界条件
describe('validateAge', () => {
it('should accept valid ages', () => {
expect(validateAge(0)).toBe(true);
expect(validateAge(18)).toBe(true);
expect(validateAge(120)).toBe(true);
});

it('should reject invalid ages', () => {
expect(validateAge(-1)).toBe(false);
expect(validateAge(121)).toBe(false);
expect(validateAge(NaN)).toBe(false);
});
});
  1. 测试错误路径
describe('UserService', () => {
it('should throw NotFoundError when user not found', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);

await expect(service.findById('999'))
.rejects
.toThrow(NotFoundError);
});

it('should handle database errors', async () => {
mockPrisma.user.findMany.mockRejectedValue(new Error('DB Error'));

await expect(service.findAll())
.rejects
.toThrow('DB Error');
});
});
  1. 使用参数化测试
describe.each([
[1, 2, 3],
[0, 0, 0],
[-1, 1, 0],
[100, -50, 50]
])('add(%i, %i)', (a, b, expected) => {
it(`should return ${expected}`, () => {
expect(add(a, b)).toBe(expected);
});
});

// 或者使用模板字符串语法
describe('divide', () => {
it.each`
a | b | expected
${10} | ${2} | ${5}
${9} | ${3} | ${3}
${7} | ${7} | ${1}
${0} | ${5} | ${0}
`('should return $expected when dividing $a by $b', ({ a, b, expected }) => {
expect(divide(a, b)).toBe(expected);
});
});

测试最佳实践

1. 描述性测试名称

// ❌ 不好
it('test1', () => { ... });
it('should work', () => { ... });

// ✅ 好
it('should return user when valid id is provided', () => { ... });
it('should throw NotFoundError when user does not exist', () => { ... });
it('should send welcome email after successful registration', () => { ... });

2. AAA 模式

it('should calculate total price with discount', () => {
// Arrange(准备)
const items = [
{ price: 100, quantity: 2 },
{ price: 50, quantity: 1 }
];
const discount = 0.1;

// Act(执行)
const total = calculateTotal(items, discount);

// Assert(断言)
expect(total).toBe(225); // (200 + 50) * 0.9
});

3. 测试隔离

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

beforeEach(() => {
// 每个测试都有新的实例
service = new UserService();
jest.clearAllMocks();
});

afterEach(() => {
// 清理资源
jest.restoreAllMocks();
});
});

4. 避免测试实现细节

// ❌ 测试实现细节
it('should set internal state to loading', () => {
const service = new UserService();
service.fetchUsers();
expect(service.loading).toBe(true);
});

// ✅ 测试行为
it('should emit loading event when fetching users', async () => {
const service = new UserService();
const listener = jest.fn();
service.on('loading', listener);

const promise = service.fetchUsers();

expect(listener).toHaveBeenCalledWith(true);
await promise;
expect(listener).toHaveBeenCalledWith(false);
});

5. 使用测试工具函数

// tests/helpers/factories.ts
export function createTestUser(overrides: Partial<User> = {}): User {
return {
id: 'test-id',
name: 'Test User',
email: '[email protected]',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
...overrides
};
}

export function createTestRequest(overrides: Partial<Request> = {}): Request {
return {
body: {},
params: {},
query: {},
headers: {},
...overrides
} as Request;
}

// 使用
describe('UserController', () => {
it('should update user', async () => {
const user = createTestUser({ name: 'Original Name' });
const req = createTestRequest({
params: { id: user.id },
body: { name: 'New Name' }
});

await controller.update(req, mockResponse, mockNext);

expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ name: 'New Name' })
);
});
});

小结

本章介绍了 TypeScript 项目中的测试实践:

  • 测试类型:单元测试、集成测试、端到端测试
  • 测试框架:Jest 和 Vitest 的配置与使用
  • Mock 和 Stub:模拟依赖和行为
  • 异步测试:Promise、回调、定时器测试
  • Express 测试:路由和中间件测试
  • 快照测试:捕获和验证数据快照
  • 测试覆盖率:配置和提升覆盖率
  • 最佳实践:描述性命名、AAA 模式、测试隔离

良好的测试习惯能显著提升代码质量和开发效率。

参考资料