12 KiB
12 KiB
# 后端测试策略
面向后端与全栈应用的全面测试指南。涵盖完整的测试金字塔,重点深入 API 集成测试、数据库测试、契约测试与性能测试。
## 快速启动清单
- [ ] **测试运行器已配置**(Jest/Vitest、Pytest、Go test)
- [ ] **测试数据库**就绪(Docker 容器或内存数据库)
- [ ] **每次测试的数据库隔离**(事务回滚或截断表)
- [ ] **测试工厂**用于常见实体(用户、订单、商品)
- [ ] **认证助手**用于生成测试令牌
- [ ] **CI 流水线**使用真实数据库服务运行测试
- [ ] **覆盖率阈值**已设定(≥ 80%)
---
## 测试金字塔
╱╲ E2E(少量、慢速)——跨服务的完整流程
╱ ╲
╱────╲ 集成测试(适中)——API + 数据库 + 外部服务
╱ ╲
╱────────╲ 单元测试(大量、快速)——纯业务逻辑
╱__________╲
| 层级 | 测试内容 | 速度 | 数量 |
|-------|------|-------|-------|
| 单元测试 | 纯函数、业务逻辑、无 I/O | < 10ms | 占总测试的 70% 以上 |
| 集成测试 | API 路由 + 真实数据库 + 模拟外部服务 | 50-500ms | 约 20% |
| E2E 测试 | 跨已部署服务的完整用户流程 | 1-30s | 约 10% |
| 契约测试 | 服务间的 API 兼容性 | < 100ms | 每个 API 边界 |
| 性能测试 | 负载、压力、浸泡测试 | 数分钟 | 每个关键路径 |
---
## 1. API 集成测试(关键)
### 每个端点需要测试的内容
| 方面 | 需编写的测试 |
|--------|---------------|
| 正常路径 | 正确的输入 → 预期的响应 + 正确的数据库状态 |
| 认证 | 无令牌 → 401,错误令牌 → 401,过期令牌 → 401 |
| 授权 | 错误角色 → 403,非所有者 → 403 |
| 校验 | 缺少字段 → 422,错误类型 → 422,边界值 |
| 未找到 | 无效 ID → 404,已删除资源 → 404 |
| 冲突 | 重复创建 → 409,过期更新 → 409 |
| 幂等性 | 相同请求两次 → 相同结果 |
| 副作用 | 数据库状态变更、事件被触发、缓存失效 |
| 错误格式 | 所有错误均符合 RFC 9457 信封格式 |
### TypeScript(Jest + Supertest)
```typescript
describe('POST /api/orders', () => {
let token: string;
let product: Product;
beforeAll(async () => {
await resetDatabase();
const user = await createTestUser({ role: 'customer' });
token = await getAuthToken(user);
product = await createTestProduct({ price: 29.99, stock: 10 });
});
it('创建订单 → 201 + 正确的数据库状态', async () => {
const res = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${token}`)
.send({ items: [{ productId: product.id, quantity: 2 }] });
expect(res.status).toBe(201);
expect(res.body.data.total).toBe(59.98);
const updated = await db.product.findUnique({ where: { id: product.id } });
expect(updated!.stock).toBe(8);
});
it('无认证时拒绝 → 401', async () => {
const res = await request(app).post('/api/orders').send({ items: [] });
expect(res.status).toBe(401);
});
it('空商品列表时拒绝 → 422', async () => {
const res = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${token}`)
.send({ items: [] });
expect(res.status).toBe(422);
expect(res.body.errors[0].field).toBe('items');
});
});
Python(Pytest + FastAPI TestClient)
@pytest.fixture
def client(db_session):
def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
yield TestClient(app)
app.dependency_overrides.clear()
def test_create_order_success(client, auth_headers, test_product):
response = client.post("/api/orders", json={
"items": [{"product_id": test_product.id, "quantity": 2}]
}, headers=auth_headers)
assert response.status_code == 201
assert response.json()["data"]["total"] == 59.98
def test_create_order_no_auth(client):
response = client.post("/api/orders", json={"items": []})
assert response.status_code == 401
def test_create_order_empty_items(client, auth_headers):
response = client.post("/api/orders", json={"items": []}, headers=auth_headers)
assert response.status_code == 422
2. 数据库测试(高优先级)
测试隔离策略
| 策略 | 速度 | 真实度 | 使用时机 |
|---|---|---|---|
| 事务回滚 | ⚡ 最快 | 中等 | 单元测试 + 集成测试的默认方式 |
| 截断表 | 快 | 高 | 事务回滚不可用时 |
| 测试容器 | 启动慢 | 最高 | CI 流水线、完整集成测试 |
事务回滚(推荐默认方式):
let tx: Transaction;
beforeEach(async () => { tx = await db.beginTransaction(); });
afterEach(async () => { await tx.rollback(); });
Docker 测试容器(CI):
# docker-compose.test.yml
services:
test-db:
image: postgres:16-alpine
tmpfs: /var/lib/postgresql/data # RAM 磁盘以提升速度
environment:
POSTGRES_DB: myapp_test
测试工厂(而非原始 SQL)
// factories/user.factory.ts
import { faker } from '@faker-js/faker';
export function buildUser(overrides: Partial<User> = {}): CreateUserDTO {
return {
email: faker.internet.email(),
firstName: faker.person.firstName(),
role: 'customer',
...overrides,
};
}
export async function createUser(overrides = {}) {
return db.user.create({ data: buildUser(overrides) });
}
# factories/user_factory.py
import factory
from faker import Faker
class UserFactory(factory.Factory):
class Meta:
model = User
email = factory.LazyAttribute(lambda _: Faker().email())
first_name = factory.LazyAttribute(lambda _: Faker().first_name())
role = "customer"
3. 外部服务测试(高优先级)
HTTP 级别模拟(而非函数级别模拟)
TypeScript(nock):
import nock from 'nock';
it('成功处理支付', async () => {
nock('https://api.stripe.com')
.post('/v1/charges')
.reply(200, { id: 'ch_123', status: 'succeeded', amount: 5000 });
const result = await paymentService.charge({ amount: 50.00, currency: 'usd' });
expect(result.status).toBe('succeeded');
});
it('处理支付超时', async () => {
nock('https://api.stripe.com').post('/v1/charges').delay(10000).reply(200);
await expect(paymentService.charge({ amount: 50, currency: 'usd' }))
.rejects.toThrow('timeout');
});
Python(responses):
import responses
@responses.activate
def test_payment_success():
responses.post("https://api.stripe.com/v1/charges",
json={"id": "ch_123", "status": "succeeded"}, status=200)
result = payment_service.charge(amount=50.00, currency="usd")
assert result.status == "succeeded"
用于基础设施的测试容器
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { RedisContainer } from '@testcontainers/redis';
beforeAll(async () => {
const pg = await new PostgreSqlContainer('postgres:16').start();
process.env.DATABASE_URL = pg.getConnectionUri();
await runMigrations();
}, 60000);
4. 契约测试(中高优先级)
消费者驱动的契约(Pact)
消费者(OrderService 调用 UserService):
it('可以根据 ID 获取用户', async () => {
await pact.addInteraction()
.given('用户 usr_123 存在')
.uponReceiving('GET /users/usr_123')
.withRequest('GET', '/api/users/usr_123')
.willRespondWith(200, (b) => {
b.jsonBody({ data: { id: MatchersV3.string(), email: MatchersV3.email() } });
})
.executeTest(async (mockserver) => {
const user = await new UserClient(mockserver.url).getUser('usr_123');
expect(user.id).toBeDefined();
});
});
提供者在 CI 中验证:
await new Verifier({
providerBaseUrl: 'http://localhost:3001',
pactBrokerUrl: process.env.PACT_BROKER_URL,
provider: 'UserService',
}).verifyProvider();
5. 性能测试(中优先级)
k6 负载测试
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 20 }, // 逐步增加
{ duration: '1m', target: 100 }, // 持续维持
{ duration: '30s', target: 0 }, // 逐步减少
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const res = http.get(`${__ENV.BASE_URL}/api/orders`);
check(res, { 'status 200': (r) => r.status === 200 });
sleep(1);
}
性能预算
| 指标 | 目标值 | 超出后措施 |
|---|---|---|
| p95 响应时间 | < 500ms | 优化查询/缓存 |
| p99 响应时间 | < 1000ms | 检查异常查询 |
| 错误率 | < 0.1% | 排查异常尖峰 |
| 数据库查询时间 | 每次 < 100ms | 添加索引 |
何时运行
| 触发时机 | 测试类型 |
|---|---|
| 重大版本发布前 | 完整负载测试 |
| 新增数据库查询/索引 | 查询基准测试 |
| 基础设施变更 | 基线对比 |
| 每周(CI) | 冒烟负载测试 |
测试文件组织
tests/
unit/ # 纯逻辑,模拟依赖
order.service.test.ts
integration/ # API + 真实数据库
orders.api.test.ts
auth.api.test.ts
contracts/ # 消费者驱动的契约
user-service.consumer.pact.ts
performance/ # 负载测试
load-test.js
fixtures/
factories/ # 测试数据工厂
user.factory.ts
seeds/
test-data.ts
helpers/
setup.ts # 全局测试配置
auth.helper.ts # 令牌生成
db.helper.ts # 数据库清理
反模式
| # | ❌ 不要这样做 | ✅ 应该这样做 |
|---|---|---|
| 1 | 仅测试正常路径 | 测试错误、认证、校验、边界情况 |
| 2 | 什么都模拟(无真实数据库) | 使用测试容器或测试数据库 |
| 3 | 测试依赖执行顺序 | 每个测试自行设置/清理自身状态 |
| 4 | 硬编码测试数据 | 使用工厂(faker + 覆盖参数) |
| 5 | 测试实现细节 | 测试行为:输入 → 输出 |
| 6 | 共享可变状态 | 每次测试隔离(事务回滚) |
| 7 | 在 CI 中跳过迁移测试 | 在 CI 中从头运行迁移 |
| 8 | 发布前不做性能测试 | 每个重大版本都做负载测试 |
| 9 | 对生产数据做测试 | 仅使用生成的测试数据 |
| 10 | 测试套件超过 10 分钟 | 并行化、使用 RAM 磁盘、优化设置 |
常见问题
问题 1:「测试单独通过,一起运行时失败」
原因: 测试之间共享了数据库状态。缺少清理操作。
修复方法:
beforeEach(async () => { await db.raw('TRUNCATE orders, users CASCADE'); });
// 或者每次测试使用事务回滚
问题 2:「测试运行结束后 Jest 没有在一秒内退出」
原因: 数据库连接或 HTTP 服务器未关闭。
修复方法:
afterAll(async () => {
await db.destroy();
await server.close();
});
问题 3:「异步回调未在超时时间内被调用」
原因: 缺少 async/await 或未处理的 promise。
修复方法:
// ❌ Promise 未被等待
it('should work', () => { request(app).get('/users'); });
// ✅ 正确等待
it('should work', async () => { await request(app).get('/users'); });
问题 4:「集成测试在 CI 中太慢」
修复方法:
- 对 PostgreSQL 数据目录使用
tmpfs(RAM 磁盘) - 在
beforeAll中运行一次迁移,在beforeEach中截断表 - 使用
--maxWorkers并行化测试套件 - 特性分支跳过性能测试(仅在主分支运行)