qa/api-testing · v1.0.0 · 2026-10-27 · sha256 b025e957fdb90e6f

qa/api-testing v1.0.0A

Immutable. This exact content never changes and is served at /api/v1/blob/b025e957fdb90e6f.

---
name: qa/api-testing
description: API 接口测试方法论,覆盖 RESTful/GraphQL 接口的功能、边界、安全、契约测试,参考 Supertest、REST Assured 等开源实践
version: 1.0.0
agent: qa
type: methodology
user-invocable: false
agent-invocable: true
dependencies:
  - shared/tech-stack-detection
triggers:
  - 需要编写或执行 API 接口测试时
  - 需要验证 API 契约一致性时
  - 需要覆盖接口边界和安全场景时
metadata:
  internal: true
---

# API 接口测试方法论

> 参考开源实践:Supertest、REST Assured、Postman/Newman、HTTPie、Testcontainers

## 适用场景

- 后端 API 端点需要功能验证
- 需要测试接口边界值、错误处理、权限控制
- 需要验证 API 契约(请求/响应 schema 一致性)
- 需要覆盖认证、授权、越权等安全场景
- 需要测试完整业务流程的 API 链路

## 测试类型矩阵

| 测试类型 | 目的 | 工具 | 示例 |
|----------|------|------|------|
| **功能测试** | 验证接口正常行为 | Supertest / REST Assured | 创建资源返回 201 |
| **边界测试** | 验证边界值和错误处理 | Supertest | 空 payload、超长字符串、非法 ID |
| **安全测试** | 认证、授权、越权 | Supertest + auth header | 无 Token 访问受保护资源 |
| **契约测试** | 请求/响应 schema 一致性 | Zod / JSON Schema | 响应字段类型校验 |
| **流程测试** | 多接口业务链路 | Supertest 链式调用 | 注册→登录→下单→支付 |

## 1. RESTful API 测试

### 1.1 Node.js (Supertest + Jest/Vitest)

```typescript
// tests/api/users.test.ts
import request from 'supertest';
import { app } from '../../src/app';

describe('User API', () => {
  describe('POST /api/users', () => {
    it('creates user with valid data', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({ name: 'Alice', email: 'alice@example.com' })
        .expect(201)
        .expect('Content-Type', /json/);

      expect(res.body.success).toBe(true);
      expect(res.body.data).toMatchObject({ name: 'Alice', email: 'alice@example.com' });
      expect(res.body.data.id).toBeDefined();
    });

    it('rejects empty name with 400', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({ name: '', email: 'test@example.com' })
        .expect(400);

      expect(res.body.error.code).toBe('VALIDATION_ERROR');
    });

    it('rejects duplicate email with 409', async () => {
      await request(app).post('/api/users').send({ name: 'A', email: 'dup@example.com' });
      const res = await request(app)
        .post('/api/users')
        .send({ name: 'B', email: 'dup@example.com' })
        .expect(409);

      expect(res.body.error.code).toBe('CONFLICT');
    });
  });
});
```

### 1.2 Python (pytest + requests)

```python
# tests/api/test_users.py
import pytest
import requests

BASE_URL = "http://localhost:3000"

def test_create_user():
    res = requests.post(f"{BASE_URL}/api/users", json={"name": "Alice", "email": "alice@example.com"})
    assert res.status_code == 201
    assert res.json()["data"]["name"] == "Alice"

def test_create_user_empty_name():
    res = requests.post(f"{BASE_URL}/api/users", json={"name": "", "email": "test@example.com"})
    assert res.status_code == 400
```

### 1.3 Go (net/http/httptest)

```go
// handlers/users_test.go
func TestCreateUser(t *testing.T) {
    app := setupTestApp()
    body := strings.NewReader(`{"name":"Alice","email":"alice@example.com"}`)
    req := httptest.NewRequest("POST", "/api/users", body)
    req.Header.Set("Content-Type", "application/json")
    w := httptest.NewRecorder()

    app.ServeHTTP(w, req)

    if w.Code != http.StatusCreated {
        t.Errorf("expected 201, got %d", w.Code)
    }
}
```

## 2. GraphQL API 测试

```typescript
// tests/api/graphql.test.ts
import request from 'supertest';
import { app } from '../../src/app';

describe('GraphQL API', () => {
  it('queries user by id', async () => {
    const query = `
      query GetUser($id: ID!) {
        user(id: $id) { id name email }
      }
    `;
    const res = await request(app)
      .post('/graphql')
      .send({ query, variables: { id: '1' } })
      .expect(200);

    expect(res.body.data.user).toBeDefined();
    expect(res.body.errors).toBeUndefined();
  });

  it('mutates: create user', async () => {
    const mutation = `
      mutation CreateUser($input: CreateUserInput!) {
        createUser(input: $input) { id name }
      }
    `;
    const res = await request(app)
      .post('/graphql')
      .send({ mutation, variables: { input: { name: 'Bob', email: 'bob@example.com' } } })
      .expect(200);

    expect(res.body.data.createUser.id).toBeDefined();
  });
});
```

## 3. 认证与授权测试

### 3.1 Token 认证

```typescript
describe('Protected endpoints', () => {
  let token: string;

  beforeAll(async () => {
    const res = await request(app)
      .post('/api/auth/login')
      .send({ email: 'admin@example.com', password: 'password' });
    token = res.body.data.token;
  });

  it('returns 401 without token', async () => {
    await request(app).get('/api/users/profile').expect(401);
  });

  it('returns 401 with invalid token', async () => {
    await request(app)
      .get('/api/users/profile')
      .set('Authorization', 'Bearer invalid-token')
      .expect(401);
  });

  it('returns 200 with valid token', async () => {
    await request(app)
      .get('/api/users/profile')
      .set('Authorization', `Bearer ${token}`)
      .expect(200);
  });
});
```

### 3.2 权限/越权测试

```typescript
describe('Authorization', () => {
  let adminToken: string;
  let userToken: string;

  beforeAll(async () => {
    adminToken = await login('admin@example.com', 'password');
    userToken = await login('user@example.com', 'password');
  });

  it('admin can delete any user', async () => {
    await request(app)
      .delete('/api/users/123')
      .set('Authorization', `Bearer ${adminToken}`)
      .expect(200);
  });

  it('regular user cannot delete others (403)', async () => {
    await request(app)
      .delete('/api/users/123')
      .set('Authorization', `Bearer ${userToken}`)
      .expect(403);
  });
});
```

## 4. 边界值与错误用例

### 4.1 输入边界

```typescript
describe('Input boundaries', () => {
  it('rejects email exceeding max length', async () => {
    const longEmail = 'a'.repeat(256) + '@example.com';
    await request(app)
      .post('/api/users')
      .send({ name: 'Test', email: longEmail })
      .expect(400);
  });

  it('rejects invalid UUID format', async () => {
    await request(app).get('/api/users/not-a-uuid').expect(400);
  });

  it('rejects negative quantity', async () => {
    await request(app)
      .post('/api/orders')
      .send({ items: [{ productId: '1', quantity: -1 }] })
      .expect(400);
  });

  it('rejects SQL injection attempt', async () => {
    await request(app)
      .get("/api/users?id=1' OR '1'='1")
      .expect(400);
  });
});
```

### 4.2 业务状态机管控

当接口涉及状态流转时,必须测试状态管控:

```typescript
describe('Order state machine', () => {
  it('cannot cancel a paid order', async () => {
    // 创建并支付订单
    const order = await createAndPayOrder();

    // 尝试取消已支付订单
    const res = await request(app)
      .post(`/api/orders/${order.id}/cancel`)
      .set('Authorization', `Bearer ${token}`)
      .expect(409);

    expect(res.body.error.code).toBe('INVALID_STATE_TRANSITION');
  });

  it('cannot modify a confirmed order', async () => {
    const order = await createConfirmedOrder();

    await request(app)
      .put(`/api/orders/${order.id}`)
      .set('Authorization', `Bearer ${token}`)
      .send({ items: [] })
      .expect(409);
  });
});
```

## 5. 契约测试(Schema 校验)

使用 Zod 或 JSON Schema 校验响应结构:

```typescript
import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

it('response matches user schema', async () => {
  const res = await request(app).get('/api/users/1').expect(200);
  const result = UserSchema.safeParse(res.body.data);
  expect(result.success).toBe(true);
});
```

## 6. 数据依赖管理

### 6.1 预取数据(Prefetch)

当用例依赖已有数据时,先预取真实 ID 注入变量池:

```typescript
describe('Dependent endpoints', () => {
  let existingUserId: string;

  beforeAll(async () => {
    // 预取一个真实存在的用户 ID
    const res = await request(app).get('/api/users?limit=1');
    existingUserId = res.body.data.items[0]?.id;
  });

  it('updates existing user', async () => {
    await request(app)
      .put(`/api/users/${existingUserId}`)
      .send({ name: 'Updated' })
      .expect(200);
  });
});
```

### 6.2 破坏性操作安全保护

对 DELETE/逻辑删除类用例,使用假 ID 避免污染真实数据:

```typescript
it('returns 404 when deleting non-existent user', async () => {
  await request(app)
    .delete('/api/users/00000000-0000-0000-0000-000000000000')
    .expect(404);
});
```

### 6.3 测试数据隔离

```typescript
beforeEach(async () => {
  // 每个测试前清理相关表
  await db.user.deleteMany({ where: { email: { startsWith: 'test-' } } });
});
```

## 7. 性能与并发测试

```typescript
it('handles 10 concurrent user creations', async () => {
  const promises = Array.from({ length: 10 }, (_, i) =>
    request(app)
      .post('/api/users')
      .send({ name: `User${i}`, email: `user${i}@test.com` })
  );

  const responses = await Promise.all(promises);
  responses.forEach(res => expect(res.status).toBe(201));
});
```

## 8. 断言策略

### 分层判定原则

| 场景 | 主判据 | 辅判据 |
|------|--------|--------|
| 正常请求 | HTTP 状态码 2xx | 响应体字段 |
| 参数错误 | HTTP 400 + 业务码 | 错误消息 |
| 业务错误 | **业务码**(非 HTTP 状态码) | 错误消息 |
| 认证失败 | HTTP 401 | WWW-Authenticate header |
| 授权失败 | HTTP 403 | - |

> ⚠️ 当后端使用「200 + 业务码」表达错误时,断言应以**业务码语义**为主,不应因 HTTP 200 就判定通过。

## 9. 测试报告要求

每个 API 测试文件必须覆盖:

- [ ] 正常流程(happy path)
- [ ] 参数校验(必填、格式、长度、类型)
- [ ] 认证(无 Token、无效 Token、过期 Token)
- [ ] 授权(角色权限、越权访问)
- [ ] 业务状态机(状态流转限制)
- [ ] 边界值(空、超长、非法、特殊字符)
- [ ] 安全(SQL 注入、XSS、路径遍历)
- [ ] 并发与幂等

## 10. 检查清单

- [ ] 所有 CRUD 端点都有功能测试
- [ ] 每个端点至少有 1 个错误/边界用例
- [ ] 认证端点覆盖成功和失败场景
- [ ] 受保护资源覆盖 401/403 场景
- [ ] 响应体通过 schema 校验
- [ ] 测试数据相互隔离,可重复运行
- [ ] 破坏性操作使用假 ID 或测试数据
- [ ] 不依赖外部服务(mock 第三方 API)