返回文章列表

Rust 微服务测试别只跑单元测试,真正要测的是边界

311·3 分钟阅读
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 项目的测试不应该只追求覆盖率。覆盖率能告诉你代码跑过了,不能告诉你业务边界被验证了。

纯业务规则用单元测试打牢;数据库约束用真实数据库验证;服务间协议用契约测试守住;全链路集成测试只覆盖少数关键路径。

测试不是为了证明代码没问题,而是为了让重要边界出问题时立刻暴露。