Overview
Integration tests in Rust have a dedicated compilation model: every file in tests/
is built as its own crate linking your library like an external consumer — public
API only, by construction. Above that sit three workhorse techniques: driving the HTTP
stack in-process (a tower Router is callable without a socket), running real
dependencies in containers (testcontainers-rs, or sqlx's test-database macro), and
stubbing third-party HTTP with wiremock. Async is the main mechanical difference from
the neighbours: tests are #[tokio::test] functions and the usual async hazards
(leaked tasks, timeouts) apply.
Key points
tests/files are separate crates: public-API-only testing by construction, and each file links the library independently — group scenarios in fewer files (or atests/it/module tree) to keep link times sane; shared helpers live intests/common/mod.rsor a dev-dependency crate.#[tokio::test]wraps a test in a runtime; multi-thread flavor (#[tokio::test(flavor = "multi_thread")]) when the code under test spawns;tokio::time::pause()fast-forwards timers — retry/timeout logic tests run in microseconds.- In-process HTTP:
router.oneshot(Request::builder()...)(tower::ServiceExt) drives the full axum stack — routing, extractors, middleware — with no port, no flakiness; actix'stest::init_serviceis the same idea. Reserve a realTcpListener+ reqwest for the smoke layer. #[sqlx::test]is the relational sweet spot: creates a fresh database per test, runs migrations, hands the pool in as an argument, cleans up after — parallel-safe database tests with zero boilerplate (needs a running server; pair with a compose/CI service).- testcontainers-rs (
testcontainers+testcontainers-modules) starts Postgres/Redis/Kafka/anything per test or shared per binary — same substrate as the Java and Go stacks; the module catalog is smaller than Java's but covers the staples. - wiremock (wiremock-rs) stubs outbound HTTP: mount matchers on a local
MockServer, point the client's base URL at it, assert on received requests — the reqwest-era answer to Java's WireMock. - Segregation:
#[ignore]on the container-heavy shelf pluscargo nextest run -E 'not test(/slow/)'filtersets, or atests/e2e_*naming convention — keep the defaultcargo testfast.
Examples
#[tokio::test]
async fn creates_a_user() {
let app = build_router(test_state());
let res = app
.oneshot(Request::post("/users")
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"ada"}"#)).unwrap())
.await.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
}
#[sqlx::test(migrations = "./migrations")]
async fn persists_and_reads(pool: PgPool) -> sqlx::Result<()> {
users::insert(&pool, "ada").await?;
assert_eq!(users::count(&pool).await?, 1);
Ok(())
}
Related
- Testing in Rust — strategies & tooling map — parent map.
- Axum deep dive — the router-as-Service property these tests exploit.
- Relational databases from Rust — sqlx and the compile-time checking that shrinks this test layer.
- End-to-end testing — the next boundary out.
- Integration testing in Java and in Go — the same Testcontainers-shaped substrate on the neighbouring stacks.