Rust 微服务测试别只跑单元测试,真正要测的是边界
handler 里每个函数都测了,覆盖率也不低;一接数据库、Redis、下游 HTTP,问题还是一堆。
Rust 项目很容易把测试写成两种极端:
一种是纯单元测试,快是快,但只证明小函数没写错。
另一种是全链路测试,启动一堆依赖,慢、脆、失败了也不知道哪层坏了。
我更喜欢把测试看成一组边界校验:类型边界、业务边界、数据库边界、协议边界、集成边界。每一层测自己该负责的东西,不互相冒充。
单元测试适合测纯业务规则
纯函数最值得写单元测试,因为它们快、稳定、失败信息清楚。
比如优惠规则:
#[derive(Debug)]
struct OrderDraft {
user_id: u64,
total_cents: u64,
}
fn can_use_coupon(order: &OrderDraft) -> bool {
order.total_cents >= 10_000
}测试应该直接表达业务边界:
#[test]
fn coupon_requires_minimum_amount() {
let small = OrderDraft { user_id: 1, total_cents: 9_999 };
let enough = OrderDraft { user_id: 1, total_cents: 10_000 };
assert!(!can_use_coupon(&small));
assert!(can_use_coupon(&enough));
}这类测试不需要 mock 数据库,不需要启动 HTTP server。
越核心的业务规则,越应该从框架里拿出来测。
如果一个规则只能通过 handler 测,通常说明业务逻辑和 I/O 混得太紧。
Handler 测试不要测数据库细节
handler 层应该测协议行为:
- 请求字段缺失返回什么
- 参数非法返回什么
- 业务错误映射成什么状态码
- trace/request id 有没有传递
不要在 handler 测试里过度关心 SQL 怎么写。
先定义服务层接口:
#[derive(Debug, PartialEq, Eq)]
enum CreateOrderError {
InvalidSku,
NotEnoughStock,
}
fn status_code_for(err: CreateOrderError) -> u16 {
match err {
CreateOrderError::InvalidSku => 400,
CreateOrderError::NotEnoughStock => 409,
}
}这个映射就值得单独测:
#[test]
fn not_enough_stock_maps_to_conflict() {
assert_eq!(status_code_for(CreateOrderError::NotEnoughStock), 409);
}HTTP 框架测试可以覆盖少量关键路径,但不要把所有业务组合都搬到 handler 测试里。那样失败时很难看出是协议层坏了,还是业务层坏了。
数据库测试要测约束,不只测 Repository
很多 repository 测试看起来像这样:
insert user
find user
assert equal这当然有用,但价值有限。数据库测试更应该验证那些应用层不可靠的边界:
- 唯一约束
- 外键约束
- 事务回滚
- 条件更新
- 幂等键冲突
比如幂等键:
create unique index uniq_order_idempotency
on orders(user_id, idempotency_key);测试关注点应该是“重复提交插不进去”,不是“insert 函数返回 Ok”。
#[derive(Debug, PartialEq, Eq)]
enum InsertOrderResult {
Created,
AlreadyExists,
}
fn map_insert_result(unique_conflict: bool) -> InsertOrderResult {
if unique_conflict {
InsertOrderResult::AlreadyExists
} else {
InsertOrderResult::Created
}
}真实项目里这类测试最好跑在真实数据库上。SQLite 模拟 PostgreSQL 的部分行为可以加速开发,但不要把它当完全等价。
数据库约束用真实数据库测,业务纯逻辑用纯 Rust 测。
Mock 适合隔离,不适合制造幻想
Rust 里 mock trait 很方便,但 mock 过度以后,测试会变成“证明 mock 按照我设想的方式被调用”。
我更愿意让 trait 表达业务依赖,而不是表达技术细节:
trait PaymentGateway {
fn charge(&self, user_id: u64, cents: u64) -> Result<String, PaymentError>;
}
#[derive(Debug, PartialEq, Eq)]
enum PaymentError {
Rejected,
Timeout,
}这个 trait 的抽象层级还可以接受:它表达“支付网关能扣款”,不是“HTTP client 调了哪个 URL”。
mock 的重点应该是覆盖业务分支:
- 支付成功后订单变成 paid
- 支付被拒后订单变成 failed
- 支付超时后订单保持 paying,等待补偿
不要 mock 出一个永远完美的世界。真实下游会超时、会返回 500、会返回你没见过的字段。
契约测试比口头约定可靠
微服务之间最容易出问题的是协议漂移:
调用方以为 status 是 "paid"
提供方改成 "PAID"或者字段从必填变成可选,错误码语义变了,分页字段改名了。
最轻量的契约测试,是把请求响应样本固化下来:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct UserResponse {
id: u64,
name: String,
}
fn parse_user_response(body: &str) -> serde_json::Result<UserResponse> {
serde_json::from_str(body)
}然后用样本测解析:
#[test]
fn parses_user_contract() {
let body = r#"{"id":1,"name":"Ada"}"#;
let user = parse_user_response(body).unwrap();
assert_eq!(user.name, "Ada");
}如果团队规模更大,可以上 Pact 或基于 OpenAPI 的契约校验。工具不是重点,重点是不要靠“应该没人会改字段”来维持服务间协议。
集成测试要少而硬
全链路集成测试应该覆盖最关键的用户路径,不应该覆盖所有排列组合。
我会优先选这些:
- 用户注册到登录
- 下单到支付状态变化
- 幂等重试
- 权限拒绝
- 消息 Outbox 投递
- 数据库迁移后旧数据还能读
集成测试失败时,成本很高,所以它必须测真正值得测的路径。
一个健康的测试金字塔大概是:
| 层级 | 数量 | 关注点 |
|---|---|---|
| 单元测试 | 多 | 业务规则、错误分类 |
| 数据库测试 | 中 | 约束、事务、迁移 |
| 契约测试 | 中 | 服务间协议 |
| 集成测试 | 少 | 核心链路 |
不要用大量慢集成测试弥补业务逻辑不可测。
如果业务规则只能靠全链路测,那是代码结构在提醒你该拆了。
测试数据要有名字,不要只有数字
测试里最常见的可读性问题是这样:
let order = OrderDraft { user_id: 1, total_cents: 9999 };过一段时间没人知道 9999 是什么意思。
改成带语义的构造函数:
fn order_below_coupon_threshold() -> OrderDraft {
OrderDraft { user_id: 1, total_cents: 9_999 }
}
fn order_at_coupon_threshold() -> OrderDraft {
OrderDraft { user_id: 1, total_cents: 10_000 }
}这不是形式主义。测试的价值一半在防回归,一半在解释业务边界。
测试数据没有名字,业务边界就藏进了数字里。
结论
Rust 项目的测试不应该只追求覆盖率。覆盖率能告诉你代码跑过了,不能告诉你业务边界被验证了。
纯业务规则用单元测试打牢;数据库约束用真实数据库验证;服务间协议用契约测试守住;全链路集成测试只覆盖少数关键路径。
测试不是为了证明代码没问题,而是为了让重要边界出问题时立刻暴露。