Testing Best Practices
Fast, deterministic, meaningful tests give Rust teams confidence to refactor without flaking CI or duplicating production incidents.
How to Use This List
- Apply when adding a crate or reviewing test gaps after an incident.
- Use as PR checklist for new features (unit + integration + docs where public).
- Pair with Coverage & CI for pipeline enforcement.
A - Structure
- Colocate unit tests in
#[cfg(test)] mod tests. Private API tested beside implementation. - Integration tests in
tests/per behavior area. One file per feature, not one megaf ile. - Extract library from binary for testability. Thin
main, fatlib. - Shared fixtures in
tests/common/. No copy-paste HTTP/DB setup. - Doctests on public API examples. Keep rustdoc honest (Doctests).
B - Determinism
- No real network or prod DB in default
cargo test. Mocks, fakes, or testcontainers behind#[ignore]. - Use
tokio::time::pauseinstead ofsleepin async tests. Faster, deterministic timing. - Isolate global state or use
serial_test. Parallel tests must not share mut statics. - Redact timestamps/UUIDs in snapshots. Stable golden files (Snapshot Testing).
- Seed property tests; commit
proptest-regressions. Reproducible failures.
C - Quality
- Assert behavior, not implementation details. Refactor-friendly tests.
- Table-driven cases for edge boundaries. Empty, max, unicode, error paths.
- Property tests for parsers and serializers. Roundtrip invariants.
- Trait seams for external I/O.
mockallor manual fakes (Mocking & Test Doubles). - Name tests
scenario_expected_outcome. Readablecargo testfilter names.
D - Performance and CI
- Run
cargo nextest run --workspacein CI. Faster than plain test on large repos. - Separate fast PR job from slow E2E. Keep feedback under minutes.
- Benchmark hot paths with Criterion in release. Not in every PR (Benchmarking with Criterion).
- llvm-cov on domain crates with ratchet threshold. Coverage informs, does not replace review.
- Fail CI on
clippy -D warningsbefore merge. Tests pass in same profile as developers.
E - Async and HTTP
-
#[tokio::test]with correct flavor.current_threadonly when!Sendrequired. - Axum tests via
ServiceExt::oneshot. No random ports in unit layer. -
#[sqlx::test]or rolled-back transactions for DB. No leftover rows between tests. - Timeout wrappers on external calls in integration tests. Fail hung tests quickly.
- Document env vars for optional integration (
DATABASE_URL). README for local E2E.
FAQs
Minimum tests for a new endpoint?
One happy path integration, one auth/validation failure, unit tests on extracted domain logic.
How many property tests?
A few strong invariants per parser/serializer; not every function needs proptest.
Ignore flaky tests?
Fix or quarantine with owner and deadline; never ignore indefinitely on main.
Test private functions?
Yes via unit tests in same module; prefer testing via public API when behavior is stable contract.
Mock or testcontainers?
Mock/fake for speed; testcontainers for SQL dialect quirks in nightly job.
Doctests in CI?
cargo test --doc --workspace in PR if docs are part of the API contract.
How test CLI?
assert_cmd or snapshot of --help; integration with temp dirs for file output.
When delete tests?
When behavior removed; do not keep tests for deleted features "just in case."
Test coverage on glue code?
Lower bar acceptable for thin wiring; high bar on domain and security modules.
Who owns test debt?
Team rotation; tie flaky test fixes to same sprint as feature work touching that area.
Related
- Testing Basics - getting started
- Unit vs Integration Tests - placement
- Coverage & CI - llvm-cov pipelines
- Linting in CI - quality gates
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+.