Test Organization
Organized tests scale with your codebase: colocate unit tests, share integration fixtures, and keep setup explicit so parallel cargo test stays reliable.
Recipe
// tests/common/mod.rs
pub fn test_app() -> axum::Router {
my_api::app_with_config(test_config())
}// tests/orders.rs
mod common;
#[tokio::test]
async fn creates_order() {
let app = common::test_app();
// ...
}When to reach for this: More than a handful of integration tests or repeated DB/API setup.
Working Example
tests/
├── common/
│ ├── mod.rs
│ └── db.rs
├── orders.rs
└── users.rs
// tests/common/mod.rs
pub mod db;
use sqlx::PgPool;
pub async fn pool() -> PgPool {
db::migrate_and_connect().await
}// tests/orders.rs
mod common;
#[sqlx::test]
async fn inserts_order(pool: sqlx::PgPool) {
let id = my_api::create_order(&pool, "sku-1").await.unwrap();
assert!(id > 0);
}What this demonstrates:
tests/common/mod.rsis not a test crate by itselfmod common;shares helpers across integration files#[sqlx::test]provides per-test DB setup- Async fixtures stay in async helpers
Deep Dive
Unit Test Layout
| Pattern | Use |
|---|---|
Inline mod tests | Small modules |
src/foo/tests.rs | Large foo.rs |
#[path = "tests/unit/foo_tests.rs"] | Rare split |
Builder Fixtures
pub struct OrderBuilder {
sku: String,
}
impl OrderBuilder {
pub fn new() -> Self {
Self { sku: "default".into() }
}
pub fn sku(mut self, s: &str) -> Self {
self.sku = s.into();
self
}
pub fn build(self) -> Order {
Order { sku: self.sku }
}
}- Builders reduce duplicated test setup
- Default valid object; override one field per test
- Keeps tests readable:
OrderBuilder::new().sku("x").build()
Gotchas
- Global static
OnceLockpool - tests pollute each other. Fix: transactions rolled back per test or#[serial_test::serial]. - tests/common.rs AND tests/common/mod.rs - confusing module resolution. Fix: pick one layout and document in README.
- Fixture panics - obscure which test failed setup. Fix: return
Resultfrom helpers; use?in tests. - Copy-paste HTTP client setup - drift across files. Fix:
test_app()helper with shared middleware stack. - Unit tests in
tests/- cannot access private API. Fix: move logic test tosrcor expose#[cfg(test)]helpers.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
test-log crate | Capture logs in tests | Simple pure functions |
rstest parameterized tests | Many input cases | One-off assertions |
Separate integration-tests crate | Workspace-wide E2E | Single package services |
FAQs
Where put test data files?
tests/fixtures/ or tests/data/; load with include_str! or std::fs from CARGO_MANIFEST_DIR.
How do I share Tokio runtime?
Use #[tokio::test] per test; avoid custom global runtime unless benchmarking.
Should helpers be pub?
Within tests/common, pub for cross-file use; not exported to production code.
How organize workspace E2E?
Dedicated workspace member e2e-tests depending on API + CLI crates.
What about feature-gated tests?
#[cfg(feature = "postgres")] on modules or cargo test --features postgres.
How do I name test modules?
mod tests for unit; integration files named by behavior orders.rs, not test_orders.rs.
Can I use traits for test doubles?
Yes in tests/common/mock.rs; see Mocking & Test Doubles.
How clean databases?
Truncate in #[sqlx::test] fixture or wrap each test in a transaction and rollback.
Parallel integration tests safe?
Only with isolated resources (unique ports, containers per test, or mutex around shared resource).
Document test layout?
CONTRIBUTING.md section: unit vs integration, how to run subsets, env vars for DB URL.
Related
- Unit vs Integration Tests - placement rules
- Mocking & Test Doubles - trait seams
- Testing Async Code - Tokio setup
- Coverage & CI - pipeline layout
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.