Skip to main content
Navigation
HomeTechnical ReferenceJournalGitHubGitHub
Sidebar — toggle document categories via the logo
Categories

Testing in Rust

Overview

Rust ships a first-class test runner built into cargo test. This reference covers the full testing lifecycle, using a two-tier approach that separates fast, mocked unit tests (no infrastructure) from feature-gated integration tests that exercise a real database and cache. It also covers Cargo feature flags, Cargo.toml test configuration, mocking, async tests, and other techniques worth knowing.

The core idea: keep the common path (unit tests) instant and hermetic, and isolate anything that needs real infrastructure behind a cargo feature flag so it only runs when explicitly requested.

Test tiers

Split the suite into two tiers, separated by an integration cargo feature:

TierScopeInfrastructureCommand
Unit testsServices, stores, modelsIn-memory fakes onlycargo test --lib
Integration testsSQL queries, DB seed/migrate helpersReal Postgres + Rediscargo test --features integration

Unit tests — mocked, no database or Redis

Business logic is tested against in-memory fakes, so the entire logic layer runs with zero infrastructure. Two common patterns:

  • A configurable in-memory database executor — e.g. store::dbx::MockDbx, a safe in-memory DbExecutor. Register canned responses with helpers like .with_one::<T>(), .with_optional::<T>(), .with_all::<T>(), and .with_execute() (served FIFO per row type).
  • An in-memory cache executor with real read / write / incr / delete semantics — e.g. cache::mock::MockChx.
# from api/:
cargo test --lib
# or from the repo root:
cargo test -p oxideauth --lib

Integration tests — real Postgres + Redis

The only tests that touch a real database are the SQL query tests and the DB seed/migration helpers, because the query layer (store/queries/*) is the sole place SQL is built and executed. Typical layout:

  • tests/queries/{batch,contains,count,crud,join}.rs — SQL query tests (kept out of the library).
  • src/dev/db.rs — reset / migrate / seed helpers.

These are gated behind the integration feature (declared in api/Cargo.toml) and require a running Postgres + Redis. They read URLs from a test config (e.g. postgres://test_user:password@localhost:5432/test_db, redis://127.0.0.1:6379) and migrate + seed the database once via a shared init helper.

# Full suite — unit + integration (requires Postgres + Redis):
cargo test -p oxideauth --features integration

# Compile-check the integration suite without running it:
cargo check -p oxideauth --tests --features integration

Feature flags & Cargo.toml setup

Declaring the feature

A feature flag is an optional, named set of dependencies and/or cfg toggles. Declaring an empty integration feature gives you a #[cfg(feature = "integration")] switch with no extra dependencies:

[features]
default = []
integration = [] # gate slow/real-infra tests behind this flag

You can also make a feature pull in dependencies that only integration tests need:

[features]
default = []
integration = ["dep:tokio", "dep:testcontainers"]

When a feature name matches an optional dependency (dep:foo), enabling the feature activates that dependency. Mark such dependencies optional = true:

[dependencies]
tokio = { version = "1", optional = true }
testcontainers = { version = "0.15", optional = true }

Gating tests with #[cfg(feature = ...)]

The integration entry point becomes a no-op unless the feature is enabled:

// tests/main.rs
#[cfg(feature = "integration")]
mod queries;

#[cfg(feature = "integration")]
fn main() {
// run the integration suite
}

With this, a plain cargo test compiles the integration file but runs nothing from it, while cargo test --features integration runs the real suite.

Dev-dependencies

Test-only dependencies (mock frameworks, tempfile, tokio's test macros, serde_json for fixtures) go in [dev-dependencies] so they never bloat the published crate:

[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util"] }
mockall = "0.12"
tempfile = "3"
serde_json = "1"
insta = "1"
proptest = "1"

[dev-dependencies] are available to both inline #[cfg(test)] modules and files in tests/.

Per-target test configuration

Control how each integration test binary is built and gated with explicit [[test]] sections:

[[test]]
name = "main"
path = "tests/main.rs"
required-features = ["integration"] # skip building unless the feature is on

required-features means cargo test silently skips the target when the feature is off (instead of compiling an empty harness). Combine this with #[cfg(feature = "integration")] in the file for belt-and-suspenders gating.

A complete example Cargo.toml

[package]
name = "oxideauth"
version = "0.1.0"
edition = "2021"

[features]
default = []
integration = []

[dependencies]
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"] }
redis = { version = "0.24", features = ["tokio-comp"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
mockall = "0.12"
tempfile = "3"

[[test]]
name = "main"
path = "tests/main.rs"
required-features = ["integration"]

Test organization & structure

Rust has two canonical test locations:

api/
src/ inline #[cfg(test)] mod tests — unit tests (mocked)
services/
account.rs # contains #[cfg(test)] mod tests { ... }
tests/ integration tests (each file = separate test binary)
main.rs #[cfg(feature = "integration")] mod queries; ← gates the suite
queries/
mod.rs declares the query test modules
batch.rs store::queries::batch
contains.rs store::queries::contains
count.rs store::queries::count
crud.rs store::queries::crud
join.rs store::queries::join

Inline unit tests

Inline tests live next to the code they test, inside a #[cfg(test)] module. #[cfg(test)] strips the module from production builds entirely:

// src/services/account.rs
pub fn get_balance(id: u64) -> Option<u64> {
// ... real implementation ...
Some(42)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_get_balance_found() {
assert_eq!(get_balance(1), Some(42));
}

#[test]
fn test_get_balance_missing() {
assert_eq!(get_balance(99), None);
}
}

Key assertions and attributes:

#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
assert!(true);
assert_ne!(1, 2);
}

#[test]
#[should_panic(expected = "boom")]
fn it_panics() {
panic!("boom");
}

#[test]
#[ignore] // skip unless -- --ignored is passed
fn slow_test() { /* ... */ }

Integration tests (tests/)

Each .rs file directly under tests/ compiles to its own test binary and imports your crate like an external user would (via the crate name). Use tests/<name>/mod.rs for shared modules (Rust does not support tests/foo.rs + tests/foo/bar.rs as one target).

Running tests

cargo test builds the library plus all test targets and runs them. Scope it precisely to keep the loop fast:

cargo test # run everything (lib + doc + integration targets)
cargo test --lib # unit tests only (inline #[cfg(test)])
cargo test --test main # a single integration test binary (tests/main.rs)
cargo test -p oxideauth # only one workspace member's tests
cargo test -p oxideauth --lib # that member's unit tests
cargo test -p oxideauth --features integration # enable the integration feature

Filtering to a single test

Cargo passes the filter to the test harness; match by (sub)string of the full test path:

# A single unit test (mocked, no DB):
cargo test -p oxideauth --lib store::stores::account::tests::test_create_get_ok

# A single query integration test (requires Postgres + Redis):
cargo test -p oxideauth --features integration --test main queries::crud::test_create_and_get_pass

Test harness flags (after --)

Everything after -- goes to the test binary, not cargo:

cargo test -- --nocapture # show println! output from tests
cargo test -- --test-threads=1 # run tests single-threaded (order-isolation / DB contention)
cargo test -- --ignored # run #[ignore]d tests only
cargo test -- --include-ignored # run both normal and ignored tests
cargo test -- --exact # exact (non-substring) test name match
cargo test -- --list # list tests without running them

Compile-check without running

cargo check verifies test code compiles without producing a binary — fast feedback in CI or before starting a DB:

cargo check --tests # type-check all test targets
cargo check --tests --features integration # include feature-gated targets
cargo check --all-targets # tests + examples + benches

Async tests

Async functions need a runtime. #[tokio::test] (or #[tokio::test(flavor = "multi_thread")]) wraps the test in a Tokio runtime:

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn test_create_and_get() {
let db = MockDbx::new().with_one::<User>(user()).with_execute();
let cache = MockChx::new();
let svc = AccountService::new(db, cache);

let created = svc.create(NewUser { name: "ada".into() }).await.unwrap();
assert_eq!(created.name, "ada");
}
}

Add the macros and rt-multi-thread features to tokio in [dev-dependencies] for the macro and multi-thread runtime. For sqlx and other pool-based code, #[sqlx::test] spins up an isolated test database per test.

Mocking

Two complementary approaches:

Hand-rolled in-memory fakes

For the data-access boundary, a fake that implements the same trait as the real thing is often clearer than a mocking framework:

// A configurable in-memory executor
let db = MockDbx::new()
.with_one::<User>(user()) // next query expecting one row gets `user()`
.with_optional::<User>(None) // next optional query returns None
.with_execute(); // next execute returns Ok(rows_affected)

Fakes keep tests hermetic and readable, and they naturally encode the FIFO response contract (.with_one / .with_optional / .with_all / .with_execute served in order per row type).

The mockall framework

mockall generates mocks from traits with expectations and call-count verification:

use mockall::{automock, predicate::*};

#[automock]
pub trait Notifier {
fn notify(&self, to: &str, msg: &str) -> Result<(), Error>;
}

#[test]
fn test_notify_is_called() {
let mut notifier = MockNotifier::new();
notifier
.expect_notify()
.with(eq("ops@example.com"), eq("deployed"))
.times(1)
.returning(|_, _| Ok(()));

let svc = DeployService::new(notifier);
svc.deploy().unwrap();
}

Prefer fakes for broad interfaces you control and mockall for tight behavioral contracts (calls, ordering, counts).

Test fixtures & helpers

  • Factories: fn user() -> User constructors keep test data consistent and terse.
  • tempfile: create isolated temp dirs/files for tests that touch the filesystem, auto-cleaned on drop.
  • once init: for expensive shared setup (DB migrate/seed), use std::sync::Once or a lazy_static/once_cell so the suite migrates and seeds once, not per test.
  • #[cfg(test)] helper modules: put mod test_utils under #[cfg(test)] so helpers never ship in the library.

Other testing techniques worth knowing

Doc tests

Code blocks in /// doc comments are compiled and run as tests — great for keeping examples honest:

/// Returns the sum of two numbers.
///
/// ```
/// let result = my_crate::add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
cargo test --doc # run only doc tests
cargo test --doc add # run a specific doc test

Snapshot testing (insta)

Snapshot tests record output to snapshots/*.snap and diff against it on later runs — ideal for serialized JSON, rendered templates, and error output:

#[test]
fn test_serialized_user() {
let user = User { id: 1, name: "ada".into() };
insta::assert_json_snapshot!(user);
}

Review/accept changes with cargo insta review (or set INSTA_UPDATE=always).

Property-based testing (proptest)

Instead of hand-picking inputs, state properties that must hold for arbitrary generated inputs:

use proptest::prelude::*;

proptest! {
#[test]
fn roundtrip_doesnt_crash(s in "[a-z]{1,32}") {
let parsed: String = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
prop_assert_eq!(s, parsed);
}
}

Code coverage

cargo llvm-cov # via cargo-llvm-cov: line/region coverage, HTML report
cargo tarpaulin # alternative coverage tool

Best practices

  • Keep unit tests hermetic. No network, no filesystem (use tempfile), no time-of-day — inject clocks and config instead of reading them.
  • Gate anything real behind a feature flag so cargo test stays instant and CI can run the DB-dependent tier separately.
  • One integration target per concern. Keep SQL query tests isolated (tests/queries/*) so failures localize to the layer that owns the SQL.
  • Fake at the boundary, not deep inside. Mock the data-access/executor trait, not every function.
  • Name tests for behavior, e.g. test_create_get_ok, test_get_missing_returns_none, not test_1.
  • Run formatting, clippy, and tests together in CI:
cargo fmt --check
cargo clippy -- -D warnings
cargo test --all-targets
cargo test --features integration # separate job, with DB + Redis services

Quick reference table

TaskCommand
Run all testscargo test
Unit tests onlycargo test --lib
One integration binarycargo test --test main
One workspace membercargo test -p oxideauth
With feature flagcargo test -p oxideauth --features integration
Single test (substring)cargo test --lib path::to::test
Show println! outputcargo test -- --nocapture
Run ignored testscargo test -- --ignored
Compile-check testscargo check --tests
Compile-check with featurescargo check --tests --features integration
Doc testscargo test --doc
Coveragecargo llvm-cov

See also