In the previous tutorial, we learned modules and project organization. Now we learn testing – one of Rust’s best features.
Rust has testing built into the language and toolchain. You do not need to install a separate testing framework. Write #[test], run cargo test, and you are done. The compiler and test runner handle everything.
Good tests give you confidence to refactor code, add features, and fix bugs without breaking existing functionality.
Your First Test
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);
}
}
Run it:
cargo test
Output:
running 1 test
test tests::test_add ... ok
test result: ok. 1 passed; 0 failed; 0 ignored
Key parts:
#[cfg(test)]– this module is only compiled when running testsuse super::*– imports everything from the parent module#[test]– marks a function as a testassert_eq!– checks that two values are equal
Assert Macros
Rust provides three main assert macros.
assert_eq! – Check Equality
#[test]
fn test_equality() {
assert_eq!(add(2, 3), 5);
assert_eq!("hello".to_uppercase(), "HELLO");
}
When it fails, it shows both values:
left: 6
right: 5
assert_ne! – Check Inequality
#[test]
fn test_not_equal() {
assert_ne!(add(2, 3), 6);
}
assert! – Check Boolean Condition
#[test]
fn test_boolean() {
assert!(add(2, 3) > 0);
assert!(!vec![1, 2, 3].is_empty());
}
Custom Failure Messages
All assert macros accept an optional message:
#[test]
fn test_with_message() {
let result = add(2, 3);
assert_eq!(
result, 5,
"Expected 2 + 3 to equal 5, but got {}",
result
);
}
Custom messages make it easier to understand what went wrong when a test fails.
Testing Option and Result
Testing Option
fn divide(a: f64, b: f64) -> Option<f64> {
if b == 0.0 { None } else { Some(a / b) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_divide_some() {
let result = divide(10.0, 3.0);
assert!(result.is_some());
let value = result.unwrap();
assert!((value - 3.333).abs() < 0.01);
}
#[test]
fn test_divide_by_zero() {
assert!(divide(10.0, 0.0).is_none());
}
}
For floating-point comparisons, check that the difference is within a small tolerance. Do not use assert_eq! with floats.
Testing Result
fn parse_number(s: &str) -> Result<i32, String> {
s.parse::<i32>().map_err(|e| format!("Parse error: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ok() {
let result = parse_number("42");
assert!(result.is_ok());
assert_eq!(result.unwrap(), 42);
}
#[test]
fn test_parse_err() {
let result = parse_number("abc");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("Parse error"));
}
}
Result-Returning Tests
Tests can return Result instead of using unwrap():
#[test]
fn test_parse_returns_result() -> Result<(), String> {
let value = parse_number("42")?;
assert_eq!(value, 42);
Ok(())
}
If parse_number returns Err, the ? operator causes the test to fail with the error message. This is cleaner than .unwrap() for functions that return Result.
Testing Panics with should_panic
Some functions are supposed to panic. Test them with #[should_panic]:
fn assert_positive(value: i32) -> i32 {
if value < 0 {
panic!("Value must be positive, got {}", value);
}
value
}
#[test]
#[should_panic]
fn test_panics() {
assert_positive(-1);
}
Check the Panic Message
Add expected to verify the panic message contains specific text:
#[test]
#[should_panic(expected = "Value must be positive")]
fn test_panic_message() {
assert_positive(-5);
}
The test passes only if the panic message contains “Value must be positive”. This prevents false positives from unrelated panics.
Ignoring Tests with ignore
Mark slow or resource-heavy tests with #[ignore]:
#[test]
#[ignore]
fn test_slow_operation() {
std::thread::sleep(std::time::Duration::from_secs(10));
assert!(true);
}
Ignored tests are skipped by default:
cargo test # Skips ignored tests
cargo test -- --ignored # Runs ONLY ignored tests
cargo test -- --include-ignored # Runs ALL tests including ignored
Testing Structs
Test constructors, methods, and edge cases:
#[derive(Debug, PartialEq, Clone)]
struct User {
name: String,
age: u32,
}
impl User {
fn new(name: &str, age: u32) -> Result<Self, String> {
if name.trim().is_empty() {
return Err("Name cannot be empty".to_string());
}
if age > 150 {
return Err("Age must be 150 or less".to_string());
}
Ok(Self {
name: name.trim().to_string(),
age,
})
}
fn is_adult(&self) -> bool {
self.age >= 18
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_creation() {
let user = User::new("Alex", 25).unwrap();
assert_eq!(user.name, "Alex");
assert_eq!(user.age, 25);
}
#[test]
fn test_user_empty_name() {
let result = User::new("", 25);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Name cannot be empty");
}
#[test]
fn test_user_name_trimmed() {
let user = User::new(" Alex ", 25).unwrap();
assert_eq!(user.name, "Alex");
}
#[test]
fn test_user_invalid_age() {
let result = User::new("Alex", 200);
assert!(result.is_err());
}
#[test]
fn test_user_is_adult() {
assert!(User::new("Alex", 18).unwrap().is_adult());
assert!(!User::new("Sam", 17).unwrap().is_adult());
}
#[test]
fn test_user_equality() {
let user1 = User::new("Alex", 25).unwrap();
let user2 = User::new("Alex", 25).unwrap();
assert_eq!(user1, user2);
}
}
Test Helper Functions
Create helper functions to reduce repetition:
#[cfg(test)]
mod tests {
use super::*;
fn create_test_user() -> User {
User::new("Test User", 25).unwrap()
}
fn create_test_users(count: usize) -> Vec<User> {
(0..count)
.map(|i| User::new(&format!("User {}", i), 20 + i as u32).unwrap())
.collect()
}
#[test]
fn test_with_helper() {
let user = create_test_user();
assert_eq!(user.name, "Test User");
}
#[test]
fn test_with_multiple_users() {
let users = create_test_users(3);
assert_eq!(users.len(), 3);
assert_eq!(users[0].name, "User 0");
}
}
Helper functions are regular functions inside the test module. They do not need #[test].
Integration Tests
Integration tests live in the tests/ directory at the project root. They test your public API from the outside:
my-project/
├── src/
│ ├── lib.rs
│ └── ...
├── tests/
│ └── integration_test.rs
└── Cargo.toml
Writing Integration Tests
// tests/integration_test.rs
use my_project::math;
use my_project::User;
#[test]
fn test_math_operations() {
let sum = math::add(10, 20);
let product = math::multiply(sum, 2);
assert_eq!(product, 60);
}
#[test]
fn test_user_workflow() {
let user = User::new("Alex", 25).unwrap();
assert!(user.is_adult());
assert_eq!(user.name, "Alex");
}
Key differences from unit tests:
- No
#[cfg(test)]needed – the whole file is a test - No
use super::*– import from your crate as an external user would - Can only test public API
- Each file in
tests/is compiled as a separate crate
Running Integration Tests
cargo test # Run all tests
cargo test --test integration_test # Run one test file
cargo test --lib # Run only unit tests
Unit Tests vs Integration Tests
| Feature | Unit Tests | Integration Tests |
|---|---|---|
| Location | Same file, #[cfg(test)] | tests/ directory |
| Scope | Can test private functions | Only public API |
| Speed | Fast (in same compile unit) | Slower (separate compilation) |
| Use for | Implementation details | Public API contracts |
Unit tests – test individual functions, edge cases, error paths, private helpers.
Integration tests – test how modules work together, test the public API, test workflows that span multiple components.
Doc Tests
Rust can run code examples in documentation as tests:
/// Adds two numbers.
///
/// # Examples
///
/// ```
/// use my_project::math;
/// assert_eq!(math::add(2, 3), 5);
/// ```
pub fn add(a: i64, b: i64) -> i64 {
a + b
}
Run doc tests with:
cargo test --doc
Doc tests serve double duty: they test your code AND provide working examples in your documentation. If the code changes and the example breaks, the test fails.
Hiding Setup Code
Use # to hide lines from the documentation but still run them:
/// Gets the first item.
///
/// ```
/// # let items = vec![1, 2, 3];
/// let first = items.first();
/// assert_eq!(first, Some(&1));
/// ```
Lines starting with # are executed but not shown in the generated docs.
Test Organization Best Practices
1. Test the Happy Path and Edge Cases
#[test]
fn test_divide_normal() {
assert_eq!(divide(10.0, 2.0), Some(5.0));
}
#[test]
fn test_divide_by_zero() {
assert_eq!(divide(10.0, 0.0), None);
}
#[test]
fn test_divide_negative() {
assert_eq!(divide(-10.0, 2.0), Some(-5.0));
}
2. One Assertion Per Test (When Possible)
// Focused — easy to identify what broke
#[test]
fn test_user_name_is_trimmed() {
let user = User::new(" Alex ", 25).unwrap();
assert_eq!(user.name, "Alex");
}
#[test]
fn test_user_age_is_set() {
let user = User::new("Alex", 25).unwrap();
assert_eq!(user.age, 25);
}
3. Test Names Describe the Behavior
// Good names — describe what is being tested
#[test]
fn test_empty_name_returns_error() { /* ... */ }
#[test]
fn test_age_over_150_returns_error() { /* ... */ }
#[test]
fn test_name_whitespace_is_trimmed() { /* ... */ }
// Bad names — too vague
// fn test1() { ... }
// fn test_user() { ... }
4. Use Test Helpers for Setup
fn setup_database() -> Database {
let db = Database::new_in_memory();
db.run_migrations();
db.seed_test_data();
db
}
#[test]
fn test_find_user_by_id() {
let db = setup_database();
let user = db.find_user(1).unwrap();
assert_eq!(user.name, "Test User");
}
Running Tests
# Run all tests
cargo test
# Run tests matching a pattern
cargo test test_user # Runs all tests containing "test_user"
cargo test math:: # Runs tests in the math module
# Show output from passing tests
cargo test -- --show-output
# Run tests in a single thread (useful for tests that share state)
cargo test -- --test-threads=1
# Run ignored tests
cargo test -- --ignored
# Run only library tests
cargo test --lib
Common Mistakes
Mistake 1: Not Testing Error Cases
// INCOMPLETE: only tests the happy path
#[test]
fn test_parse() {
assert_eq!(parse_number("42").unwrap(), 42);
}
// COMPLETE: also tests the error case
#[test]
fn test_parse_invalid() {
assert!(parse_number("abc").is_err());
}
Mistake 2: Using unwrap() Without Checking the Value
// BAD: unwraps but does not check the value
#[test]
fn test_bad() {
let _result = parse_number("42").unwrap();
// Test passes even if the value is wrong
}
// GOOD: actually verify the result
#[test]
fn test_good() {
let result = parse_number("42").unwrap();
assert_eq!(result, 42);
}
Mistake 3: Tests That Depend on Each Other
// BAD: tests run in parallel — shared state causes random failures
// GOOD: each test sets up its own state
#[test]
fn test_independent_a() {
let data = create_fresh_data();
// Test with data
}
#[test]
fn test_independent_b() {
let data = create_fresh_data();
// Test with data
}
Tests run in parallel by default. Never depend on the execution order.
Summary
| Concept | Syntax | Purpose |
|---|---|---|
| Unit test | #[test] | Mark a function as a test |
| Test module | #[cfg(test)] | Only compile for testing |
| Equality | assert_eq!(a, b) | Check two values are equal |
| Inequality | assert_ne!(a, b) | Check two values differ |
| Boolean | assert!(condition) | Check a condition is true |
| Panic test | #[should_panic] | Test that code panics |
| Ignore | #[ignore] | Skip slow tests by default |
| Result test | fn test() -> Result<> | Use ? in tests |
| Integration | tests/ directory | Test public API |
| Doc test | `/// ``` code ```` | Test documentation examples |
| Run tests | cargo test | Execute all tests |
Source Code
Related Articles
- Rust Tutorial #17: Modules and Crates – previous tutorial
- Rust Tutorial #9: Traits – traits and derive macros
- Rust Tutorial Series – all tutorials
What’s Next?
We now know how to test Rust programs. Unit tests, integration tests, doc tests, and test organization – Rust makes all of these easy and built-in. Testing is not optional in Rust. The language encourages it at every step.
In the next tutorial, we will build on everything we have learned so far and start working on real projects – CLI tools, web APIs, and more. Stay tuned!
Continue with the Rust Tutorial Series for more tutorials.