Unit & Integration Tests
Rust has first-class testing built into the language and build system. No test framework to install, no configuration files to write. Put #[test] above a function, run cargo test, and you are testing. The convention of placing tests next to the code they test — in the same file — means tests stay synchronized with the implementation.
#[test] & #[cfg(test)]
Unit tests live in a #[cfg(test)] module inside the source file:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err("division by zero".into())
} else {
Ok(a / b)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_add_negative() {
assert_eq!(add(-1, 1), 0);
}
#[test]
fn test_divide() {
let result = divide(10.0, 3.0).unwrap();
assert!((result - 3.333).abs() < 0.01);
}
#[test]
fn test_divide_by_zero() {
let result = divide(10.0, 0.0);
assert!(result.is_err());
}
}
$ cargo test
running 4 tests
test tests::test_add ... ok
test tests::test_add_negative ... ok
test tests::test_divide ... ok
test tests::test_divide_by_zero ... ok
test result: ok. 4 passed; 0 failed; 0 ignored
#[cfg(test)] ensures the test module is only compiled when running tests. It adds zero overhead to your release binary.
Assert Macros
Rust provides three core assertion macros:
#[cfg(test)]
mod tests {
#[test]
fn assert_examples() {
// Boolean assertion
assert!(2 + 2 == 4);
// Equality
assert_eq!(vec![1, 2, 3].len(), 3);
// Inequality
assert_ne!("hello", "world");
// Custom messages
let status = 404;
assert_eq!(status, 200, "Expected 200 OK, got {}", status);
}
}
Custom messages are invaluable for debugging test failures. Always add them when the assertion is not self-explanatory.
Testing Private Functions
Unlike most languages, Rust lets you test private functions directly. The test module is a child of the module it tests, so it has access to everything:
fn internal_hash(input: &str) -> u64 {
// Private function — not pub
input.bytes().fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64))
}
pub fn is_valid_token(token: &str) -> bool {
internal_hash(token) % 7 == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_internal_hash() {
// We CAN test the private function
let hash = internal_hash("hello");
assert!(hash > 0);
}
#[test]
fn test_hash_deterministic() {
assert_eq!(internal_hash("test"), internal_hash("test"));
}
#[test]
fn test_is_valid_token() {
// Also test the public interface
let result = is_valid_token("some-token");
// We just verify it returns without panicking
assert!(result || !result);
}
}
Whether you should test private functions is a design question. If a private function has complex logic, test it. If it is a trivial helper, test it through the public API.
#[should_panic] for Expected Failures
When a function should panic under certain conditions, use #[should_panic]:
pub struct NonEmptyVec<T> {
inner: Vec<T>,
}
impl<T> NonEmptyVec<T> {
pub fn new(first: T) -> Self {
NonEmptyVec { inner: vec![first] }
}
pub fn first(&self) -> &T {
&self.inner[0]
}
pub fn push(&mut self, item: T) {
self.inner.push(item);
}
pub fn remove(&mut self, index: usize) -> T {
if self.inner.len() == 1 {
panic!("cannot remove last element from NonEmptyVec");
}
self.inner.remove(index)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "cannot remove last element")]
fn test_remove_last_panics() {
let mut v = NonEmptyVec::new(42);
v.remove(0); // should panic
}
#[test]
fn test_remove_non_last() {
let mut v = NonEmptyVec::new(1);
v.push(2);
let removed = v.remove(0);
assert_eq!(removed, 1);
assert_eq!(*v.first(), 2);
}
}
The expected parameter matches against the panic message. Use it to ensure the right panic fires, not an unrelated one.
Integration Tests
Integration tests live in a tests/ directory at the crate root. Each file is compiled as a separate crate that can only access your crate's public API:
my-crate/
src/
lib.rs
tests/
api_tests.rs
parsing_tests.rs
// tests/api_tests.rs
use my_crate::{User, UserStore};
#[test]
fn test_create_and_retrieve_user() {
let mut store = UserStore::new();
store.add(User::new(1, "Alice".into()));
let user = store.get(1).unwrap();
assert_eq!(user.name, "Alice");
}
#[test]
fn test_missing_user_returns_none() {
let store = UserStore::new();
assert!(store.get(999).is_none());
}
Integration tests verify that your public API works correctly as a consumer would use it. They catch issues that unit tests miss: visibility problems, broken re-exports, incorrect public signatures.
Test Organization
A well-organized test suite uses both unit and integration tests:
src/
lib.rs # pub mod declarations
parser.rs # unit tests at bottom of file
validator.rs # unit tests at bottom of file
store.rs # unit tests at bottom of file
tests/
smoke_tests.rs # high-level integration tests
parser_edge_cases.rs # focused integration tests
Shared test utilities go in tests/common/mod.rs. Other integration test files import them with mod common;.
Running Tests
cargo test # run all tests
cargo test test_parse # run tests matching "test_parse"
cargo test --lib # unit tests only
cargo test --test api_tests # specific integration test file
cargo test -- --nocapture # show println! output
cargo test -- --test-threads=1 # run tests sequentially
cargo test -- --ignored # run only #[ignore] tests
#[ignore] for Slow Tests
Mark slow or expensive tests with #[ignore] so they do not run by default:
#[test]
#[ignore]
fn test_large_dataset_processing() {
let data = generate_large_dataset(1_000_000);
let result = process(data);
assert!(result.len() > 0);
}
Run ignored tests explicitly with cargo test -- --ignored or cargo test -- --include-ignored to run everything.
Returning Result from Tests
Tests can return Result for cleaner error handling:
#[test]
fn test_parsing() -> Result<(), Box<dyn std::error::Error>> {
let config = parse_config("port = 8080")?;
assert_eq!(config.port, 8080);
Ok(())
}
This avoids .unwrap() chains and produces better error messages on failure.
Common Pitfalls
- Tests that depend on each other — tests run in parallel by default. If test A writes to a file and test B reads it, they will flake. Each test must set up its own state.
- Testing implementation instead of behavior — testing that an internal HashMap has exactly 3 entries is fragile. Test the observable behavior: "after adding 3 items, count returns 3."
- Ignoring
#[cfg(test)]— without this attribute, test utilities compile into your production binary. Always wrap the test module. - Not testing error paths — the happy path is easy to test. The error paths are where bugs live. Test every
Errvariant your functions can return. - Over-mocking in unit tests — if you mock everything, your test verifies the mock, not the code. Start with real implementations; mock only external dependencies.
- Flaky tests from timing — tests that depend on
sleepor wall-clock time break under load. Use injected clocks or channels for synchronization instead.
Key Takeaways
#[test]functions in#[cfg(test)]modules are unit tests. Files intests/are integration tests.- Rust lets you test private functions from the test module — use this for complex internal logic.
#[should_panic]verifies expected panics. Use theexpectedparameter to match the message.- Integration tests can only access your public API, making them ideal for verifying the consumer experience.
- Tests run in parallel by default. Each test must be independent and set up its own state.
- Return
Resultfrom tests for cleaner error handling and better failure messages.