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 tests
  • use super::* – imports everything from the parent module
  • #[test] – marks a function as a test
  • assert_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

FeatureUnit TestsIntegration Tests
LocationSame file, #[cfg(test)]tests/ directory
ScopeCan test private functionsOnly public API
SpeedFast (in same compile unit)Slower (separate compilation)
Use forImplementation detailsPublic 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

ConceptSyntaxPurpose
Unit test#[test]Mark a function as a test
Test module#[cfg(test)]Only compile for testing
Equalityassert_eq!(a, b)Check two values are equal
Inequalityassert_ne!(a, b)Check two values differ
Booleanassert!(condition)Check a condition is true
Panic test#[should_panic]Test that code panics
Ignore#[ignore]Skip slow tests by default
Result testfn test() -> Result<>Use ? in tests
Integrationtests/ directoryTest public API
Doc test`/// ``` code ````Test documentation examples
Run testscargo testExecute all tests

Source Code

View source code on GitHub ->

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.