Files
2026-07-13 21:35:57 +08:00

407 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
```markdown
# 后端测试策略
面向后端与全栈应用的全面测试指南。涵盖完整的测试金字塔,重点深入 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 信封格式 |
### TypeScriptJest + 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');
});
});
```
### PythonPytest + FastAPI TestClient
```python
@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 流水线、完整集成测试 |
**事务回滚(推荐默认方式):**
```typescript
let tx: Transaction;
beforeEach(async () => { tx = await db.beginTransaction(); });
afterEach(async () => { await tx.rollback(); });
```
**Docker 测试容器(CI):**
```yaml
# docker-compose.test.yml
services:
test-db:
image: postgres:16-alpine
tmpfs: /var/lib/postgresql/data # RAM 磁盘以提升速度
environment:
POSTGRES_DB: myapp_test
```
### 测试工厂(而非原始 SQL
```typescript
// 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) });
}
```
```python
# 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 级别模拟(而非函数级别模拟)
**TypeScriptnock):**
```typescript
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');
});
```
**Pythonresponses):**
```python
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"
```
### 用于基础设施的测试容器
```typescript
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):**
```typescript
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 中验证:**
```typescript
await new Verifier({
providerBaseUrl: 'http://localhost:3001',
pactBrokerUrl: process.env.PACT_BROKER_URL,
provider: 'UserService',
}).verifyProvider();
```
---
## 5. 性能测试(中优先级)
### k6 负载测试
```javascript
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:「测试单独通过,一起运行时失败」
**原因:** 测试之间共享了数据库状态。缺少清理操作。
**修复方法:**
```typescript
beforeEach(async () => { await db.raw('TRUNCATE orders, users CASCADE'); });
// 或者每次测试使用事务回滚
```
### 问题 2:「测试运行结束后 Jest 没有在一秒内退出」
**原因:** 数据库连接或 HTTP 服务器未关闭。
**修复方法:**
```typescript
afterAll(async () => {
await db.destroy();
await server.close();
});
```
### 问题 3:「异步回调未在超时时间内被调用」
**原因:** 缺少 `async/await` 或未处理的 promise。
**修复方法:**
```typescript
// ❌ Promise 未被等待
it('should work', () => { request(app).get('/users'); });
// ✅ 正确等待
it('should work', async () => { await request(app).get('/users'); });
```
### 问题 4:「集成测试在 CI 中太慢」
**修复方法:**
1. 对 PostgreSQL 数据目录使用 `tmpfs`RAM 磁盘)
2.`beforeAll` 中运行一次迁移,在 `beforeEach` 中截断表
3. 使用 `--maxWorkers` 并行化测试套件
4. 特性分支跳过性能测试(仅在主分支运行)
```