Skip to content

第21章 测试模式:ServiceExt::oneshot 与 axum-test crate

前 20 章讲的都是"怎么写 axum 代码"——这一章讲"怎么测 axum 代码"。测试和写代码同等重要——特别对生产 web 服务:错误的 handler 会让线上服务出问题、未覆盖的 corner case 会成 bug 温床。

axum 的一个特点:没有内建的 TestClient——不像 actix-web 的 TestRequest / Flask 的 app.test_client()。这看起来像功能缺失、但实际是设计——axum 让你用已有工具(tower、axum-test crate)——不造新 API。

本章覆盖几种测试模式:

  • tower::ServiceExt::oneshot:最底层——直接调 Router 的 Service 方法
  • axum-test crate:更 ergonomic 的 HTTP 测试 client
  • 集成测试和单元测试的组合:按测试金字塔组织
  • 特殊响应测试(SSE / 流式 / WebSocket):分块验证
  • 性能测试(wrk / bombardier / criterion):宏观和微观 benchmark

读完你应该能给任意 axum 项目设计一套完整的测试策略——单元 + 集成 + e2e + 性能。

为什么 axum 不内建 TestClient

Actix-web、Rocket、Flask 等框架都有内建测试 client——TestRequestClienttest_client()。axum 偏偏没有。这是设计决策而不是功能缺失:

一、tower ecosystem 已经提供tower::ServiceExt::oneshot 是通用 Service 测试工具——axum 的 Router 就是 Service、直接用。axum 不 NIH(Not-Invented-Here)——不重复造。

二、testing client 接口选择困难:要像 reqwest 那样的 chain API 吗?要支持 multipart 吗?cookie 持久吗?axum 不想替用户选——让 axum-test crate 负责。用户按需装。

三、核心 crate 精简原则:axum 本体依赖少是优势——加 TestClient 意味着依赖 HTTP client(reqwest 或自己写)——偏离"精简核心"哲学。

四、测试场景多样:纯 Service 测试(oneshot)、完整 TCP 测试(启真实 server)、流式测试(verify chunks)——一个 client API 很难覆盖全。axum 提供 building block、用户组合。

这个决策争议较多——有人觉得缺 TestClient 让入门门槛高。但随着 axum-test 成熟——这个问题实际已经解决:用 axum-test 就和用内建 client 一样方便。axum 本体保持精简——用户按需加依赖。

测试金字塔在 axum 项目里的映射

经典测试金字塔:

  • 底层(多):单个 handler 的单元测试——用 oneshot 直接调 Service——毫秒级跑、数量多
  • 中层:完整 Router 集成测试——带 mock state——秒级跑、覆盖 handler 间交互
  • 顶层(少):端到端测试——启真实 TCP 监听、reqwest client 发请求——最真实、最慢

每层测试各有价值——单元测试捕获 handler 逻辑 bug、集成测试捕获 Router 组装 bug、e2e 测试捕获部署层 bug(TLS、网络、配置)。

tower::ServiceExt::oneshot:最底层测试

tower::ServiceExt::oneshot(req) 把 Service + Request 组合成一次调用——返回 Future<Output = Result<Response, Error>>。这是测试 axum 的最直接方法。

rust
use axum::{Router, routing::get, body::Body};
use http::{Request, StatusCode};
use tower::ServiceExt;  // 提供 oneshot

#[tokio::test]
async fn hello_world() {
    async fn handler() -> &'static str { "hello" }
    let app = Router::new().route("/", get(handler));

    let request = Request::builder()
        .uri("/")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();

    assert_eq!(response.status(), StatusCode::OK);
    let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
    assert_eq!(&body[..], b"hello");
}

核心 pattern:

  1. 构造 Router:和生产代码一样
  2. 构造 Request:用 http::Request::builder()Request::new()
  3. app.oneshot(request).await:拿到 Response
  4. assert 各方面:status、header、body

不起真实 TCP——所有都在内存里。测试速度是真实 HTTP 的几十到几百倍。没有网络、没有 hyper parse——只是 Service::call。

oneshot 的返回类型

rust
fn oneshot<S, Req>(self, req: Req) -> impl Future<Output = Result<S::Response, S::Error>>
where S: Service<Req>;

对 axum 的 Router:S::Response = ResponseS::Error = Infallible——所以 await.unwrap() 安全(Infallible 不可能 err)。

测试 body 的 helper

每次都写 axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap() 太长——建议抽 helper:

rust
async fn body_bytes(response: Response) -> Bytes {
    axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap()
}

async fn body_string(response: Response) -> String {
    String::from_utf8(body_bytes(response).await.to_vec()).unwrap()
}

async fn body_json<T: DeserializeOwned>(response: Response) -> T {
    let bytes = body_bytes(response).await;
    serde_json::from_slice(&bytes).unwrap()
}

放到测试模块的共享 helper 文件里——所有测试复用。

测试带 state 的 handler

rust
#[derive(Clone)]
struct AppState { db: Arc<MockDb> }

async fn get_user(State(state): State<AppState>, Path(id): Path<u64>) -> Json<User> {
    Json(state.db.get_user(id).await)
}

#[tokio::test]
async fn test_get_user() {
    let db = Arc::new(MockDb::new());
    db.insert(User { id: 1, name: "Alice".into() }).await;

    let state = AppState { db: db.clone() };
    let app = Router::new()
        .route("/users/{id}", get(get_user))
        .with_state(state);

    let request = Request::builder().uri("/users/1").body(Body::empty()).unwrap();
    let response = app.oneshot(request).await.unwrap();

    assert_eq!(response.status(), StatusCode::OK);
    let user: User = body_json(response).await;
    assert_eq!(user.name, "Alice");
}

关键:state 在测试里可以和生产不同——生产用真实 PgPool、测试用 MockDb。两者都满足 Clone——Router 类型一致。这是第 18 章讨论过的 state 抽象化(用 trait object 或 Arc<dyn Trait>)让测试 mock 容易。

mock 一个 trait object state

rust
trait UserService: Send + Sync {
    async fn get_user(&self, id: u64) -> Option<User>;
    async fn create_user(&self, user: User) -> Result<(), Error>;
}

#[derive(Clone)]
struct AppState {
    users: Arc<dyn UserService>,
}

// 生产
struct ProductionUsers { db: PgPool }
impl UserService for ProductionUsers { /* ... */ }

// 测试 mock
struct MockUsers {
    users: Mutex<HashMap<u64, User>>,
}
impl UserService for MockUsers {
    async fn get_user(&self, id: u64) -> Option<User> {
        self.users.lock().unwrap().get(&id).cloned()
    }
    // ...
}

#[tokio::test]
async fn test_with_mock() {
    let mock = MockUsers::new();
    mock.users.lock().unwrap().insert(1, test_user());

    let state = AppState { users: Arc::new(mock) };
    // ... 跑测试
}

trait object 让 handler 代码不变——测试只替换 impl。第 18 章详讨论过这个 pattern。

mockall crate 自动生成 mock

手写 mock 繁琐——mockall crate 自动生成:

rust
#[mockall::automock]
trait UserService: Send + Sync {
    async fn get_user(&self, id: u64) -> Option<User>;
}

#[tokio::test]
async fn test_with_mockall() {
    let mut mock = MockUserService::new();
    mock.expect_get_user()
        .with(eq(1u64))
        .returning(|_| Some(test_user()));

    let state = AppState { users: Arc::new(mock) };
    // 运行 test
    // 结束时 mock 自动验证 `expect_get_user` 被调了正确次数
}

expect 语法让测试明确——能 assert "某方法被调几次、参数是什么"。对复杂 business logic 的 handler 特别有用。

测试 Router 的组合

复杂项目 Router 是多个子 Router 组合。测试时 decide:

  • 测单个子 Router:模块内 unit test、不跨模块
  • 测完整 Router:集成测试——覆盖 Router 组合的行为
rust
// lib.rs 里暴露 build_* helper
pub fn build_user_router() -> Router<AppState> { /* ... */ }
pub fn build_admin_router() -> Router<AppState> { /* ... */ }
pub fn build_full_router(state: AppState) -> Router { /* ... */ }

// 单个子 Router 测试
#[tokio::test]
async fn user_router_lists_users() {
    let app = build_user_router().with_state(test_state());
    let response = app.oneshot(Request::builder().uri("/users").body(Body::empty()).unwrap())
        .await.unwrap();
    assert_eq!(response.status(), StatusCode::OK);
}

// 完整 Router 测试——包括 middleware 栈
#[tokio::test]
async fn full_router_with_auth() {
    let app = build_full_router(test_state());
    // 包括 auth middleware、rate limit 等完整栈
    // ...
}

子 Router 测试快、聚焦——适合日常开发。完整 Router 测试全、但慢——适合 CI 跑。两层组合让开发和 CI 都有合适的反馈速度。

测试 middleware

测试全局 middleware

rust
async fn add_header_mw<B>(mut req: Request<B>, next: Next) -> Response {
    req.headers_mut().insert("x-test", "yes".parse().unwrap());
    next.run(req).await
}

#[tokio::test]
async fn middleware_adds_header() {
    async fn handler(headers: HeaderMap) -> String {
        headers["x-test"].to_str().unwrap().to_string()
    }

    let app = Router::new()
        .route("/", get(handler))
        .layer(middleware::from_fn(add_header_mw));

    let response = app.oneshot(Request::new(Body::empty())).await.unwrap();
    assert_eq!(body_string(response).await, "yes");
}

核心:挂 middleware 到 Router、请求过来看 handler 是否看到 middleware 的效果——通过 handler 的响应验证 middleware 工作。

测试拒绝路径

rust
#[tokio::test]
async fn auth_rejects_without_token() {
    async fn handler() -> &'static str { "secret" }
    let app = Router::new()
        .route("/", get(handler))
        .route_layer(from_extractor::<RequireAuth>());

    // 没 token——应该 401
    let response = app.clone().oneshot(
        Request::builder().uri("/").body(Body::empty()).unwrap()
    ).await.unwrap();
    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);

    // 有 token——应该 200
    let response = app.oneshot(
        Request::builder()
            .uri("/")
            .header("authorization", "Bearer valid")
            .body(Body::empty()).unwrap()
    ).await.unwrap();
    assert_eq!(response.status(), StatusCode::OK);
}

两条路径都测——成功 + 失败。assert 不同状态码和响应。

app.clone() 让同一个 Router 能跑多次(ServiceExt::oneshot 按值消费 Router)——Router 本身是 Clone、复制零开销。

测试 SSE 流式响应

SSE 的测试需要验证多个 chunk:

rust
async fn stream_handler() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
    let stream = stream::iter(vec![
        Ok(Event::default().data("first")),
        Ok(Event::default().data("second")),
    ]);
    Sse::new(stream)
}

#[tokio::test]
async fn sse_produces_events() {
    let app = Router::new().route("/events", get(stream_handler));
    let response = app.oneshot(
        Request::builder().uri("/events").body(Body::empty()).unwrap()
    ).await.unwrap();

    assert_eq!(response.headers()["content-type"], "text/event-stream");

    let mut stream = response.into_body().into_data_stream();

    let chunk1 = stream.next().await.unwrap().unwrap();
    assert!(std::str::from_utf8(&chunk1).unwrap().contains("data: first"));

    let chunk2 = stream.next().await.unwrap().unwrap();
    assert!(std::str::from_utf8(&chunk2).unwrap().contains("data: second"));

    assert!(stream.next().await.is_none());
}

第 17 章讲的 response.into_body().into_data_stream() 在测试里大量用——让流式响应能按 chunk 逐个验证。

测试带 timeout 的 SSE

生产 SSE 可能持续很久——测试不应该等真实秒数。用 tokio::time::timeout

rust
#[tokio::test]
async fn sse_completes_in_time() {
    // ... 启动 app、发请求 ...
    let mut stream = response.into_body().into_data_stream();

    let result = tokio::time::timeout(
        Duration::from_secs(1),
        async {
            let mut chunks = Vec::new();
            while let Some(chunk) = stream.next().await {
                chunks.push(chunk.unwrap());
            }
            chunks
        }
    ).await;

    let chunks = result.expect("should complete within 1s");
    assert_eq!(chunks.len(), 3);
}

#[tokio::test(start_paused = true)] 能让 tokio::time::sleep 走虚拟时间——测试不用真实等——但要注意底层 IO 不走虚拟时间。

Snapshot testing:快照测试

对于响应体内容复杂(JSON 多字段、HTML 多行)——手写 assert 每个字段繁琐。snapshot testing 工具自动记录首次运行的响应、之后每次对比——变化要求用户 confirm:

rust
use insta::assert_json_snapshot;

#[tokio::test]
async fn user_endpoint_snapshot() {
    let app = build_test_app();
    let response = app.oneshot(Request::builder().uri("/users/1").body(Body::empty()).unwrap())
        .await.unwrap();

    let body: serde_json::Value = body_json(response).await;
    assert_json_snapshot!(body, {
        ".timestamp" => "[timestamp]",  // 忽略变化的字段
        ".random_id" => "[id]",
    });
}

insta 库的工作流:

  1. 第一次跑测试——insta 创建 snapshots/users__user_endpoint_snapshot.snap 文件存 body
  2. 后续跑——对比当前 body 和 snapshot
  3. 不一致——测试失败、显示 diff
  4. 如果变化是预期的——cargo insta review 审查并接受新 snapshot

优点:

  • 减少 assert boilerplate:不用写几十行 assert_eq
  • 完整响应校验:整个 body 对比、不遗漏字段
  • 易发现回归:格式小变化也会被捕获

缺点:

  • snapshot 文件要 commit——project repo 多几个 .snap 文件
  • 合并冲突频繁——snapshot 改动大时 diff 噪音多

适合表现层不常变的场景(稳定的 API 响应)。API 还在演进时不适合——每次 review snapshot 烦。

snapshot + 忽略动态字段

snapshot 里常见问题:timestamp、UUID 等字段每次都不同——直接对比失败。insta 的 redactions:

rust
assert_json_snapshot!(body, {
    ".created_at" => "[datetime]",
    ".id" => "[uuid]",
    ".**.token" => "[token]",  // 递归匹配任何层级的 token 字段
});

用正则或路径模式替换动态字段——让 snapshot 聚焦在稳定部分。

属性测试:proptest

对 handler 的边界值——写几十个测试用例烦。proptest 自动生成:

rust
use proptest::prelude::*;

proptest! {
    #[test]
    fn parse_user_id_handles_any_u64(id in any::<u64>()) {
        let tokio_rt = tokio::runtime::Runtime::new().unwrap();
        tokio_rt.block_on(async {
            let app = build_test_app();
            let response = app.oneshot(
                Request::builder().uri(&format!("/users/{id}")).body(Body::empty()).unwrap()
            ).await.unwrap();
            // 任何 u64 都应该不 panic——要么 200 要么 404
            assert!(response.status().is_success() || response.status() == 404);
        });
    }
}

any::<u64>() 生成随机 u64——测试 handler 对所有 u64 稳定。proptest 会试几百个值——常见边界(0、MAX、MIN)自动涵盖。

适合输入多样性关键的 handler——search、filter、query 等。简单 CRUD 不需要 proptest——常规测试够。

axum-test crate

axum-test 是第三方(但官方推荐)的测试 client——API 比 oneshot 更 ergonomic:

rust
use axum_test::TestServer;

#[tokio::test]
async fn test_with_axum_test() {
    let app = Router::new().route("/", get(handler));
    let server = TestServer::new(app).unwrap();

    let response = server.get("/").await;
    response.assert_status_ok();
    response.assert_text("hello");
}

特点:

  • 链式 assert.assert_status_ok().assert_text(...).assert_json(...)——比手动 assert_eq! 可读
  • Cookie supportserver.add_cookie(...) 让后续请求带 cookie——session 测试方便
  • multipart helpers:文件上传测试简化
  • 响应方法response.text()response.json::<T>()response.header(...)

对比 oneshot:axum-test 的 API 更高层、不用手动构造 Request/Response——但依赖多一个 crate、不像 oneshot 那样"直接看到 Service 工作"。

axum-test 的适用场景

选择规则:

  • 单 handler、简单断言:oneshot 够用
  • 多请求、需要 cookie 会话:axum-test 方便
  • 复杂的 assert 链:axum-test 的流利 API 舒服
  • 测试要暴露给非 Rust 开发者读:axum-test 可读性好

大多项目可以混用——easy case 用 oneshot、复杂 case 用 axum-test。

axum-test 的内部实现

简化来看——axum-test 就是 tower::ServiceExt + 便利包装。不启真实 TCP——只在内存中跑 Service。这保持了 oneshot 的速度优势、同时提供 HTTP client 式的 API。

两种 API 都通过 Service::call——只是封装风格不同。

集成测试的项目结构

Rust 的 tests/ 目录存集成测试——和 src/ 里的 unit test 不同:

text
my_project/
  src/
    lib.rs
    routes.rs    # handler 定义
    state.rs     # state 类型
  tests/
    auth.rs      # 认证相关集成测试
    users.rs     # 用户 CRUD 集成测试
    common/
      mod.rs     # 共享 test helpers

tests/auth.rs

rust
use my_project::{build_app, test_state};
use axum_test::TestServer;

#[tokio::test]
async fn login_creates_session() {
    let server = TestServer::new(build_app(test_state()).await).unwrap();
    let response = server.post("/login")
        .json(&login_data())
        .await;
    response.assert_status_ok();
    // ...
}

项目里需要暴露 build_app(state)test_state()——让测试能构造真实 Router + mock state。这些 helper 放 lib.rs 或 test_helpers 模块。

common/ 模块避开的坑

Rust 的 tests/ 下每个 .rs 文件是独立 binary——不能直接 mod common; 引用——因为 common.rs 也会被当独立 test binary。

解决:创建 tests/common/mod.rs(注意是 dir + mod.rs)——这种结构 cargo 不会当独立 binary、可以 mod common; import。

rust
// tests/auth.rs
mod common;
use common::test_helper;

这个 trick 在 Rust 社区很常见——每本 Rust 书都提到。

数据库相关测试

真实项目的测试常涉及数据库——策略:

内存 SQLite

rust
async fn test_pool() -> sqlx::SqlitePool {
    let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
    sqlx::migrate!("./migrations").run(&pool).await.unwrap();
    pool
}

每个测试一个独立内存 DB——完全隔离、跑完即销毁。速度快(毫秒级)、但 SQLite 和生产 PgSQL 有语法差异——某些测试可能在 SQLite 上跑得过但生产失败。

独立 schema / prefix

rust
async fn test_pool() -> sqlx::PgPool {
    let unique = format!("test_{}", uuid::Uuid::new_v4());
    let admin_pool = /* 连 admin pool */;
    sqlx::query(&format!("CREATE SCHEMA {unique}")).execute(&admin_pool).await.unwrap();

    // 连接 URL 里加 schema param
    let pool = sqlx::PgPool::connect(&format!("{base_url}?search_path={unique}")).await.unwrap();
    sqlx::migrate!("./migrations").run(&pool).await.unwrap();
    pool
}

每个测试一个独立 schema——同一 DB 实例但隔离。慢但接近生产(真实 PgSQL)。

testcontainers

rust
use testcontainers::{clients::Cli, images::postgres::Postgres};

async fn test_db() -> sqlx::PgPool {
    let docker = Cli::default();
    let container = docker.run(Postgres::default());
    let url = format!("postgres://postgres@127.0.0.1:{}/postgres", container.get_host_port_ipv4(5432));
    let pool = sqlx::PgPool::connect(&url).await.unwrap();
    pool
}

每次测试启一个新 docker 容器——最真实、最慢。CI/CD 环境需要支持 docker。

选择:

  • 快速 feedback(本地开发):内存 SQLite
  • 完整覆盖(CI):独立 schema 或 testcontainers
  • 生产-like(pre-deploy):testcontainers

常见测试陷阱

陷阱一:忘 .clone()

rust
// ❌ app 被 oneshot 消费, 无法重用
let response = app.oneshot(req1).await.unwrap();
let response2 = app.oneshot(req2).await.unwrap();  // compile error: app moved

// ✅ clone
let response = app.clone().oneshot(req1).await.unwrap();
let response2 = app.oneshot(req2).await.unwrap();

陷阱二:body 不 to_bytes 就 assert

rust
// ❌ body 是 Body 类型, 不能直接和 Vec<u8> 比
assert_eq!(response.body(), &[1, 2, 3]);

// ✅ 先 to_bytes
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(&bytes[..], &[1, 2, 3]);

陷阱三:忘了 #[tokio::test]

rust
// ❌ 普通 #[test] 不能 await
#[test]
fn oneshot_test() {
    app.oneshot(...).await  // compile error
}

// ✅ tokio runtime
#[tokio::test]
async fn oneshot_test() {
    app.oneshot(...).await
}

陷阱四:测试里的 spawn 泄漏

rust
#[tokio::test]
async fn leaky_test() {
    tokio::spawn(async move { loop { /* forever */ } });
    // 测试结束——这个 task 还在运行
    // 多个测试累积——runtime 泄漏
}

测试里 spawn 的 task 确保有明确 shutdown——或者用 JoinHandle::abort 显式终止。

陷阱五:共享可变状态的测试互相污染

rust
static COUNTER: AtomicU64 = AtomicU64::new(0);  // 全局

#[tokio::test] fn test1() { COUNTER.fetch_add(1, ...); /* ... */ }
#[tokio::test] fn test2() { /* COUNTER 可能已经不是 0 */ }

cargo test 默认并发跑测试——全局状态必然冲突。要么 #[serial_test::serial] 让测试串行、要么每测试独立构造不共享 state。

测试 fixture 和 builder 模式

复杂测试需要构造大量测试数据——fixture 模式让这些结构化:

rust
// test_helpers.rs
pub struct UserBuilder {
    id: u64,
    name: String,
    role: Role,
    active: bool,
}

impl UserBuilder {
    pub fn new() -> Self {
        Self { id: 1, name: "default".into(), role: Role::User, active: true }
    }
    pub fn id(mut self, id: u64) -> Self { self.id = id; self }
    pub fn name(mut self, n: &str) -> Self { self.name = n.into(); self }
    pub fn admin(mut self) -> Self { self.role = Role::Admin; self }
    pub fn inactive(mut self) -> Self { self.active = false; self }
    pub fn build(self) -> User {
        User { id: self.id, name: self.name, role: self.role, active: self.active }
    }
}

// 测试
#[tokio::test]
async fn admin_can_see_inactive_users() {
    let admin = UserBuilder::new().admin().build();
    let inactive = UserBuilder::new().id(2).inactive().build();
    // ... 测试逻辑
}

builder pattern 好处:

  • 默认合理:构造一个默认 user 用 UserBuilder::new().build()——不用每次填所有字段
  • 意图明确.admin().inactive(){ role: Role::Admin, active: false, ... } 读起来清楚
  • 小修改容易:添加新字段只改 UserBuilder + default——不改每个测试

测试代码量大的项目这种 fixture 投资回报很高——一组好 fixture 能让几百个测试共享。

axum-test 的高级 API

axum-test 不只基础 GET/POST——还有几个值得记住的方法:

状态持久化

rust
let server = TestServer::new(app).unwrap()
    .with_cookie(Cookie::new("session", "abc"))  // 所有后续请求都带
    .with_header("authorization", "Bearer xyz");

// 发几次请求——都带 cookie + header
server.get("/users/me").await.assert_status_ok();
server.post("/posts").json(&post).await.assert_status_created();

对"登录后做几步操作"的 session 测试极方便——不用每个请求都手动加 header。

文件上传

rust
use axum_test::multipart::{MultipartForm, Part};

server.post("/upload")
    .multipart(
        MultipartForm::new()
            .add_text("title", "my file")
            .add_part("file", Part::bytes(file_bytes).file_name("test.pdf"))
    )
    .await
    .assert_status_ok();

multipart form 的 builder——省去手动构造 HTTP body。

响应断言

rust
let response = server.get("/users/1").await;

response.assert_status_ok();
response.assert_json_eq(&serde_json::json!({ "id": 1, "name": "Alice" }));
response.assert_contains_header("x-custom", "yes");
response.assert_text_contains("expected substring");

链式 assert 让测试代码扁平——比一堆 assert_eq! 可读。

自定义 client

rust
let server = TestServer::builder()
    .save_cookies()            // 自动存 server 返回的 Set-Cookie
    .default_content_type("application/json")
    .build(app)?;

全局配置一次、所有请求继承——适合固定的测试模式。

测试反模式

常见的不好的测试写法:

反模式一:测试里写业务逻辑

rust
// ❌ 测试代码复杂度和业务代码相当
#[tokio::test]
async fn bad_test() {
    let expected = {
        let mut result = Vec::new();
        for i in 0..10 {
            if i % 2 == 0 { result.push(i * 2); }
        }
        result
    };
    let response = /* ... */;
    // 测试里重新实现业务——如果业务改了两边都得改
}

测试应该断言已知结果——不重新计算。预期值写死更好(即使多几行)。

反模式二:测试互相依赖

rust
#[tokio::test]
async fn test_1_creates_user() { /* 依赖 state 在其他测试里被修改 */ }
#[tokio::test]
async fn test_2_reads_the_user() { /* 依赖 test_1 先跑 */ }

测试必须独立——并发执行时顺序不确定、一个失败其他连锁失败。每测试独立构造自己的 state、不依赖他人。

反模式三:assert 太多

rust
#[tokio::test]
async fn too_many_asserts() {
    // 20 个 assert 检查各种字段
}

一个测试一个 concern。测 "创建 user"——只 assert "创建成功 + 基本字段对"。其他方面(权限、副作用)单独测试。每测试失败时原因清晰。

反模式四:慢测试

rust
#[tokio::test]
async fn slow_test() {
    start_real_server().await;     // 启 TCP——几百 ms
    make_real_db_queries().await;  // 真实 DB——几百 ms
    // ... 500 个这种测试——CI 跑 15 分钟
}

单元测试快(几 ms)、集成测试中等(几十 ms)、e2e 少量慢测试。全部用 e2e 测试——CI 挂掉。用对层级。

反模式五:注释测试

rust
// TODO: fix this test
// #[tokio::test]
// async fn test_important_thing() { /* ... */ }

失败测试要么修、要么删——不要注释掉。注释掉的测试等于不存在——CI 不跑——可能永远不修。

性能测试

单元测试确保 correctness——性能测试确保 response time 和吞吐。

微观 benchmark:criterion

criterion 测特定 function / handler 的性能:

rust
use criterion::{criterion_group, criterion_main, Criterion};
use axum::{Router, routing::get};
use tower::ServiceExt;
use http::Request;
use axum::body::Body;

fn bench_handler(c: &mut Criterion) {
    let rt = tokio::runtime::Runtime::new().unwrap();

    c.bench_function("simple handler", |b| {
        b.to_async(&rt).iter(|| async {
            let app = Router::new().route("/", get(|| async { "hi" }));
            let request = Request::builder().uri("/").body(Body::empty()).unwrap();
            let _ = app.oneshot(request).await.unwrap();
        });
    });
}

criterion_group!(benches, bench_handler);
criterion_main!(benches);

criterion 多次跑 benchmark、统计平均 / p99——比简单 timer 精确。适合 regression 检查——CI 跑 criterion、存基线、new PR 超过基线 5% 报警。

宏观 benchmark:wrk / bombardier

真实负载测试用 wrk 或 bombardier:

bash
# wrk 跑 10s、12 threads、400 连接
wrk -t12 -c400 -d10s http://127.0.0.1:3000/

# bombardier
bombardier -c 400 -d 10s http://127.0.0.1:3000/

输出:吞吐(requests/sec)、latency(p50、p99、p99.9)、错误率。生产预发阶段跑——确认性能指标达标。

典型 axum 单连接 handler 在 M1 MacBook 上 100k+ req/s、p99 < 1ms——数字会因 handler 复杂度、业务逻辑、数据库等变化。

压测的注意事项

  • warm-up:第一次请求慢(JIT、cache miss)——测试前跑几秒 warm-up
  • 客户端限制:wrk / bombardier 本身可能成瓶颈——跑在 server 机器上或同等性能机器
  • TCP backlog:服务端 somaxconn 要调大(4096+)——否则高并发下 SYN 被拒
  • File descriptorulimit -n 调大
  • 长连接 vs 短连接wrk -H "Connection: close" 测短连接、默认是长连接

CI/CD 里的测试

生产项目通常在 CI(GitHub Actions / GitLab CI)跑全套测试:

yaml
# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 10s

    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo fmt --check
      - run: cargo clippy -- -D warnings
      - run: cargo test --all-features
      - run: cargo test --doc

几个元素:

  • services: postgres:CI 里起 Postgres 容器——集成测试用
  • Swatinem/rust-cache:缓存 cargo 依赖——不每次重新编译
  • cargo fmt --check:格式检查——防格式不一致 PR
  • cargo clippy -D warnings:lint——把 warning 当 error
  • cargo test --all-features:所有 feature 组合跑测试
  • cargo test --doc:doctest(文档里的示例代码)也跑——确保文档示例不腐烂

CI 跑全测试通常几分钟——开发者 push 后几分钟知道 PR 是否通过。这个 feedback loop 让问题在合入前被发现。

matrix 测试

多版本 Rust 和 feature 组合:

yaml
strategy:
  matrix:
    rust: [stable, beta, nightly]
    features: ["", "--features=tokio", "--all-features"]
steps:
  - run: cargo test --no-default-features ${{ matrix.features }}

矩阵测试让 axum 同时支持 3 个 Rust 版本 × 若干 feature 组合。规模大——但 CI 并行——加 10 分钟内完成。

axum 项目本身用 matrix 测试——保证代码在所有支持的配置下工作。你自己的项目视支持范围——通常 stable Rust + 默认 feature 就够。

测试文化

超越工具——测试文化决定质量:

测试先写:TDD(测试驱动开发)或 BDD(行为驱动开发)——先定 behavior、后实现。对清晰需求的功能效果好;对探索性代码可以先写再补测。

测试随提交:每个 PR 必须带对应测试——code review 时检查。没测试的 PR 打回——简单规则避免 tech debt 堆积。

测试要有意义:不要为覆盖率而测——每个测试都应该验证一个具体行为。"A + B = C" 这种测试没价值——测试逻辑要比代码本身更可信。

测试定期 review:flaky test、slow test 每周一审——修或删。测试也是代码——要维护。

测试失败要关注:CI 变红不要等——立刻排查。积累的失败会让团队忽略 CI signal——最后真正的 bug 也被忽略。

这些文化因素比任何工具都重要——小团队可以先从"每 PR 带测试"开始——逐步建成习惯。

测试数据库 migration

测试 pool 初始化里跑 migration:

rust
async fn test_pool() -> sqlx::PgPool {
    let pool = sqlx::PgPool::connect("postgres://...").await.unwrap();
    sqlx::migrate!("./migrations").run(&pool).await.unwrap();
    pool
}

每个测试一个干净数据库——确保 migration 是最新的、顺便验证 migration 脚本本身没错。CI 里这一步能 catch migration bug——"production 迁移会失败"在 CI 就被发现。

真实项目可能 migration 几十条——每个测试都跑完整 migration 很慢。优化:

  • 缓存已初始化的 database template——每个测试用 CREATE DATABASE x TEMPLATE cached_template——跳过 migration
  • 只跑 schema 创建、不跑数据填充——seed 数据测试各自管理

这些优化在测试数量大时显著加速——从每测试 2 秒降到 0.1 秒。

覆盖率测量

cargo llvm-cov 跑测试 + 生成覆盖率报告:

bash
cargo install cargo-llvm-cov
cargo llvm-cov --html   # 生成 HTML 报告
cargo llvm-cov --lcov --output-path lcov.info  # CI 用 lcov 格式

报告显示每行代码是否被测试覆盖——哪些路径没测、哪些测了多少次。配合 CI 能显示每 PR 的覆盖率变化——帮助决定是否 merge。

但别过度追求覆盖率:

  • 100% 覆盖率不等于 100% 正确——路径覆盖是必要非充分条件
  • 花大时间测 getter/setter 不值——测试要测业务逻辑
  • 80% 左右够——剩下的 edge case 优先覆盖重要业务分支、不是每行都要覆盖

生产项目经验:核心业务代码 90%+、工具代码 60-80%、玩具实验代码 < 60%。按重要性分配测试投入。

测试 FromRef derive 的 state

前面第 18 章讲的 FromRef——测试时需要 mock state 的字段:

rust
#[derive(Clone, FromRef)]
struct AppState {
    db: Arc<dyn Database + Send + Sync>,
    email: Arc<dyn EmailSender + Send + Sync>,
    config: Arc<Config>,
}

// 测试用 mock 构造
fn test_state() -> AppState {
    AppState {
        db: Arc::new(MockDatabase::new()),
        email: Arc::new(MockEmail::new()),
        config: Arc::new(Config::default()),
    }
}

#[tokio::test]
async fn handler_sends_email() {
    let mut mock_email = MockEmail::new();
    mock_email.expect_send_welcome().times(1);  // 期待恰好调一次

    let state = AppState {
        db: Arc::new(MockDatabase::new()),
        email: Arc::new(mock_email),
        config: Arc::new(Config::default()),
    };

    let app = Router::new().route("/signup", post(signup_handler)).with_state(state);
    let response = app.oneshot(/* ... */).await.unwrap();

    // 测试结束时 mock_email 自动验证 expect_send_welcome 被调了 1 次
    // 没调——panic
}

测试关注点:handler 是否调了 mock email——mockall 让这类验证成为可能。

测试的工程经验

几条从生产经验来的教训:

一、测试速度决定 iteration 速度。单元测试应该秒级跑完、集成测试秒到分钟。慢测试会让开发者不愿跑——bug 漏过。

二、并发安全的测试。cargo test 默认并发——全局状态、文件系统、network port——容易冲突。用独立 state、独立临时目录、随机端口。

三、mock 的粒度。mock 数据库查询能快测 handler 逻辑——但 SQL 错误你测不出来。平衡——handler 层 mock、repository 层真实 DB 测。

四、测试命名test_get_user_returns_404_when_not_foundtest_get_user_2 信息量大——失败时 log 看得懂。

五、flaky test 处理。偶尔失败的测试是隐患——要么修、要么删、不要 #[ignore] 掩盖。通常是并发、时序、外部依赖导致——根源分析修。

六、测试覆盖率不是唯一指标。80% 覆盖率的测试可能都是 getter/setter——边界情况没测。关注 关键业务逻辑历史上出过 bug 的地方 有测试——比追求 100% 覆盖率有价值。

axum 特定的测试模式

回顾前 20 章讨论过的测试场景:

  • 提取器(第 6-8 章):.oneshot(req) 配合各种 header / body 验证提取器行为
  • 响应(第 9-10 章):response.body() / response.headers() 验证 IntoResponse 行为
  • middleware(第 13-14 章):挂 layer 到 Router、验证 middleware 的前/后处理
  • state(第 18 章):mock state + FromRef derive、handler 层测试和生产代码一致
  • 错误处理(第 12 章):测试 handler Err 分支、测试 HandleError closure
  • body(第 17 章):流式响应按 chunk 验证、缓冲响应 to_bytes 验证

这些共同组成 axum 项目的完整测试矩阵——每个 feature 都有对应的测试模式。

完整测试分层图

一张图把本章讨论的各种测试方式归位:

不同层级测试覆盖不同关注点、用不同工具——完整覆盖质量的方方面面。

一个完整的测试文件样板

综合应用:

rust
// tests/users.rs
use axum_test::TestServer;
use my_project::{build_app, test_state, User};

fn server() -> TestServer {
    TestServer::new(build_app(test_state())).unwrap()
}

#[tokio::test]
async fn get_user_by_id() {
    let server = server();
    let response = server.get("/users/1").await;
    response.assert_status_ok();
    let user: User = response.json();
    assert_eq!(user.id, 1);
}

#[tokio::test]
async fn get_user_returns_404_when_not_found() {
    let server = server();
    let response = server.get("/users/9999").await;
    response.assert_status_not_found();
}

#[tokio::test]
async fn create_user_requires_auth() {
    let server = server();
    let response = server.post("/users").json(&new_user()).await;
    response.assert_status_unauthorized();
}

#[tokio::test]
async fn create_user_succeeds_with_auth() {
    let server = server().with_header("authorization", "Bearer valid");
    let response = server.post("/users").json(&new_user()).await;
    response.assert_status_created();
    let user: User = response.json();
    assert_eq!(user.name, "new");
}

四个测试覆盖一条 CRUD path——成功 / 失败 / 未授权 / 授权——把 handler 的行为矩阵走一遍。每个测试独立 server 实例(虽然共享 state)——不互相干扰。

这种结构是 axum 生产项目的标准 pattern——tests/ 目录下几十个文件、每个覆盖一块功能——完整时几百到几千个测试。运行全部几分钟——值得。

测试相关 FAQ

Q:oneshot 和真实 HTTP 的行为一致吗?

基本一致——都走 Service::call。差异极小:oneshot 不经过 hyper parse(已经是 Request 对象)、不经过 TCP。对业务逻辑测试 100% 等价;对 "HTTP 协议层 bug"(比如 header 大小写处理、chunked body)oneshot 可能漏过——这些用 axum-test 或真实 e2e 测试。

Q:测试里能用 axum 的 Extension 吗?

可以——测试时构造 Request 时给 extensions 塞值:

rust
let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
request.extensions_mut().insert(RequestId("test-123".into()));
let response = app.oneshot(request).await.unwrap();

middleware 会看到注入的 extension——handler 能提取。这让"middleware 插入 extensions、handler 使用"这类模式能完整测。

Q:测试运行慢——怎么排查?

先 profile:

  • cargo test -- --nocapture 看输出找慢测试
  • cargo test --test my_test -- --test-threads=1 禁并发排查共享状态问题
  • cargo test --release 看是否 debug build 慢
  • 单独跑单个测试 cargo test test_name 看耗时

常见原因:真实网络 IO(用 mock 替代)、数据库 migration 每次重新跑(用 fixture)、并发冲突(用 serial_test)、大 JSON 序列化(小 fixture)。

Q:测试里要 mock tokio::time 吗?

看场景。简单 handler 不需要——测试跑得快。但如果 handler 用了 tokio::time::sleepinterval——#[tokio::test(start_paused = true)] 让虚拟时间前进、测试秒级完成:

rust
#[tokio::test(start_paused = true)]
async fn my_test() {
    let start = tokio::time::Instant::now();
    tokio::time::sleep(Duration::from_secs(3600)).await;  // 虚拟 1 小时
    assert!(start.elapsed() >= Duration::from_secs(3600));
    // 实际测试瞬间完成——不真等 1 小时
}

Q:能测 WebSocket 吗?

能——但复杂些。需要 axum-test 的 ws 支持、或者真实 TCP + tokio-tungstenite 客户端。简单 handler 的 oneshot 对 WebSocket 不直接适用——因为 upgrade 过程需要完整 HTTP 连接。

Q:测试里的 panic 能捕获吗?

Rust 默认 #[should_panic] 属性——测试预期 panic。如果是 handler panic——配 CatchPanicLayer 后 panic 被转成 500 响应、测试能 assert。

对比其他框架的测试体验

axum 的测试体验和其他 Rust web framework 对比:

框架测试工具oneshot 等价client 式 API
axumtower + axum-testServiceExt::oneshotaxum-test
actix-web内建test::call_servicetest::TestRequest
warp内建.reply(&request)直接 filter 调用
rocket内建Client::trackedclient.get("/")

axum 没内建 test client 是设计决策——不造新 API、让用户用 tower。社区通过 axum-test crate 补齐——效果和内建差不多。

这是 Rust 生态常见的取舍——核心精简 + 社区扩展。喜欢 opinionated 框架的人可能觉得 axum 缺东西(要装 axum-test);追求核心简洁的人欣赏这种设计。

工程经验:build_app 函数

生产项目推荐暴露一个 build_app(state) -> Router 函数——生产和测试共享:

rust
// src/lib.rs
pub fn build_app(state: AppState) -> Router {
    Router::new()
        .merge(user_routes())
        .merge(order_routes())
        .layer(TraceLayer::new_for_http())
        .layer(auth_layer())
        .with_state(state)
}

// src/bin/server.rs
fn main() {
    let state = AppState::from_env();
    let app = my_crate::build_app(state);
    axum::serve(listener, app).await.unwrap();
}

// tests/integration.rs
#[tokio::test]
async fn integration_test() {
    let state = test_state();
    let app = my_crate::build_app(state);
    let response = app.oneshot(/* ... */).await.unwrap();
    // ...
}

好处:

  • 测试和生产完全相同 Router 结构——包括 middleware、routes——行为一致
  • 改 Router 一处改——加新 handler、改 middleware——生产和测试同步

这条 pattern 几乎是所有 axum 生产项目的标配——简单但极其重要。项目初期就养成这习惯——后期加测试不用 refactor。

快速反馈的测试写法

开发时改代码 → 等测试反馈 → 再改——这个 cycle 的速度决定开发速度。几条让 cycle 快的 pattern:

1. 用 cargo test --lib 跑 unit test——跳过慢的集成和 doctest:

bash
cargo test --lib    # 快速反馈
cargo test          # 完整测试

2. 用 cargo watch 自动重跑:

bash
cargo install cargo-watch
cargo watch -x test
# 代码改 → 自动重新编译 + 跑测试 → 看结果

3. 单测 focused

bash
cargo test test_get_user_succeeds   # 只跑这一个测试

4. 用 --no-run 只编译不跑——看是否编译过:

bash
cargo test --no-run

5. 分层运行:先跑 unit test(快)、确认通过再跑 integration(慢)、最后跑 e2e(最慢)。开发时 90% 时间只跑 unit、合入前跑完整。

这些习惯组合起来让开发 cycle 从分钟级降到秒级——iteration 速度提升巨大。

安全测试

生产 web service 需要覆盖安全方面的测试:

认证 / 授权

  • 无 token 访问受保护 endpoint——应该 401
  • 错 token——应该 401
  • 权限不够的 user 访问 admin endpoint——应该 403
  • 过期 token——应该 401

输入验证

  • 超大 body(> default limit)——应该 413
  • 畸形 JSON——应该 400
  • SQL injection 尝试('; DROP TABLE)——应该 400 或正常 escape
  • XSS 尝试(<script>)——HTML 响应应该 escape

Rate limiting

  • 短时间大量请求——应该 429
  • cooldown 后应该恢复

CSRF

  • POST 请求没 CSRF token——应该 403(如果用 CSRF 保护)

这些测试通常放 tests/security.rs——专门的安全 test suite。生产上线前跑——catch 基础安全问题。

测试代码的组织原则

几条组织测试代码的原则:

一、每个 feature 一个 test filetests/users.rstests/orders.rs——按业务 feature 分。搜起来方便、影响面清晰。

二、共享 helper 放 common/mod.rstest_state()test_user()login_as(...) 等——多文件复用。

三、长测试分拆:单个测试 > 50 行——考虑拆成 helper + 多个小测试。可读性提高。

四、命名描述行为users_can_delete_own_poststest_delete_1 信息量大。失败时 log 可读。

五、arrange-act-assert 结构:每个测试三段——arrange(构造)、act(执行)、assert(验证)。清晰的三段让读者快速理解。

六、避免"测试内循环"

rust
// ❌ 循环难读、失败难定位
for i in 0..100 {
    let response = /* ... */;
    assert_eq!(response.status(), 200);
}

// ✅ proptest / parameterized

循环如果必要、每次 iteration 加 context log(tracing::info!(i, "iteration"))——失败时能定位哪次。

ServiceExt::oneshot 的内部

简化源码:

rust
// tower 源码
impl<S, Req> ServiceExt<Req> for S where S: Service<Req> {
    fn oneshot(self, req: Req) -> Oneshot<Self, Req> {
        Oneshot::new(self, req)
    }
}

pub struct Oneshot<S, Req> { /* ... */ }

impl<S, Req> Future for Oneshot<S, Req> where S: Service<Req> {
    type Output = Result<S::Response, S::Error>;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // 1. poll_ready
        // 2. call
        // 3. poll future
    }
}

三步走:poll_ready → call → poll future。一个 future 封装完整 Service 调用周期——用户只看到 await

这让测试代码极简——不用自己写 poll_ready 循环、不用手动处理 future。.oneshot(req).await 三 token 完整调用。

从测试看 axum 的设计

测试 axum 项目的关键工具:

  • tower::ServiceExt::oneshot:底层、通用、快速——单个 handler 的单元测试
  • axum-test::TestServer:高层、ergonomic——复杂集成测试
  • mock trait object:替换 state 字段的 impl——生产和测试代码共享 Router
  • mockall crate:自动生成 mock——复杂业务逻辑的参数 / 调用次数验证
  • criterion / wrk:性能 benchmark

测试金字塔的分层原则在 axum 项目里同样适用——单元测试多、集成测试中等、e2e 测试少。每层各有价值、不能互相替代。

axum 的测试体验和生产代码结构紧密——越是用好 axum 类型机制的代码(mock 化的 trait object state、结构化的 middleware)越容易测。第 18 章讨论 state 设计时强调"trait object 抽象"就是为测试服务——生产用具体实现、测试用 mock——Router 类型一样、测试代码简单。第 12 章讨论的错误处理、第 9-11 章讨论的响应抽象——这些机制都是为了让代码可测试。

axum 鼓励的工程风格和测试友好是同一件事——让依赖显式、让类型表达意图、让 mock 容易。写生产代码时想着"怎么测"——代码质量自然高一档。回头看前面 20 章讨论的 pattern——几乎每个都能用 "这让测试更容易" 来解释。

测试和 debugging 的关系

测试失败后 debug 流程:

  1. 看 assertion 消息assert_eq!(a, b) 失败显示两者值——对应 code 改
  2. 跑失败测试时加 --nocapture:看 println / tracing 输出
  3. RUST_BACKTRACE=1:看 panic stack
  4. 单独跑cargo test failing_test -- --exact——隔离环境
  5. debuggerrust-lldb target/debug/deps/test-XXXX——极少用但有时必要

大部分 axum 测试 debug 只需第 1、2 步——assert 消息 + tracing log 够。复杂场景才需要深入。

性能 regression 监控

CI 里跑 benchmark 并存基线:

yaml
- name: Benchmark
  run: cargo bench --bench handler_bench -- --save-baseline current
- name: Compare with previous
  run: cargo bench --bench handler_bench -- --baseline previous --compare
- name: Promote to baseline
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  run: cargo bench --bench handler_bench -- --save-baseline previous

PR 跑时对比前次 main 的 baseline——性能倒退 5%+ 报警。合入后新 main 更新 baseline。

这种"持续性能监控"让性能问题 PR 阶段发现——不会"几个月后才发现系统慢了 20%"——长期维护质量的关键。

对 axum 项目特别重要——因为 Rust 的性能是选它的原因之一。如果一个 PR 引入性能回归、整个选型价值打折扣。持续 benchmark 是保护 Rust 性能优势的工具。

测试的代价认知

虽然测试重要、但认识它的成本

  • 开发时间:写测试占整体开发时间 30-50% 不意外
  • CI 成本:运行时间、计算资源——大项目 CI 每天烧几百美元
  • 维护成本:重构代码时改测试——测试越多越重
  • 学习曲线:新人要学 test helper / fixture / mock——增加 onboarding 时间

所以"写测试"不是 strict "越多越好"——要平衡。关键业务逻辑必须测、实验/原型代码可以少测、内部工具代码适度测——按价值分配投入。

这个权衡没标准答案——每个团队根据项目类型、团队大小、业务风险决定。测试投入的 ROI 曲线是倒 U 形——太少(bug 多)和太多(维护负担)都差——中间某个点最优。定期 review 团队的测试投入和产出——调整到合适水平。

一个简单的生产测试范例

让一切具体化——一个完整测试文件的骨架:

rust
// tests/user_api.rs
use my_app::{build_app, AppState, test_helpers::*};
use axum_test::TestServer;
use serde_json::json;

async fn setup() -> TestServer {
    let state = AppStateBuilder::new()
        .with_mock_db()
        .with_mock_email()
        .build();
    TestServer::new(build_app(state)).unwrap()
}

#[tokio::test]
async fn signup_creates_user_and_sends_welcome() {
    let server = setup().await;
    let response = server.post("/signup")
        .json(&json!({ "email": "new@example.com", "password": "strong_pw_123" }))
        .await;

    response.assert_status_created();
    let body: serde_json::Value = response.json();
    assert!(body["user_id"].is_number());
}

#[tokio::test]
async fn signup_rejects_duplicate_email() {
    let server = setup().await;
    server.post("/signup")
        .json(&json!({ "email": "existing@example.com", "password": "pw" }))
        .await
        .assert_status_created();

    // 再次用同 email 注册
    let response = server.post("/signup")
        .json(&json!({ "email": "existing@example.com", "password": "pw" }))
        .await;
    response.assert_status_conflict();
}

#[tokio::test]
async fn login_returns_session_cookie() {
    let server = setup().await;
    // ...先注册...

    let response = server.post("/login")
        .json(&json!({ "email": "user@example.com", "password": "pw" }))
        .await;
    response.assert_status_ok();
    assert!(response.headers().get("set-cookie").is_some());
}

三个测试——成功注册 + 重复拒绝 + 登录返 cookie——覆盖核心 path。每测试独立 server、互不影响——并发跑安全。

真实项目这样的测试文件每个 API feature 一个——项目几十个 feature——几百个测试。每个都这样结构化——code review 友好、CI 反馈快。

额外的测试工具

一些值得知道的测试相关 crate:

  • insta:snapshot testing(前面讨论过)
  • proptest / quickcheck:属性测试
  • mockall:mock 生成
  • mockito:HTTP 服务端 mock(测试 handler 调外部 API 时用)
  • wiremock:类似 mockito、更现代化
  • testcontainers:docker 容器化测试依赖
  • serial_test:禁用测试并发
  • cargo-nextest:更快的测试 runner(替代 cargo test
  • rstest:参数化测试
  • claim:更友好的 assert(assert_ok!(result)assert!(result.is_ok()) 读起来好)

选用这些 crate 按需——不是每项目都装。从 mockallrstest 开始——投入回报最高。

测试 Router 本体而非 handler 函数

一个容易出的错:只测 handler 函数本身——不测它挂在 Router 上的行为。handler 测过了、但路径拼写错误(/users/:id 写成 /user/:id)、middleware 漏加、方法谓词写错(post 写成 put)——这些 handler 层看不出来的错只有 Router 层能暴露。

规则:端到端测试走完整 Router——用 build_app() 返回的根 Router、用 oneshot 或 axum-test 发请求、断言 status + body。不要只对 handler 函数调 .await——那等于跳过了 axum 最重要的抽象层。

对于 state 依赖——生产 AppState 里换成 test double(mock DB pool、fake external client)——其他结构保持和生产一致。这样测试最接近真实行为——catch 的 bug 最广。

测试代码的可读性硬要求

测试代码可读性比生产代码更重要——它是第二作者理解代码行为的第一入口。三条硬要求:

一、一个测试只断言一件事test_create_user_success 断言 status == 201test_create_user_returns_id 断言返回体有 id。不要一个测试塞五个 assert——失败时不知道哪个先炸。

二、命名讲因果test_<动作>_<条件>_<期望> 模式——比如 test_delete_user_when_not_admin_returns_403。看命名就懂意图——不用读测试体。

三、Given-When-Then 结构。测试体分三段——准备数据(given)、执行操作(when)、断言结果(then)。空行分隔——视觉上一眼读懂。塞一起的测试要读三遍才明白顺序。

这三条做到——测试就从"验证代码"升级成"代码的活文档"——团队新人读测试就能掌握系统行为。

测试并发隔离:每个测试一份数据库

多个测试并发跑时共享同一份 Postgres / Redis 会相互污染——一个测试 insert 的行被另一个测试 select 到。cargo test 默认并行跑测试——这个问题在真实项目必然出现。

三种隔离方案。方案一:每测试一个 schema——CREATE SCHEMA test_<test_name>、测试结束 DROP SCHEMA。速度中等(schema 创建 50-100ms)、隔离彻底。适合 Postgres。方案二:每测试一个 DB——整个 database 重建。最慢(几秒)、最彻底。只适合 schema 变动相关的测试。方案三:同一 schema + 事务回滚——测试内 BEGIN、测试体在事务里、最后 ROLLBACK。最快(几 ms)、数据彻底清理。但限制是不能测跨事务的业务(比如 listen/notify)。

生产推荐方案三为主、方案一为辅——能走事务回滚就走、不能就建 schema。sqlx::test 宏原生支持方案三——#[sqlx::test] 标注的测试自动在事务里跑。自建 helper 也不难——TestServer 的 state 里包一层 "事务 guard"、drop 时 rollback。

Redis 用另一套——每测试一个 db_index(Redis 支持 16 个默认 db,启动时 --databases 64 扩到 64)。或者 key 前缀(test_<uuid>:)+ 测试结束扫描删除。

测试 async 取消

axum 的 handler 可能因客户端断开被取消——tokio::select! 的 dropped branch、或 serve 的 graceful shutdown。生产上这条路径会走但单元测试很难覆盖——oneshot 执行完整 future、不模拟取消。

专门测试要手写 tokio::select! 场景:

rust
#[tokio::test]
async fn handler_cleanup_on_cancel() {
    let state = setup_mock_state();
    let req = Request::builder().uri("/slow").body(Body::empty()).unwrap();
    let fut = build_app(state.clone()).oneshot(req);

    tokio::select! {
        _ = fut => panic!("should be cancelled"),
        _ = tokio::time::sleep(Duration::from_millis(10)) => {}
    }
    // 断言:handler drop 时释放了 DB connection、tracing span 关闭、计数器 -1
    assert_eq!(state.active_requests.load(Ordering::Relaxed), 0);
}

这种测试防止 handler 泄露资源——生产事故多次出在"客户端断了、handler 还在占着 DB connection"。注意 select! 里 fut 分支的 drop 语义——drop 时会级联 drop handler 内持有的所有 future 和 guard、触发 Rust 的 RAII 链。assert 语句读的是 drop 后的 state——验证清理完成。

本章 takeaway

八条核心规则——落到生产项目每天能用到:

  1. oneshot 是最便宜的测试——单 handler 的 unit test 首选
  2. axum-test 是更 ergonomic 的选择——集成测试和 session 测试方便
  3. state 抽象化让 mock 容易——Arc<dyn Trait> 是测试黄金 pattern
  4. build_app 函数共享生产和测试——避免 Router 组装代码重复
  5. 测试金字塔分层——快测试多、慢测试少——CI 才能在合理时间跑完
  6. mock 要配合期望验证——mockall 的 expect 语法比手写 mock 威力大
  7. CI 跑完整测试集——PR 见红必修——不让 flaky test 腐蚀 signal
  8. 测试代码和生产代码同等对待——review、重构、命名一样讲究

下一章是最后一章——生产实战:把前 21 章的所有机制汇总到"真实大型 axum 项目怎么组织"的视角。

基于 VitePress 构建