Testing in Practice
#[test] and cargo test are simple enough that most Rust developers pick them up in an afternoon. What's less obvious is how to keep tests organized as a codebase grows, how to test code that depends on a database or an external API without actually hitting one, and how to avoid copy-pasted setup code spreading across every test function. This tutorial skips the basics and goes straight to those patterns.
Unit Tests Live Next to the Code They Test
The idiomatic place for unit tests is a tests submodule inside the same file, guarded by #[cfg(test)] so it's compiled only when running tests, never in a release build.
fn discount(price: f64, percent: f64) -> f64 {
price * (1.0 - percent / 100.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn applies_percentage_discount() {
assert_eq!(discount(100.0, 25.0), 75.0);
}
#[test]
fn zero_percent_is_a_no_op() {
assert_eq!(discount(50.0, 0.0), 50.0);
}
}
use super::* pulls in everything from the parent module, including private items. This is the main reason unit tests live inside the crate rather than in a separate file: they can exercise private functions directly, not just the public API.
Integration Tests Exercise the Public API Only
Files under tests/ at the crate root are compiled as separate crates, each one only sees what your library exposes publicly. There's no #[cfg(test)] needed, everything in tests/ is test-only by virtue of its location.
my_crate/
├── src/
│ └── lib.rs
└── tests/
└── checkout_flow.rs
// tests/checkout_flow.rs
use my_crate::Cart;
#[test]
fn empty_cart_totals_zero() {
let cart = Cart::new();
assert_eq!(cart.total(), 0.0);
}
Use unit tests for internal logic and edge cases close to the implementation. Use integration tests to verify the crate behaves correctly from a caller's perspective, the same boundary your actual users interact with.
Sharing setup code across integration test files is the one wrinkle: each file in tests/ is its own crate, so a plain module won't be shared automatically. Put shared helpers in tests/common/mod.rs, the mod.rs filename tells Cargo not to treat it as its own test crate.
// tests/common/mod.rs
pub fn test_cart_with_items() -> my_crate::Cart {
let mut cart = my_crate::Cart::new();
cart.add_item("widget", 9.99);
cart
}
// tests/checkout_flow.rs
mod common;
#[test]
fn cart_with_items_has_nonzero_total() {
let cart = common::test_cart_with_items();
assert!(cart.total() > 0.0);
}
Mocking: Depend on a Trait, Not a Concrete Type
Rust has no built-in mocking framework, and there's no runtime magic that swaps an implementation out from under a concrete type the way some dynamic languages allow. The idiomatic substitute is to depend on a trait instead of a concrete struct, then hand the test a fake implementation.
trait UserRepository {
fn find_by_id(&self, id: u64) -> Option<User>;
}
struct PgUserRepository {
// holds a real database connection
}
impl UserRepository for PgUserRepository {
fn find_by_id(&self, id: u64) -> Option<User> {
// real query
todo!()
}
}
fn greet_user(repo: &dyn UserRepository, id: u64) -> String {
match repo.find_by_id(id) {
Some(user) => format!("hello, {}", user.name),
None => "unknown user".to_string(),
}
}
In tests, write a small fake that returns canned data instead of touching a real database:
#[cfg(test)]
mod tests {
use super::*;
struct FakeUserRepository {
user: Option<User>,
}
impl UserRepository for FakeUserRepository {
fn find_by_id(&self, _id: u64) -> Option<User> {
self.user.clone()
}
}
#[test]
fn greets_known_user() {
let repo = FakeUserRepository {
user: Some(User { name: "Ferris".into() }),
};
assert_eq!(greet_user(&repo, 1), "hello, Ferris");
}
#[test]
fn handles_unknown_user() {
let repo = FakeUserRepository { user: None };
assert_eq!(greet_user(&repo, 1), "unknown user");
}
}
This is the same dyn Trait vs <T: Trait> choice from Trait Objects vs Generics, function signatures that take &dyn UserRepository or a generic <R: UserRepository> are both testable this way; the trait boundary is what makes substitution possible, not the dispatch mechanism.
Hand-writing fakes works, but for traits with many methods, the mockall crate generates them for you:
#[cfg_attr(test, automock)]
trait UserRepository {
fn find_by_id(&self, id: u64) -> Option<User>;
}
#[test]
fn greets_known_user() {
let mut repo = MockUserRepository::new();
repo.expect_find_by_id()
.returning(|_| Some(User { name: "Ferris".into() }));
assert_eq!(greet_user(&repo, 1), "hello, Ferris");
}
#[automock] generates a MockUserRepository with .expect_*() builders for setting up return values and call expectations. Reach for it once hand-writing fakes for every trait starts to feel repetitive, for a one- or two-method trait, a hand-written fake is usually less ceremony.
Tests That Return Result
A test function can return Result<(), E> instead of panicking on failure, which means the ? operator works inside tests, the same pattern from Error Handling in Practice.
#[test]
fn parses_valid_config() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::parse("port = 8080")?;
assert_eq!(config.port, 8080);
Ok(())
}
This avoids .unwrap() littering test bodies that exercise fallible code, an Err returned from the test fails it with the error printed, same as an uncaught panic would.
For the inverse, asserting that something should panic, use #[should_panic], optionally with expected to check the panic message:
#[test]
#[should_panic(expected = "divide by zero")]
fn rejects_zero_denominator() {
divide(10, 0);
}
Test Builders: Avoiding Setup Duplication
Once a struct has more than a handful of fields, every test that needs one ends up copy-pasting the same construction boilerplate, with one field changed. A small builder dedicated to tests keeps that setup in one place.
#[cfg(test)]
struct UserBuilder {
name: String,
age: u32,
active: bool,
}
#[cfg(test)]
impl UserBuilder {
fn new() -> Self {
Self { name: "default".into(), age: 30, active: true }
}
fn name(mut self, name: &str) -> Self {
self.name = name.to_string();
self
}
fn inactive(mut self) -> Self {
self.active = false;
self
}
fn build(self) -> User {
User { name: self.name, age: self.age, active: self.active }
}
}
#[test]
fn inactive_users_are_excluded() {
let user = UserBuilder::new().name("Ferris").inactive().build();
assert!(!is_visible(&user));
}
Every test only specifies the fields it actually cares about; everything else comes from a sensible default. When the User struct grows a new field, only the builder needs updating, not every test that constructs one.
Key Takeaways
- Unit tests go in a
#[cfg(test)] mod testsnext to the code, withuse super::*to reach private items. Integration tests go intests/and only see the crate's public API. - Share helpers across integration test files via
tests/common/mod.rs, themod.rsname keeps Cargo from treating it as a standalone test crate. - Depend on traits, not concrete types, for anything you'll want to fake in tests (databases, HTTP clients, clocks). This is the entire mocking strategy in Rust, there's no dependency-injection framework needed.
- Hand-write fakes for small traits; reach for
mockall's#[automock]once a trait has enough methods that hand-writing every fake gets repetitive. - Tests can return
Result<(), E>to use?instead of.unwrap(); use#[should_panic]to assert that a panic is the expected behavior. - A test-only builder with sensible defaults keeps fixture setup in one place instead of duplicated across every test function.