When you write code, how do you know it actually works? You might run it once, see it does what you expect, and call it a day. But software is fragile. A change here can break something over there, and you won’t know until a user finds the problem. That’s where testing comes in. In Rust, testing isn’t an afterthought; it’s a first-class feature of the language. The compiler catches a huge category of errors for you, but tests catch the logic errors the compiler can’t see. They give you the confidence to change and improve your code.
Let me show you some ways I write tests for my Rust projects. These aren’t just rules, but patterns I’ve picked up that make testing less of a chore and more of a helpful routine.
I always start by putting my tests right next to the code they’re checking. Rust makes this easy. In any .rs file, I can write my functions and then, at the bottom, create a special module just for tests. This module only gets compiled when I run cargo test. It keeps things tidy. I can look at a piece of code and immediately see how it’s supposed to be used and what its limits are.
// This is the real, production code.
pub fn calculate_discount(price: f64, percentage: u8) -> f64 {
if percentage > 100 {
panic!("Discount percentage cannot exceed 100%");
}
price * (percentage as f64 / 100.0)
}
// And here, living in the same file, are its tests.
#[cfg(test)]
mod tests {
use super::*; // This imports everything from the outer module.
#[test]
fn normal_discount_works() {
let result = calculate_discount(100.0, 20);
// I use assert_eq! to check if two things are equal.
assert_eq!(result, 20.0);
}
#[test]
#[should_panic(expected = "Discount percentage cannot exceed 100%")]
fn panic_on_invalid_discount() {
// This test is supposed to cause a panic. We're checking that our
// safety catch works.
calculate_discount(50.0, 120);
}
#[test]
fn zero_discount_gives_zero() {
let result = calculate_discount(999.99, 0);
// Sometimes you just need to know something is true.
assert!(result == 0.0);
}
}
This is my first line of defense. These are unit tests. They test one small unit of code in isolation. But my code doesn’t live in isolation. It has friends. Functions call other functions, modules use other modules. That’s where integration tests come in.
I put integration tests in a special directory called tests at the very top level of my project, next to src. Cargo knows to look here. Each file in tests is treated like a separate little program that uses my library. This is fantastic because it forces me to test my code the way a user would—only through the public interface I’ve exposed. I can’t cheat and test private functions here.
// File: /my_project/tests/api_tests.rs
// Note: 'my_crate' is the name of my project from Cargo.toml.
use my_crate::checkout::Cart;
#[test]
fn cart_starts_empty() {
let cart = Cart::new();
// I'm testing the public API: `new()` and `total_items()`.
assert_eq!(cart.total_items(), 0);
}
#[test]
fn adding_item_increases_total() {
let mut cart = Cart::new();
cart.add_item("Rust Programming Book", 1, 39.99);
assert_eq!(cart.total_items(), 1);
// I might also test the internal state is correct via a public getter.
assert!(cart.total_price() > 39.0);
}
Now, writing tests can get repetitive. I often find myself creating the same kind of dummy data for five different tests. A user named “Test User”, a database ID of 42, a mock configuration object. Duplication is the enemy. When I need to change that test data, I don’t want to hunt down twenty places in my test files.
So I use helpers. Some people call these test fixtures. I just think of them as setup functions. They package up the boring, repetitive creation logic so my tests can be clean and focused on the actual behavior I’m verifying.
// A helper function to create a standard, valid user for testing.
fn create_standard_user() -> User {
User {
id: 1001,
username: "tester_john".to_string(),
email: "[email protected]".to_string(),
is_active: true,
}
}
// Another helper for a more specific case.
fn create_admin_user() -> User {
let mut user = create_standard_user();
user.username = "admin_alpha".to_string();
user.role = UserRole::Administrator;
user
}
#[test]
fn active_user_can_login() {
let user = create_standard_user(); // Clean setup in one line.
let auth_service = AuthService::new();
assert!(auth_service.login(&user).is_ok());
}
#[test]
fn admin_user_has_extra_permissions() {
let user = create_admin_user(); // Re-use the standard setup, then tweak it.
let perms = user.get_permissions();
assert!(perms.can_manage_users);
}
My own imagination for test cases is limited. I might test with the numbers 1, 5, and 100, but what about -1, 0, or 300? What about a really long string, or an empty one? This is where property-based testing has been a game-changer for me. Instead of me writing specific examples, I describe a property that should always be true, and a tool generates hundreds of random inputs to check it.
I use the proptest crate for this. I tell it: “Here are the kinds of integers I want you to generate,” and it goes to town. It’s found so many weird edge cases I would never have thought of.
use proptest::prelude::*;
// This is our function to test.
fn sort_and_deduplicate(mut numbers: Vec<i32>) -> Vec<i32> {
numbers.sort();
numbers.dedup();
numbers
}
proptest! {
#[test]
// This test will run many times with random `input_vec`s.
fn sorting_and_deduplicating_works(input_vec: Vec<i32>) {
let result = sort_and_deduplicate(input_vec.clone());
// Property 1: The result should not be longer than the input.
prop_assert!(result.len() <= input_vec.len());
// Property 2: The result should be sorted.
for window in result.windows(2) {
prop_assert!(window[0] <= window[1]);
}
// Property 3: The result should have no consecutive duplicates.
// The `dedup()` method guarantees this.
for window in result.windows(2) {
prop_assert_ne!(window[0], window[1]);
}
}
}
One of the hardest things to test is code that talks to the outside world—a database, a web API, the filesystem. I don’t want my tests to fail because my Wi-Fi dropped, or because the test database is locked. I want to test my logic, not the weather on a server in another country.
The solution is mocking, and Rust’s trait system is perfect for it. I define what I need from the outside world as a trait. My real code uses a real implementation of that trait. My tests use a fake, predictable implementation.
// This trait defines my "contract" with an email service.
trait EmailSender {
fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), String>;
}
// The real, scary, production implementation.
struct SmtpEmailSender {
server_address: String,
}
impl EmailSender for SmtpEmailSender {
fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), String> {
// ... Actual network calls here. Could fail!
println!("[REAL SMTP] Sending to {}: {}", to, subject);
Ok(())
}
}
// The peaceful, predictable, test double.
struct MockEmailSender {
last_message_captured: Option<String>,
}
impl MockEmailSender {
fn new() -> Self {
Self { last_message_captured: None }
}
}
impl EmailSender for MockEmailSender {
fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), String> {
// Just capture the data. No network, no fuss.
let message = format!("To: {}, Subject: {}, Body: {}", to, subject, body);
// In a real mock struct, you'd use interior mutability (like a RefCell)
// to capture this. For simplicity, we'll just print it.
println!("[MOCK] Would send: {}", message);
Ok(())
}
}
// My business logic only depends on the trait.
fn send_welcome_email(sender: &impl EmailSender, user_email: &str) {
let _ = sender.send(
user_email,
"Welcome!",
"Thanks for joining our service.",
);
}
// In my main function, I'd use the real one.
// In my test, I use the mock.
#[test]
fn welcome_email_invokes_sender() {
let mock_sender = MockEmailSender::new();
send_welcome_email(&mock_sender, "[email protected]");
// Here I could assert that the mock recorded the correct call.
// For example, I might check `mock_sender.last_message_captured` contains "Welcome!".
}
Testing isn’t just about whether the code is correct; it’s also about whether it’s fast enough. Performance regressions creep in silently. A small change in a data structure or an algorithm can make a function ten times slower. I use benchmarks to guard against this. The built-in test harness has basic benchmark support, but I prefer the criterion crate. It gives me detailed statistics and beautiful charts.
// This goes in a file like `benches/my_benchmark.rs`
use criterion::{criterion_group, criterion_main, Criterion};
use my_project::data_processor::expensive_calculation;
fn benchmark_expensive_func(c: &mut Criterion) {
let test_data = vec![1.0, 2.5, 3.7, 4.1, 5.9]; // Setup data once.
c.bench_function("expensive_calculation", |b| {
// `b` is a Bencher. Its `iter` method runs the closure many times
// and measures how long it takes.
b.iter(|| {
// Note the `||`. This is a closure with no arguments.
// Criterion will call it repeatedly.
expensive_calculation(&test_data)
})
});
}
// Define a benchmark group and main function.
criterion_group!(benches, benchmark_expensive_func);
criterion_main!(benches);
I run this with cargo bench. Over time, criterion stores results and can tell me if my changes made things faster or slower. It’s like a speedometer for my code.
Good code handles failure gracefully. My functions should return clear, helpful errors, not just crash. So my tests must check that these error paths work. I make sure my functions return Result<T, E> and then write tests that expect the Err variant.
fn find_user_by_id(user_id: &str) -> Result<User, AppError> {
if user_id.is_empty() {
return Err(AppError::InvalidInput("User ID cannot be empty".to_string()));
}
if !user_id.starts_with("user_") {
return Err(AppError::InvalidInput("User ID has wrong format".to_string()));
}
// ... try to find the user ...
Ok(User { id: user_id.to_string() })
}
#[test]
fn empty_user_id_gives_error() {
let result = find_user_by_id("");
// First, assert that it's an Err.
assert!(result.is_err());
// Then, if you want to be precise, check the error kind.
if let Err(AppError::InvalidInput(msg)) = result {
assert!(msg.contains("cannot be empty"));
} else {
panic!("Expected an InvalidInput error");
}
}
#[test]
fn malformed_user_id_gives_error() {
assert!(find_user_by_id("customer_123").is_err());
}
#[test]
fn valid_user_id_works() {
assert!(find_user_by_id("user_abc123").is_ok());
}
Finally, as a project grows, I need to manage my tests. Some tests might be slow (like ones that read large files). I don’t want to run them every single time I save a file. Cargo gives me tools for this. I can tag tests with #[ignore] and run them separately. I can also control how tests are run—in parallel or one at a time—which is crucial if tests interact with a shared resource.
#[test]
fn fast_unit_test() {
// This runs with `cargo test`.
assert_eq!(1 + 1, 2);
}
#[test]
#[ignore = "Requires a special test database to be running"]
fn slow_integration_test() {
// This is ignored by default.
// Run it with `cargo test -- --ignored`
let result = query_large_database();
assert!(result.is_ok());
}
I can also add configuration to my Cargo.toml to make the test builds a bit faster, which is nice when you have a large suite.
[profile.test]
opt-level = 1 # A little optimization, but not as much as release mode.
Testing in Rust feels different. The compiler’s strictness means many bugs never make it to the test phase. The test runner is simple and fast. These patterns—unit tests beside code, integration in tests/, helpers for setup, property-based testing for coverage, mocking with traits, benchmarking for speed, testing errors explicitly, and managing the test suite—form a robust safety net. They turn testing from a burdensome requirement into a powerful tool that lets me move quickly and refactor fearlessly, knowing that if I break something, my tests will tell me immediately. It’s how I sleep well at night after pushing new code.