407 lines
12 KiB
Markdown
407 lines
12 KiB
Markdown
```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 信封格式 |
|
||
|
||
### 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)
|
||
|
||
```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 级别模拟(而非函数级别模拟)
|
||
|
||
**TypeScript(nock):**
|
||
```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');
|
||
});
|
||
```
|
||
|
||
**Python(responses):**
|
||
```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. 特性分支跳过性能测试(仅在主分支运行)
|
||
```
|