API 是前后端、服务与服务之间的契约边界。API 测试不依赖 UI、执行快速、稳定性高——它是自动化测试中 ROI 最高的层级之一。本文构建 REST / GraphQL / gRPC 的通用测试能力。
一、API 测试的独特价值
1.1 为什么 API 测试是自动化的最优切入点
| 维度 | UI/E2E 测试 | API 测试 | 单元测试 |
|---|---|---|---|
| 执行速度 | 慢(秒~分钟) | 快(毫秒~秒) | 极快(毫秒) |
| 稳定性 | 中(UI 变更敏感) | 高(契约稳定) | 最高 |
| 反馈粒度 | 用户场景级 | 接口级 | 函数级 |
| 环境依赖 | 需要前端+后端 | 只需后端服务 | 无依赖 |
| 可并行度 | 低 | 高 | 极高 |
| 协议覆盖 | HTTP | HTTP/gRPC/WS | 无 |
| 推荐占比 | 10% | 30-40% | 50-60% |
1.2 API 测试的层次
┌────────────────────────────────────────────────────────────┐
│ API 测试分层模型 │
├────────────────────────────────────────────────────────────┤
│ │
│ 契约验证层 ──► Schema / Spec 一致性 │
│ (Contract) OpenAPI, Protobuf, GraphQL SDL │
│ │
│ 功能验证层 ──► 状态码、响应体、业务逻辑 │
│ (Functional) CRUD, 边界值, 错误场景 │
│ │
│ 非功能验证层 ──► 性能、安全、可靠性 │
│ (Non-functional) 延迟基线, 认证, 速率限制 │
│ │
└────────────────────────────────────────────────────────────┘
二、REST API 测试策略
2.1 测试维度矩阵
| 维度 | 验证内容 | 示例 |
|---|---|---|
| 状态码 | HTTP 语义正确性 | 201 Created vs 200 OK 的选择 |
| 响应体 | 字段存在、类型、值 | Pydantic / JSON Schema 校验 |
| Headers | Content-Type、Location、ETag | Location: /orders/123 |
| 认证 | JWT / OAuth2 / API Key | Token 过期、权限不足 403 |
| 边界值 | 极值、空值、超长值 | page=-1, limit=999999 |
| 幂等性 | 重复请求结果一致 | PUT /orders/123 多次执行 |
| 错误处理 | 结构化错误响应 | { "error": "VALIDATION", "field": "email" } |
| 分页 | 游标/偏移量正确性 | hasMore, totalCount |
2.2 Python:pytest + requests + Pydantic
import pytest
import requests
from pydantic import BaseModel, Field
from typing import List
# --- 1. 定义响应 Schema(比 dict 更安全) ---
class OrderItem(BaseModel):
product_id: str
quantity: int = Field(gt=0)
price: float = Field(gt=0)
class OrderResponse(BaseModel):
id: str
customer_id: str
status: str # pending, confirmed, shipped
total_amount: float = Field(ge=0)
items: List[OrderItem]
created_at: str
class ErrorResponse(BaseModel):
error: str
message: str
details: dict | None = None
BASE_URL = "http://localhost:8000"
@pytest.fixture
def auth_headers():
"""获取认证Token。"""
resp = requests.post(f"{BASE_URL}/api/auth/login", json={
"email": "test@example.com",
"password": "testpass123"
})
token = resp.json()["access_token"]
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
class TestOrderAPI:
def test_create_order_success(self, auth_headers):
response = requests.post(
f"{BASE_URL}/api/orders",
headers=auth_headers,
json={
"customer_id": "cust-001",
"items": [
{"product_id": "p1", "quantity": 2, "price": 29.99}
]
}
)
assert response.status_code == 201
# Pydantic 自动校验整个响应结构
order = OrderResponse(**response.json())
assert order.status == "pending"
assert order.total_amount == 59.98
assert len(order.items) == 1
# 验证 Header
assert "/api/orders/" in response.headers.get("Location", "")
@pytest.mark.parametrize("payload,expected_error", [
# 边界值测试
({"customer_id": "", "items": []}, "VALIDATION_ERROR"),
({"customer_id": "x", "items": [{"product_id": "p1", "quantity": 0}]}, "VALIDATION_ERROR"),
({"customer_id": "x" * 1000, "items": [{"product_id": "p1", "quantity": 1, "price": -1}]}, "VALIDATION_ERROR"),
# 缺少必填字段
({"items": []}, "VALIDATION_ERROR"),
])
def test_create_order_validation_errors(self, auth_headers, payload, expected_error):
response = requests.post(
f"{BASE_URL}/api/orders",
headers=auth_headers,
json=payload
)
assert response.status_code == 422
error = ErrorResponse(**response.json())
assert error.error == expected_error
def test_get_order_not_found(self, auth_headers):
response = requests.get(
f"{BASE_URL}/api/orders/nonexistent-id",
headers=auth_headers
)
assert response.status_code == 404
def test_unauthorized_access(self):
"""未认证请求应返回 401。"""
response = requests.get(f"{BASE_URL}/api/orders")
assert response.status_code == 401
assert "Unauthorized" in response.json()["message"]
def test_pagination(self, auth_headers):
"""分页参数边界测试。"""
# 正常分页
resp = requests.get(f"{BASE_URL}/api/orders?page=1&limit=10", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "total" in data
assert "page" in data
# 越界页码
resp = requests.get(f"{BASE_URL}/api/orders?page=99999&limit=10", headers=auth_headers)
assert resp.status_code == 200
assert len(resp.json()["items"]) == 0
# limit 过大
resp = requests.get(f"{BASE_URL}/api/orders?page=1&limit=99999", headers=auth_headers)
assert resp.status_code == 400 # 或强制限制到最大值
2.3 Java:RestAssured + JSON Schema
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import io.restassured.module.jsv.JsonSchemaValidator;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
class OrderApiTest {
@BeforeAll
static void setUp() {
RestAssured.baseURI = "http://localhost:8080";
}
String getAuthToken() {
return given()
.contentType(ContentType.JSON)
.body("{\"email\":\"test@example.com\",\"password\":\"testpass123\"}")
.when()
.post("/api/auth/login")
.then()
.statusCode(200)
.extract().path("access_token");
}
@Test
void shouldCreateOrder() {
String token = getAuthToken();
given()
.contentType(ContentType.JSON)
.header("Authorization", "Bearer " + token)
.body("""
{
"customerId": "cust-001",
"items": [
{"productId": "p1", "quantity": 2, "price": 29.99}
]
}
""")
.when()
.post("/api/orders")
.then()
.statusCode(201)
.body("status", equalTo("pending"))
.body("totalAmount", closeTo(59.98f, 0.01f))
.body("items", hasSize(1))
.header("Location", containsString("/api/orders/"))
// JSON Schema 校验
.body(JsonSchemaValidator
.matchesJsonSchemaInClasspath("schemas/order-response.json"));
}
@Test
void shouldReturnValidationError() {
given()
.contentType(ContentType.JSON)
.header("Authorization", "Bearer " + getAuthToken())
.body("{\"customerId\": \"\", \"items\": []}")
.when()
.post("/api/orders")
.then()
.statusCode(400)
.body("error", equalTo("VALIDATION_ERROR"))
.body("details", notNullValue());
}
}
三、工具链对比与选型
| 工具 | 语言 | 协议支持 | 适用场景 | CI/CD 友好度 |
|---|---|---|---|---|
| Postman | GUI + JS | REST/GraphQL/gRPC | 手动探索、团队协作 | Newman CLI |
| Newman | CLI | REST/GraphQL | CLI 执行 Postman Collection | ⭐⭐⭐⭐⭐ |
| pytest + requests | Python | REST | 自动化测试、数据驱动 | ⭐⭐⭐⭐⭐ |
| RestAssured | Java | REST/GraphQL | Java 生态 API 测试 | ⭐⭐⭐⭐⭐ |
| Supertest | JS/TS | REST | Node.js 项目集成测试 | ⭐⭐⭐⭐ |
| Karate | DSL | REST/GraphQL | BDD 风格的 API 测试 | ⭐⭐⭐⭐ |
| Hoppscotch | GUI | REST/GraphQL | Postman 开源替代 | CLI 有限 |
四、Postman / Newman CI/CD 集成
4.1 导出 Collection 并用 Newman 执行
# 安装 Newman
npm install -g newman newman-reporter-htmlextra
# 执行 Collection
newman run api-tests.postman_collection.json \
-e production.postman_environment.json \
--reporters cli,htmlextra,junit \
--reporter-junit-export results/junit.xml \
--reporter-htmlextra-export results/report.html
# 失败时非零退出码,CI 自动阻断
4.2 GitHub Actions 集成
# .github/workflows/api-test.yml
name: API Tests
on: [push, pull_request]
jobs:
api-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start services
run: docker-compose -f docker-compose.test.yml up -d
- name: Wait for services
run: npx wait-on http://localhost:8080/health --timeout 30000
- name: Run Newman
run: |
npx newman run tests/api/collection.json \
-e tests/api/env.ci.json \
--reporters cli,junit \
--reporter-junit-export newman-results.xml
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: api-test-results
path: newman-results.xml
五、GraphQL 测试策略
5.1 GraphQL 测试的独特挑战
| 特性 | REST | GraphQL |
|---|---|---|
| 端点 | 多个(/users, /orders) | 单一(/graphql) |
| 请求体 | 固定结构 | 动态 Query |
| 响应结构 | 由 URL 决定 | 由 Query 决定 |
| 错误位置 | HTTP 状态码 | 200 OK + errors 数组 |
| 测试重点 | URL + 方法 + Body | Query 结构 + 变量 |
5.2 Apollo Client 测试
import { MockedProvider } from '@apollo/client/testing';
import { render, screen, waitFor } from '@testing-library/react';
import { GET_USER_ORDERS } from './queries';
import { UserOrders } from './UserOrders';
const mocks = [
{
request: {
query: GET_USER_ORDERS,
variables: { userId: 'user-123', limit: 10 },
},
result: {
data: {
user: {
id: 'user-123',
orders: [
{ id: 'order-1', total: 99.99, status: 'DELIVERED' },
{ id: 'order-2', total: 49.50, status: 'PENDING' },
],
},
},
},
},
// 错误场景
{
request: {
query: GET_USER_ORDERS,
variables: { userId: 'invalid', limit: 10 },
},
error: new Error('User not found'),
},
];
it('renders user orders', async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<UserOrders userId="user-123" />
</MockedProvider>
);
await waitFor(() => {
expect(screen.getByText('order-1')).toBeInTheDocument();
});
expect(screen.getAllByTestId('order-item')).toHaveLength(2);
});
5.3 Python GraphQL 测试
import pytest
import requests
GRAPHQL_URL = "http://localhost:8000/graphql"
@pytest.fixture
def graphql_client(auth_headers):
def execute(query: str, variables: dict = None):
response = requests.post(
GRAPHQL_URL,
headers=auth_headers,
json={"query": query, "variables": variables or {}}
)
data = response.json()
# GraphQL 错误在 200 响应中
if "errors" in data:
raise AssertionError(f"GraphQL errors: {data['errors']}")
return data["data"]
return execute
class TestGraphQL:
def test_get_user_with_orders(self, graphql_client):
query = """
query GetUser($id: ID!) {
user(id: $id) {
id
name
orders(limit: 5) {
id
total
status
}
}
}
"""
result = graphql_client(query, {"id": "user-123"})
assert result["user"]["id"] == "user-123"
assert len(result["user"]["orders"]) <= 5
def test_mutation_create_order_idempotent(self, graphql_client):
"""幂等性测试:相同 idempotencyKey 返回相同结果。"""
mutation = """
mutation CreateOrder($input: CreateOrderInput!) {
createOrder(input: $input) {
id
status
totalAmount
}
}
"""
variables = {
"input": {
"customerId": "cust-001",
"items": [{"productId": "p1", "quantity": 1}],
"idempotencyKey": "unique-key-123"
}
}
result1 = graphql_client(mutation, variables)
result2 = graphql_client(mutation, variables)
assert result1 == result2 # 幂等
六、gRPC 测试策略
6.1 gRPC 测试挑战
- 二进制协议:protobuf 序列化,HTTP/2 传输
- 无浏览器工具:不能用 curl/Postman 直接调用(需插件或 grpcurl)
- 强类型:proto 定义即契约
6.2 Python gRPC 测试
import pytest
import grpc
from concurrent import futures
# 假设有生成的 proto 代码
from generated import order_pb2, order_pb2_grpc
from server import OrderServicer
@pytest.fixture(scope="module")
def grpc_channel():
"""启动内存 gRPC 服务器用于测试。"""
server = grpc.server(futures.ThreadPoolExecutor(max_workers=1))
order_pb2_grpc.add_OrderServiceServicer_to_server(OrderServicer(), server)
port = server.add_insecure_port('localhost:0')
server.start()
channel = grpc.insecure_channel(f'localhost:{port}')
yield channel
channel.close()
server.stop(None)
class TestOrderGrpc:
def test_create_order(self, grpc_channel):
stub = order_pb2_grpc.OrderServiceStub(grpc_channel)
request = order_pb2.CreateOrderRequest(
customer_id="cust-001",
items=[
order_pb2.OrderItem(product_id="p1", quantity=2, price=29.99)
]
)
response = stub.CreateOrder(request)
assert response.order_id != ""
assert response.status == order_pb2.OrderStatus.PENDING
assert abs(response.total_amount - 59.98) < 0.01
def test_create_order_validation(self, grpc_channel):
stub = order_pb2_grpc.OrderServiceStub(grpc_channel)
# 空 items 应该返回 INVALID_ARGUMENT
with pytest.raises(grpc.RpcError) as exc_info:
stub.CreateOrder(order_pb2.CreateOrderRequest(
customer_id="cust-001",
items=[]
))
assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT
6.3 Java gRPC 测试
@ExtendWith(GrpcTestExtension.class)
class OrderGrpcServiceTest {
@GrpcClient
private OrderServiceGrpc.OrderServiceBlockingStub stub;
@Test
void shouldCreateOrder() {
CreateOrderRequest request = CreateOrderRequest.newBuilder()
.setCustomerId("cust-001")
.addItems(OrderItem.newBuilder()
.setProductId("p1")
.setQuantity(2)
.setPrice(29.99)
.build())
.build();
CreateOrderResponse response = stub.createOrder(request);
assertThat(response.getOrderId()).isNotEmpty();
assertThat(response.getStatus()).isEqualTo(OrderStatus.PENDING);
assertThat(response.getTotalAmount()).isCloseTo(59.98, within(0.01));
}
@Test
void shouldRejectEmptyItems() {
CreateOrderRequest request = CreateOrderRequest.newBuilder()
.setCustomerId("cust-001")
.build();
StatusRuntimeException exception = catchThrowableOfType(
() -> stub.createOrder(request),
StatusRuntimeException.class
);
assertThat(exception.getStatus().getCode())
.isEqualTo(Status.Code.INVALID_ARGUMENT);
}
}
七、OpenAPI 契约测试
7.1 用 Schemathesis 自动发现 API 缺陷
# 安装
pip install schemathesis
# 自动基于 OpenAPI Spec 生成测试用例
st run http://localhost:8000/openapi.json \
--base-url http://localhost:8000 \
--checks all \
--hypothesis-max-examples 100
# 输出:
# GET /api/orders/{orderId} . [OK]
# POST /api/orders F [500]
# Hypothesis found 500 when sending {"items": null}
# → 缺少 null 校验!
7.2 Dredd:API Blueprint / OpenAPI 验证
npm install -g dredd
# dredd.yml
# endpoint: http://localhost:8000
# openapi: ./openapi.yaml
dredd
# 自动验证所有端点的实际行为与 OpenAPI 定义的一致性
八、认证与授权测试
8.1 认证方式测试矩阵
| 认证方式 | 测试重点 | 示例 |
|---|---|---|
| API Key | Header/X-Api-Key 存在性、格式、权限 | X-Api-Key: invalid → 401 |
| Bearer JWT | Token 过期、篡改、签名验证、权限声明 | 过期 Token → 401, 权限不足 → 403 |
| OAuth2 | Code Flow、Token 刷新、Scope 限制 | 缺少 orders:write → 403 |
| mTLS | 证书有效性、CA 验证、客户端身份 | 无效证书 → TLS 握手失败 |
| HMAC | 时间戳窗口、重放攻击、签名算法 | 过期时间戳 → 401 |
8.2 JWT 测试工具
import jwt
from datetime import datetime, timedelta, timezone
@pytest.fixture
def expired_token():
"""生成一个已过期的 JWT 用于测试。"""
payload = {
"sub": "user-123",
"exp": datetime.now(timezone.utc) - timedelta(hours=1),
"scope": "read"
}
return jwt.encode(payload, "secret", algorithm="HS256")
class TestAuth:
def test_expired_token_rejected(self, expired_token):
resp = requests.get(
f"{BASE_URL}/api/orders",
headers={"Authorization": f"Bearer {expired_token}"}
)
assert resp.status_code == 401
assert "expired" in resp.json()["message"].lower()
def test_insufficient_scope(self, auth_headers):
# scope=read 的 token 尝试写入
read_only_token = generate_token(scope="read")
resp = requests.post(
f"{BASE_URL}/api/orders",
headers={"Authorization": f"Bearer {read_only_token}"},
json={"customer_id": "x", "items": []}
)
assert resp.status_code == 403
九、性能基线测试(非压力测试)
API 测试中常常需要验证性能基线(区别于全量压力测试):
import pytest
import time
import statistics
@pytest.mark.benchmark
class TestAPIPerformance:
def test_order_list_response_time(self, auth_headers):
"""验证 95% 的请求延迟 < 200ms。"""
times = []
for _ in range(20):
start = time.perf_counter()
resp = requests.get(f"{BASE_URL}/api/orders?limit=10", headers=auth_headers)
elapsed = (time.perf_counter() - start) * 1000
times.append(elapsed)
assert resp.status_code == 200
p95 = statistics.quantiles(times, n=20)[18] # 近似 P95
avg = statistics.mean(times)
assert p95 < 200, f"P95 latency {p95:.1f}ms exceeds 200ms"
assert avg < 100, f"Average latency {avg:.1f}ms exceeds 100ms"
十、面试常考问题
Q1:如何测试需要认证的 API?
答:三级策略:(1)Setup Fixture:测试套件级别登录一次,获取 token,后续所有测试复用,避免重复登录调用;(2)工厂函数:封装 auth_headers() fixture,内部处理 token 刷新/获取逻辑;(3)负面测试:专门测试无认证/无效 Token/过期 Token/权限不足的四种场景,每种应返回精确的 401/403 状态码和结构化错误体。
Q2:GraphQL 测试和 REST 测试的核心区别是什么?
答:(1)端点统一:GraphQL 只有一个 /graphql 端点,测试的重点不是 URL 而是 Query/Mutation 结构;(2)响应不确定性:同一个端点根据 Query 字段不同返回不同结构,需要针对具体 Query 做响应校验;(3)错误处理差异:GraphQL 通常在 200 响应体内返回 errors 数组,而不是 REST 的 4xx 状态码,测试需断言 errors 字段不存在或格式正确;(4)变量注入:GraphQL 大量使用变量,需要测试变量类型不匹配和缺失的情况。
Q3:如何保证前后端 API Schema 的一致性?
答:(1)Single Source of Truth:用 OpenAPI / Protobuf / GraphQL SDL 作为唯一契约定义;(2)代码生成:后端用 Swagger / SpringDoc 自动生成文档,前端用 OpenAPI Generator 生成 TypeScript client,消除手写不一致;(3)CI 校验:用 Dredd / Schemathesis 在 CI 中自动验证实际 API 与契约定义的一致性;(4)Consumer-Driven Contracts:用 Pact 记录消费者期望,Provider 端自动验证是否满足期望。
参考与延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。