TypeScript 开发指南 / 21 - 测试
测试
Jest + TypeScript 配置
安装
npm install -D jest @jest/globals ts-jest @types/jest
配置
// jest.config.ts
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/src"],
testMatch: ["**/__tests__/**/*.ts", "**/*.test.ts", "**/*.spec.ts"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1"
},
collectCoverageFrom: [
"src/**/*.ts",
"!src/**/*.d.ts",
"!src/**/index.ts"
]
};
export default config;
package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
基本测试
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(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;
}
// math.test.ts
import { describe, it, expect } from "@jest/globals";
import { add, multiply, divide } from "./math";
describe("math", () => {
describe("add", () => {
it("should add two positive numbers", () => {
expect(add(1, 2)).toBe(3);
});
it("should handle negative numbers", () => {
expect(add(-1, -2)).toBe(-3);
expect(add(-1, 2)).toBe(1);
});
});
describe("multiply", () => {
it("should multiply two numbers", () => {
expect(multiply(2, 3)).toBe(6);
});
});
describe("divide", () => {
it("should divide two numbers", () => {
expect(divide(6, 2)).toBe(3);
});
it("should throw on division by zero", () => {
expect(() => divide(1, 0)).toThrow("Division by zero");
});
});
});
测试异步代码
// async.test.ts
import { describe, it, expect } from "@jest/globals";
// Promise
describe("async", () => {
it("should resolve", async () => {
const result = await Promise.resolve("hello");
expect(result).toBe("hello");
});
it("should reject", async () => {
await expect(Promise.reject(new Error("fail"))).rejects.toThrow("fail");
});
});
// API 调用
describe("fetchUser", () => {
it("should fetch user", async () => {
const user = await fetchUser(1);
expect(user).toEqual({
id: 1,
name: expect.any(String)
});
});
});
Mock 类型
函数 Mock
import { jest, describe, it, expect } from "@jest/globals";
// 创建 mock 函数
const mockFn = jest.fn();
// 类型化的 mock
const mockAdd = jest.fn<(a: number, b: number) => number>();
// mock 实现
const mockMultiply = jest.fn<(a: number, b: number) => number>()
.mockImplementation((a, b) => a * b);
// mock 返回值
const mockGetUser = jest.fn<() => Promise<User>>()
.mockResolvedValue({ id: 1, name: "Alice", email: "a@b.com" });
describe("mock functions", () => {
it("should call mock", () => {
mockFn("arg1", "arg2");
expect(mockFn).toHaveBeenCalledWith("arg1", "arg2");
expect(mockFn).toHaveBeenCalledTimes(1);
});
it("should mock implementation", () => {
expect(mockMultiply(2, 3)).toBe(6);
});
});
模块 Mock
// services/user.ts
export async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// __mocks__/services/user.ts
import { jest } from "@jest/globals";
export const getUser = jest.fn<(id: number) => Promise<User>>()
.mockResolvedValue({ id: 1, name: "Alice", email: "a@b.com" });
// 测试文件中
jest.mock("./services/user");
import { getUser } from "./services/user";
describe("with mocked user service", () => {
it("should use mocked getUser", async () => {
const user = await getUser(1);
expect(user.name).toBe("Alice");
});
});
Mock 的类型安全
// 定义 Mock 类型
type MockFn<T extends (...args: any[]) => any> = jest.MockedFunction<T>;
// 使用
interface UserService {
getUser(id: number): Promise<User>;
updateUser(id: number, data: Partial<User>): Promise<User>;
}
const mockUserService: jest.Mocked<UserService> = {
getUser: jest.fn(),
updateUser: jest.fn()
};
// 或者使用 jest.createMockFromModule
const mockModule = jest.createMockFromModule<typeof import("./services")>("./services");
测试工具类型
提取函数返回类型
// 测试辅助类型
type AsyncReturnType<T extends (...args: any) => Promise<any>> =
T extends (...args: any) => Promise<infer R> ? R : never;
// 使用
async function fetchUser(id: number): Promise<User> {
return { id, name: "Alice", email: "a@b.com" };
}
type FetchUserReturn = AsyncReturnType<typeof fetchUser>; // User
// 在测试中使用
it("should return user", async () => {
const user = await fetchUser(1);
// user 的类型是 User,IDE 会提供完整的自动补全
expect(user.name).toBe("Alice");
});
部分 Mock 类型
// DeepPartial 用于创建部分 mock
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// 创建测试数据工厂
function createMockUser(overrides?: DeepPartial<User>): User {
return {
id: 1,
name: "Alice",
email: "alice@example.com",
...overrides
};
}
// 使用
const user = createMockUser({ name: "Bob" });
const adminUser = createMockUser({ role: "admin" });
高级测试模式
测试类
// UserService.ts
export class UserService {
constructor(private db: Database) {}
async getUser(id: number): Promise<User | null> {
return this.db.users.findById(id);
}
async createUser(data: CreateUserDto): Promise<User> {
return this.db.users.create(data);
}
}
// UserService.test.ts
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
describe("UserService", () => {
let service: UserService;
let mockDb: jest.Mocked<Database>;
beforeEach(() => {
mockDb = {
users: {
findById: jest.fn(),
create: jest.fn()
}
} as any;
service = new UserService(mockDb);
});
it("should get user", async () => {
const mockUser = { id: 1, name: "Alice" };
mockDb.users.findById.mockResolvedValue(mockUser);
const user = await service.getUser(1);
expect(user).toEqual(mockUser);
expect(mockDb.users.findById).toHaveBeenCalledWith(1);
});
it("should return null for non-existent user", async () => {
mockDb.users.findById.mockResolvedValue(null);
const user = await service.getUser(999);
expect(user).toBeNull();
});
});
测试 React 组件
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
// Button.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { jest, describe, it, expect } from "@jest/globals";
import { Button } from "./Button";
describe("Button", () => {
it("should render with text", () => {
render(<Button variant="primary">Click me</Button>);
expect(screen.getByText("Click me")).toBeInTheDocument();
});
it("should call onClick", () => {
const handleClick = jest.fn();
render(
<Button variant="primary" onClick={handleClick}>
Click me
</Button>
);
fireEvent.click(screen.getByText("Click me"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("should be disabled", () => {
render(<Button variant="primary" disabled>Click me</Button>);
expect(screen.getByText("Click me")).toBeDisabled();
});
});
快照测试
// 组件快照
it("should match snapshot", () => {
const { container } = render(
<UserCard user={{ id: 1, name: "Alice", email: "a@b.com" }} />
);
expect(container).toMatchSnapshot();
});
// 内联快照
it("should render error message", () => {
render(<ErrorMessage code={404} />);
expect(screen.getByText("页面未找到")).toMatchInlineSnapshot(`
<span class="error-message">
页面未找到
</span>
`);
});
测试覆盖率
// jest.config.ts
{
"collectCoverageFrom": [
"src/**/*.ts",
"src/**/*.tsx",
"!src/**/*.d.ts",
"!src/**/index.ts",
"!src/**/*.stories.ts"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
业务场景:测试 API 端点
// __tests__/api/users.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "@jest/globals";
import request from "supertest";
import { app } from "../../src/app";
import { prisma } from "../../src/db";
describe("Users API", () => {
beforeAll(async () => {
await prisma.$connect();
});
afterAll(async () => {
await prisma.$disconnect();
});
beforeEach(async () => {
await prisma.user.deleteMany();
});
describe("GET /api/users", () => {
it("should return empty array when no users", async () => {
const response = await request(app)
.get("/api/users")
.expect(200);
expect(response.body).toEqual([]);
});
it("should return all users", async () => {
await prisma.user.create({
data: { name: "Alice", email: "alice@example.com" }
});
const response = await request(app)
.get("/api/users")
.expect(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].name).toBe("Alice");
});
});
describe("POST /api/users", () => {
it("should create a user", async () => {
const response = await request(app)
.post("/api/users")
.send({ name: "Alice", email: "alice@example.com" })
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(Number),
name: "Alice",
email: "alice@example.com"
});
});
it("should return 400 for invalid data", async () => {
await request(app)
.post("/api/users")
.send({ name: "" })
.expect(400);
});
});
});
注意事项
- 类型安全的 Mock——使用
jest.Mocked<T> 确保 Mock 与原函数类型一致 - 异步测试——使用
async/await 测试异步代码 - 清理测试状态——使用
beforeEach 或 afterEach 清理测试数据 - 测试覆盖率——设置合理的覆盖率阈值
- 快照测试——适合测试 UI 组件的输出,但不要过度使用
扩展阅读