测试类型概览
- 单元测试 测试单个函数或模块的行为,隔离所有外部依赖(数据库、网络)。速度最快,数量最多。测试金字塔的底层。
- 集成测试 测试多个组件协同工作,包括真实数据库连接、HTTP 请求。速度较慢,但更接近真实使用场景。
- E2E 测试 端到端测试,模拟真实用户操作(如 Playwright),覆盖整个系统。速度最慢,最接近真实场景。
- Mock / Spy Mock 替换函数实现(可控制返回值);Spy 包装函数(保留原实现,但记录调用次数/参数)。用于隔离外部依赖。
- 覆盖率(Coverage) 代码覆盖率,衡量测试覆盖了多少代码路径。包括行覆盖率、分支覆盖率、函数覆盖率。100% 不代表没有 bug,但低覆盖率通常意味着风险。
Bun Test — 内置测试框架
性能对比
| 测试运行器 | 运行时 | 速度(1000个测试) | 特点 |
|---|---|---|---|
| Bun Test | Bun | ~0.5s | 内置,无需安装,Jest 兼容 API |
| Vitest | Node/Bun | ~2s | Vite 生态,HMR 支持,推荐 Node.js 项目 |
| Jest | Node | ~8s | 最成熟,生态最大,速度较慢 |
| node:test | Node 18+ | ~3s | 内置,无需安装,API 较基础 |
基础语法(Jest 兼容)
// tests/math.test.ts
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
// 注:也可以从 'vitest' 导入,API 完全相同
function add(a: number, b: number) { return a + b; }
function divide(a: number, b: number) {
if (b === 0) throw new Error('除数不能为零');
return a / b;
}
describe('数学运算', () => {
test('1 + 1 等于 2', () => {
expect(add(1, 1)).toBe(2);
});
test('负数相加', () => {
expect(add(-5, 3)).toBe(-2);
});
test('除以零抛出错误', () => {
expect(() => divide(10, 0)).toThrow('除数不能为零');
});
test('正常除法', () => {
expect(divide(10, 2)).toBe(5);
});
});
// 异步测试
test('异步操作', async () => {
const result = await fetch('https://api.example.com/data');
expect(result.status).toBe(200);
});
// 常用 Matchers
expect('hello').toContain('ell');
expect([1, 2, 3]).toHaveLength(3);
expect({ a: 1, b: 2 }).toEqual({ a: 1, b: 2 });
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(true).toBeTruthy();
expect(3.14159).toBeCloseTo(3.14, 2);
Mock 与 Spy
import { mock, spyOn, jest } from 'bun:test';
// ─── mock() — 创建模拟函数 ───
const mockFetch = mock(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({ users: [] }),
}));
// 替换全局 fetch
globalThis.fetch = mockFetch as any;
test('使用 mock fetch', async () => {
const res = await fetch('/api/users');
const data = await res.json();
expect(data.users).toEqual([]);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith('/api/users');
});
// ─── spyOn() — 监视真实函数 ───
import * as mailer from '../lib/mailer';
test('注册时发送欢迎邮件', async () => {
const sendEmailSpy = spyOn(mailer, 'sendEmail')
.mockResolvedValue({ messageId: 'test-id' }); // 不实际发邮件
await registerUser({ email: 'test@example.com', password: 'Test1234!', name: '测试' });
expect(sendEmailSpy).toHaveBeenCalledOnce();
expect(sendEmailSpy).toHaveBeenCalledWith(
expect.objectContaining({ to: 'test@example.com' })
);
});
集成测试:测试 HTTP 端点
// tests/api/users.test.ts
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import app from '../../src/index';
import { prisma } from '../../src/lib/prisma';
// 测试服务器(不需要真正监听端口)
const baseUrl = 'http://localhost';
async function request(path: string, init?: RequestInit) {
return app.request(baseUrl + path, init); // Hono 的 .request() 方法
}
describe('用户 API', () => {
let authToken: string;
beforeAll(async () => {
// 登录获取 token
const res = await request('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@test.com', password: 'Admin1234!' }),
});
const data = await res.json();
authToken = data.accessToken;
});
beforeEach(async () => {
// 每个测试前清空用户表(测试隔离)
await prisma.user.deleteMany({ where: { email: { contains: '@test-' } } });
});
afterAll(async () => {
await prisma.$disconnect();
});
test('GET /users — 未认证返回 401', async () => {
const res = await request('/users');
expect(res.status).toBe(401);
});
test('GET /users — 认证后返回列表', async () => {
const res = await request('/users', {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toBeArray();
});
test('POST /users — 创建用户', async () => {
const res = await request('/users', {
method: 'POST',
headers: {
Authorization: `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: '测试用户', email: 'user@test-create.com' }),
});
expect(res.status).toBe(201);
const user = await res.json();
expect(user).toMatchObject({ name: '测试用户', email: 'user@test-create.com' });
expect(user.id).toBeDefined();
expect(user.password).toBeUndefined(); // 不能返回密码!
});
});
测试数据库隔离
集成测试需要真实数据库,但要保证测试间互不干扰。推荐方案:
方案1:独立测试数据库
# .env.test
DATABASE_URL=postgresql://localhost:5432/mydb_test
# package.json
"test": "NODE_ENV=test bun test"
方案2:事务回滚(最快)
// 每个测试用事务包裹
// 测试结束后回滚,数据消失
// 速度快,真正隔离
const tx = await prisma.$begin();
// ... 测试代码 ...
await tx.$rollback();
运行测试与覆盖率
# 运行所有测试
bun test
# 运行特定文件
bun test tests/api/users.test.ts
# 监视模式(文件变化自动重跑)
bun test --watch
# 生成覆盖率报告
bun test --coverage
# 覆盖率输出示例:
# ------------------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# ------------------|---------|----------|---------|---------|
# src/lib/jwt.ts | 95.23 | 87.50 | 100.00 | 95.23 |
# src/routes/auth.ts| 88.46 | 75.00 | 85.71 | 88.46 |
# 过滤测试(只运行匹配的测试名)
bun test --test-name-pattern "用户 API"
# 并行运行(默认)
bun test --jobs 4
Vitest — Node.js 项目的推荐选择
npm install -D vitest @vitest/coverage-v8 supertest
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // 全局 describe/test/expect(无需导入)
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
thresholds: {
branches: 70,
functions: 80,
lines: 80, // 覆盖率低于此值则 CI 失败
statements: 80,
},
},
setupFiles: ['./tests/setup.ts'],
},
});
TDD 提示:测试驱动开发(Test-Driven Development)——先写测试,再写实现,让测试通过。这不仅保证代码质量,更重要的是逼着你在写代码前先思考接口设计。很多 bug 在 TDD 中根本不会出现。