Rust Testing Guide: cargo test, Unit Tests, and Integration Tests
Rust ships with a built-in test framework. No extra dependencies, no configuration files — just annotate a function with #[test] and run cargo test. That simplicity is deceptive: the Rust test model covers unit tests, integration tests, doc tests, and benchmarks within a single coherent workflow.
This guide covers everything you need to write, organize, and run tests effectively in Rust.
The Basics: #[test] and cargo test
A Rust test is any function annotated with #[test]:
#[test]
fn it_works() {
let result = 2 + 2;
assert_eq!(result, 4);
}Run all tests with:
cargo testCargo compiles your tests and runs them in parallel by default. The output shows each test result, panics are caught per-test, and a summary is printed at the end.
Filtering Tests
Run only tests matching a pattern:
cargo test parsing <span class="hljs-comment"># runs all tests with "parsing" in their name
cargo <span class="hljs-built_in">test -- --test-thread=1 <span class="hljs-comment"># run tests sequentially
cargo <span class="hljs-built_in">test -- --nocapture <span class="hljs-comment"># print stdout/stderr from passing testsAssertion Macros
Rust's standard library provides the core assertion macros:
| Macro | Purpose |
|---|---|
assert!(expr) |
Panics if expr is false |
assert_eq!(left, right) |
Panics if values are not equal |
assert_ne!(left, right) |
Panics if values are equal |
Add a custom message with an optional format string:
assert_eq!(result, 42, "expected 42 but got {}", result);Organizing Unit Tests
Rust's convention is to put unit tests in the same file as the code being tested, inside a tests module with #[cfg(test)]:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_add_negatives() {
assert_eq!(add(-1, -1), -2);
}
}The #[cfg(test)] attribute tells Cargo to compile this module only during cargo test, not in release builds. The use super::* brings the parent module's items into scope.
Testing Private Functions
Because unit tests live in the same file, they can access private functions directly:
fn internal_helper(x: u32) -> u32 {
x * 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_internal_helper() {
assert_eq!(internal_helper(5), 10);
}
}This is one of the ergonomic advantages of Rust's test model compared to languages where private-function testing requires workarounds.
Integration Tests
Integration tests live in a tests/ directory at the crate root, alongside src/:
my_crate/
├── src/
│ └── lib.rs
└── tests/
├── api_test.rs
└── user_flow_test.rsEach file in tests/ is compiled as a separate crate that can only access your library's public API:
// tests/api_test.rs
use my_crate::Calculator;
#[test]
fn test_calculator_add() {
let calc = Calculator::new();
assert_eq!(calc.add(10, 5), 15);
}Run only integration tests:
cargo test --<span class="hljs-built_in">test api_testShared Test Utilities
If multiple integration test files need shared helpers, put them in a module under tests/:
tests/
├── common/
│ └── mod.rs # shared setup code
├── api_test.rs
└── user_flow_test.rs// tests/common/mod.rs
pub fn setup() -> String {
String::from("test-db-url")
}// tests/api_test.rs
mod common;
#[test]
fn test_with_setup() {
let db_url = common::setup();
// use db_url...
}Cargo won't compile common/mod.rs as a test file itself (it has no #[test] functions), so it acts purely as shared infrastructure.
Testing Expected Failures
Use #[should_panic] to assert that a test panics:
#[test]
#[should_panic(expected = "divide by zero")]
fn test_divide_by_zero() {
divide(10, 0);
}The expected parameter checks that the panic message contains the given string. Omit it to match any panic.
Tests That Return Result
Instead of panicking, tests can return Result<(), E>:
#[test]
fn test_parse_number() -> Result<(), std::num::ParseIntError> {
let n: i32 = "42".parse()?;
assert_eq!(n, 42);
Ok(())
}This allows using the ? operator inside tests, which is cleaner for error-heavy code paths.
Ignoring Tests
Mark a test with #[ignore] to skip it by default:
#[test]
#[ignore = "requires external database"]
fn test_database_connection() {
// ...
}Run ignored tests explicitly:
cargo test -- --ignored <span class="hljs-comment"># only ignored
cargo <span class="hljs-built_in">test -- --include-ignored <span class="hljs-comment"># all tests including ignoredBenchmark Tests
Basic benchmarks use the test crate (nightly only):
#![feature(test)]
extern crate test;
#[cfg(test)]
mod benchmarks {
use test::Bencher;
#[bench]
fn bench_sort_vec(b: &mut Bencher) {
b.iter(|| {
let mut v: Vec<i32> = (0..1000).rev().collect();
v.sort();
});
}
}For stable Rust benchmarking, use the Criterion crate instead (covered in a separate guide).
Doc Tests
Rust compiles and runs examples in doc comments as tests:
/// Adds two numbers.
///
/// # Examples
///
/// ```
/// let result = my_crate::add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}Run only doc tests:
cargo test --docDoc tests guarantee that your examples actually compile and produce the expected output, keeping documentation in sync with the code.
Test Configuration
Control parallelism and output via environment variables and flags:
# Limit to 4 parallel test threads
cargo <span class="hljs-built_in">test -- --test-threads=4
<span class="hljs-comment"># Show output for passing tests
cargo <span class="hljs-built_in">test -- --nocapture
<span class="hljs-comment"># Show test execution time
cargo <span class="hljs-built_in">test -- --report-timeCI Setup
A minimal GitHub Actions workflow for Rust tests:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --all-featuresAdd --all-features to test with every feature flag enabled. For workspace projects, use cargo test --workspace.
Structuring a Testable Rust Codebase
A few patterns that make Rust code easier to test:
Dependency injection via traits. Define interfaces as traits, pass them as generics or Box<dyn Trait>. Tests substitute a mock implementation.
Separate I/O from logic. Functions that only operate on data are trivial to test. Push file reads, network calls, and time-of-day lookups to the edges of your call graph.
Use #[cfg(test)] for test-only code. Constructors, factory methods, or Default impls that only make sense in tests belong inside #[cfg(test)] blocks to avoid bloating production binaries.
Connecting Tests to Monitoring
Unit and integration tests validate correctness at build time. For production behavior, HelpMeTest runs your test scenarios 24/7 against live endpoints — no source code access required, no infrastructure to manage.
Free plan: 10 tests, 5-minute monitoring intervals. Pro: $100/month for unlimited tests and parallel execution.
Summary
#[test]marks test functions;cargo testruns them all- Unit tests live in
#[cfg(test)] mod testsalongside the code they test - Integration tests live in
tests/and only access the public API #[should_panic],Result-returning tests, and#[ignore]cover edge cases- Doc tests keep examples in sync with the implementation
- Structure code with trait-based DI and pure functions to maximize testability