Testing in Rust
Rust has first-class support for testing built directly into the language and toolchain. No external testing framework required — you can write tests alongside your code, and cargo test handles the rest. In this lesson, you'll learn how to write meaningful tests that give you confidence your code works correctly.
Your First Test
Tests in Rust are functions annotated with #[test]. They live in the same file as your code, inside a module marked with #[cfg(test)]. The cfg(test) attribute tells the compiler to only include this module when running tests, keeping your production binary lean.
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn is_even(n: i32) -> bool {
n % 2 == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
assert_eq!(add(-1, 1), 0);
assert_eq!(add(0, 0), 0);
}
#[test]
fn test_is_even() {
assert!(is_even(4));
assert!(is_even(0));
assert!(!is_even(7));
}
}
fn main() {
println!("add(2, 3) = {}", add(2, 3));
println!("is_even(4) = {}", is_even(4));
}
The use super::* line imports everything from the parent module so your tests can access the functions being tested.
Assertion Macros
Rust provides three core assertion macros, each suited for different scenarios:
assert!(expr)— panics if the expression isfalseassert_eq!(left, right)— panics if the two values are not equal, showing both values on failureassert_ne!(left, right)— panics if the two values are equal
Prefer assert_eq! over assert!(a == b) — when a test fails, assert_eq! prints both values so you can immediately see what went wrong.
You can also attach a custom message to any assertion:
fn celsius_to_fahrenheit(c: f64) -> f64 {
c * 9.0 / 5.0 + 32.0
}
fn grade_label(score: u32) -> &'static str {
match score {
90..=100 => "A",
80..=89 => "B",
70..=79 => "C",
60..=69 => "D",
_ => "F",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_freezing_point() {
let result = celsius_to_fahrenheit(0.0);
assert_eq!(result, 32.0, "Freezing point should be 32°F, got {}", result);
}
#[test]
fn test_boiling_point() {
let result = celsius_to_fahrenheit(100.0);
assert_eq!(result, 212.0, "Boiling point should be 212°F, got {}", result);
}
#[test]
fn test_grade_boundaries() {
assert_eq!(grade_label(95), "A");
assert_eq!(grade_label(85), "B");
assert_eq!(grade_label(75), "C");
assert_eq!(grade_label(55), "F");
assert_ne!(grade_label(90), "B", "Score 90 should be an A, not a B");
}
}
fn main() {
println!("0°C = {}°F", celsius_to_fahrenheit(0.0));
println!("Score 85 = grade {}", grade_label(85));
}
Testing for Panics
Sometimes the correct behavior is to panic. Use #[should_panic] to assert that a function panics under specific conditions. You can be more precise by specifying an expected string that must appear in the panic message.
fn divide(a: f64, b: f64) -> f64 {
if b == 0.0 {
panic!("Cannot divide by zero!");
}
a / b
}
fn get_item(items: &[i32], index: usize) -> i32 {
if index >= items.len() {
panic!("Index {} out of bounds for length {}", index, items.len());
}
items[index]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normal_division() {
assert_eq!(divide(10.0, 2.0), 5.0);
}
#[test]
#[should_panic(expected = "Cannot divide by zero")]
fn test_divide_by_zero() {
divide(5.0, 0.0);
}
#[test]
#[should_panic(expected = "out of bounds")]
fn test_index_out_of_bounds() {
let items = vec![1, 2, 3];
get_item(&items, 10);
}
}
fn main() {
let result = divide(10.0, 2.0);
println!("10 / 2 = {}", result);
let items = vec![10, 20, 30];
println!("Item at index 1: {}", get_item(&items, 1));
}
Testing with Result
Tests can also return Result<(), E> instead of panicking. This lets you use the ? operator inside tests, making it easy to test code that returns Result values:
use std::num::ParseIntError;
fn parse_and_double(s: &str) -> Result<i32, ParseIntError> {
let n: i32 = s.trim().parse()?;
Ok(n * 2)
}
fn words_longer_than(text: &str, min_len: usize) -> Vec<&str> {
text.split_whitespace()
.filter(|word| word.len() > min_len)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_number() -> Result<(), String> {
let result = parse_and_double("21").map_err(|e| e.to_string())?;
assert_eq!(result, 42);
Ok(())
}
#[test]
fn test_parse_invalid_returns_error() {
let result = parse_and_double("not_a_number");
assert!(result.is_err(), "Expected an error for invalid input");
}
#[test]
fn test_words_longer_than() {
let text = "the quick brown fox jumps";
let long_words = words_longer_than(text, 3);
assert_eq!(long_words, vec!["quick", "brown", "jumps"]);
assert!(!long_words.contains(&"the"), "'the' is too short to be included");
assert!(!long_words.contains(&"fox"), "'fox' is too short to be included");
}
}
fn main() {
match parse_and_double("21") {
Ok(n) => println!("Doubled: {}", n),
Err(e) => println!("Error: {}", e),
}
let text = "the quick brown fox";
println!("Long words: {:?}", words_longer_than(text, 3));
}
Organizing Tests
As your codebase grows, keep tests close to the code they verify. A common pattern is a tests submodule at the bottom of each source file. For tests that verify how multiple modules work together, Rust supports integration tests in a dedicated tests/ directory at your project root — these are compiled as separate crates and test your public API.
Within a test module, you can use helper functions freely:
fn normalize_username(name: &str) -> String {
name.trim().to_lowercase().replace(' ', "_")
}
fn is_valid_username(name: &str) -> bool {
let normalized = normalize_username(name);
!normalized.is_empty()
&& normalized.len() >= 3
&& normalized.len() <= 20
&& normalized.chars().all(|c| c.is_alphanumeric() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
// A helper that creates test cases and checks them all
fn assert_valid(names: &[&str]) {
for name in names {
assert!(
is_valid_username(name),
"Expected '{}' to be valid",
name
);
}
}
fn assert_invalid(names: &[&str]) {
for name in names {
assert!(
!is_valid_username(name),
"Expected '{}' to be invalid",
name
);
}
}
#[test]
fn test_valid_usernames() {
assert_valid(&["alice", "bob_smith", "user123", " Alice "]);
}
#[test]
fn test_invalid_usernames() {
assert_invalid(&["ab", "", "this_username_is_way_too_long_for_our_system", "has spaces!"]);
}
#[test]
fn test_normalization() {
assert_eq!(normalize_username(" Alice Smith "), "alice_smith");
assert_eq!(normalize_username("BOB"), "bob");
}
}
fn main() {
let names = ["alice", " Bob Smith ", "ab", ""];
for name in &names {
let normalized = normalize_username(name);
let valid = is_valid_username(name);
println!("'{}' -> '{}' (valid: {})", name, normalized, valid);
}
}
Try It Yourself
Write a simple Calculator struct with add, subtract, multiply, and divide methods. Then write tests covering normal operation, edge cases (dividing by zero), and verify that your tests catch a bug if you deliberately break one of the operations.
#[derive(Default)]
struct Calculator {
history: Vec<String>,
}
impl Calculator {
fn new() -> Self {
Calculator { history: Vec::new() }
}
fn add(&mut self, a: f64, b: f64) -> f64 {
let result = a + b;
self.history.push(format!("{} + {} = {}", a, b, result));
result
}
fn subtract(&mut self, a: f64, b: f64) -> f64 {
let result = a - b;
self.history.push(format!("{} - {} = {}", a, b, result));
result
}
fn multiply(&mut self, a: f64, b: f64) -> f64 {
let result = a * b;
self.history.push(format!("{} * {} = {}", a, b, result));
result
}
fn divide(&mut self, a: f64, b: f64) -> Option<f64> {
if b == 0.0 {
return None;
}
let result = a / b;
self.history.push(format!("{} / {} = {}", a, b, result));
Some(result)
}
fn history_count(&self) -> usize {
self.history.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_operations() {
let mut calc = Calculator::new();
assert_eq!(calc.add(3.0, 4.0), 7.0);
assert_eq!(calc.subtract(10.0, 3.0), 7.0);
assert_eq!(calc.multiply(3.0, 4.0), 12.0);
assert_eq!(calc.divide(12.0, 4.0), Some(3.0));
}
#[test]
fn test_divide_by_zero_returns_none() {
let mut calc = Calculator::new();
assert_eq!(calc.divide(5.0, 0.0), None);
}
#[test]
fn test_history_tracks_operations() {
let mut calc = Calculator::new();
calc.add(1.0, 2.0);
calc.multiply(3.0, 4.0);
assert_eq!(calc.history_count(), 2);
// divide by zero should NOT add to history
calc.divide(5.0, 0.0);
assert_eq!(calc.history_count(), 2);
}
#[test]
fn test_negative_numbers() {
let mut calc = Calculator::new();
assert_eq!(calc.add(-5.0, 3.0), -2.0);
assert_eq!(calc.multiply(-3.0, -4.0), 12.0);
}
}
fn main() {
let mut calc = Calculator::new();
println!("3 + 4 = {}", calc.add(3.0, 4.0));
println!("10 - 3 = {}", calc.subtract(10.0, 3.0));
println!("3 * 4 = {}", calc.multiply(3.0, 4.0));
match calc.divide(12.0, 0.0) {
Some(result) => println!("12 / 0 = {}", result),
None => println!("12 / 0 = undefined (division by zero)"),
}
println!("Operations recorded: {}", calc.history_count());
}
Key Takeaways
- Annotate test functions with
#[test]and wrap them in a#[cfg(test)]module so they are excluded from release builds - Use
assert_eq!andassert_ne!over bareassert!when comparing values — they print both sides on failure, making debugging faster - Add custom messages to assertions with a format string argument:
assert_eq!(a, b, "Expected {} but got {}", b, a) - Use
#[should_panic(expected = "...")]to assert that panics occur and include the right message - Tests can return
Result<(), E>to use the?operator, keeping test code clean when working with fallible functions - Extract shared setup into helper functions within the test module to keep individual tests focused and avoid repetition
Pro Tip: Run
cargo test -- --nocaptureto seeprintln!output from your tests even when they pass. By default, Rust captures and hides output from passing tests. This flag is invaluable when you're debugging a subtle failure and want to trace what's happening inside a test without changing it toeprintln!.
Next Steps
With testing skills in place, you're ready to work with the file system. Next, we'll learn how to read, write, and manipulate files using Rust's standard library.
Next lesson
File I/O
Learn how to read from and write to files in Rust using std::fs and std::io traits
25 min