Chapter 09

测试:Bun Test 与 Vitest

单元测试、集成测试、Mock、覆盖率——让代码有底气上线

测试类型概览

Bun Test — 内置测试框架

性能对比

测试运行器运行时速度(1000个测试)特点
Bun TestBun~0.5s内置,无需安装,Jest 兼容 API
VitestNode/Bun~2sVite 生态,HMR 支持,推荐 Node.js 项目
JestNode~8s最成熟,生态最大,速度较慢
node:testNode 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 中根本不会出现。