Appearance
第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——TestRequest、Client、test_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:
- 构造 Router:和生产代码一样
- 构造 Request:用
http::Request::builder()或Request::new() app.oneshot(request).await:拿到 Response- 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 = Response、S::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 库的工作流:
- 第一次跑测试——insta 创建
snapshots/users__user_endpoint_snapshot.snap文件存 body - 后续跑——对比当前 body 和 snapshot
- 不一致——测试失败、显示 diff
- 如果变化是预期的——
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 support:
server.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 helperstests/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 descriptor:
ulimit -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:格式检查——防格式不一致 PRcargo clippy -D warnings:lint——把 warning 当 errorcargo 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_found 比 test_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::sleep 或 interval——#[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 |
|---|---|---|---|
| axum | tower + axum-test | ServiceExt::oneshot | axum-test |
| actix-web | 内建 | test::call_service | test::TestRequest |
| warp | 内建 | .reply(&request) | 直接 filter 调用 |
| rocket | 内建 | Client::tracked | client.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-run5. 分层运行:先跑 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 file:tests/users.rs、tests/orders.rs——按业务 feature 分。搜起来方便、影响面清晰。
二、共享 helper 放 common/mod.rs:test_state()、test_user()、login_as(...) 等——多文件复用。
三、长测试分拆:单个测试 > 50 行——考虑拆成 helper + 多个小测试。可读性提高。
四、命名描述行为:users_can_delete_own_posts 比 test_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 流程:
- 看 assertion 消息:
assert_eq!(a, b)失败显示两者值——对应 code 改 - 跑失败测试时加
--nocapture:看 println / tracing 输出 - 加
RUST_BACKTRACE=1:看 panic stack - 单独跑:
cargo test failing_test -- --exact——隔离环境 - debugger:
rust-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 previousPR 跑时对比前次 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 按需——不是每项目都装。从 mockall 和 rstest 开始——投入回报最高。
测试 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 == 201、test_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
八条核心规则——落到生产项目每天能用到:
- oneshot 是最便宜的测试——单 handler 的 unit test 首选
- axum-test 是更 ergonomic 的选择——集成测试和 session 测试方便
- state 抽象化让 mock 容易——
Arc<dyn Trait>是测试黄金 pattern - build_app 函数共享生产和测试——避免 Router 组装代码重复
- 测试金字塔分层——快测试多、慢测试少——CI 才能在合理时间跑完
- mock 要配合期望验证——mockall 的 expect 语法比手写 mock 威力大
- CI 跑完整测试集——PR 见红必修——不让 flaky test 腐蚀 signal
- 测试代码和生产代码同等对待——review、重构、命名一样讲究
下一章是最后一章——生产实战:把前 21 章的所有机制汇总到"真实大型 axum 项目怎么组织"的视角。