Chapter 14: Testing in Rust

Rust has first-class testing support built directly into the language and toolchain. There’s no need for external test frameworks like xUnit, NUnit, or Google Test — cargo test works out of the box on every Rust project. This chapter covers how to write, organize, and run tests effectively.

1. Unit Tests — The Basics

The #[test] Attribute

Any function annotated with #[test] becomes a test case. A test passes if it runs without panicking.

#![allow(unused)]
fn main() {
#[test]
fn it_works() {
    let result = 2 + 2;
    assert_eq!(result, 4);
}

#[test]
fn greeting_contains_name() {
    let greeting = format!("Hello, {}!", "Alice");
    assert!(greeting.contains("Alice"));
}
}

Assertion Macros

Rust provides three core assertion macros:

MacroPurposePanics when
assert!(expr)Boolean checkexpr is false
assert_eq!(left, right)Equality checkleft != right
assert_ne!(left, right)Inequality checkleft == right

All three accept an optional custom message as additional arguments:

#![allow(unused)]
fn main() {
#[test]
fn test_with_messages() {
    let age = 17;
    assert!(age >= 18, "Expected adult, got age {}", age);

    let expected = 42;
    let actual = compute_answer();
    assert_eq!(actual, expected, "compute_answer() returned wrong value");
}

fn compute_answer() -> i32 { 42 }
}

Note: assert_eq! and assert_ne! require the compared types to implement both PartialEq and Debug. Most standard types do; for your own types, add #[derive(Debug, PartialEq)].

Comparison with C#/.NET

C# / .NETRustNotes
[TestClass]#[cfg(test)] mod testsTest module, compiled only during testing
[TestMethod]#[test]Marks a test function
Assert.AreEqual(a, b)assert_eq!(a, b)Prints both values on failure
Assert.IsTrue(x)assert!(x)Boolean assertion
Assert.ThrowsException<T>#[should_panic]Expects a panic
[ExpectedException]#[should_panic(expected = "msg")]Checks panic message
[Ignore]#[ignore]Skips test unless explicitly requested

The #[cfg(test)] Module

By convention, unit tests live in a tests module at the bottom of the same file, gated by #[cfg(test)]:

#![allow(unused)]
fn main() {
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

pub fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        return Err("Division by zero".to_string());
    }
    Ok(a / b)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
        assert_eq!(add(-1, 1), 0);
    }

    #[test]
    fn test_divide() {
        assert_eq!(divide(10.0, 2.0), Ok(5.0));
    }

    #[test]
    fn test_divide_by_zero() {
        assert!(divide(5.0, 0.0).is_err());
    }
}
}

The #[cfg(test)] attribute means this module is only compiled when running cargo test — it won’t bloat your release binary. The use super::*; import brings the parent module’s items into scope.

Testing Result<T, E> Returns

Test functions can return Result<(), E>, which lets you use ? instead of unwrap():

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use std::num::ParseIntError;

    #[test]
    fn test_parsing() -> Result<(), ParseIntError> {
        let value: i32 = "42".parse()?;
        assert_eq!(value, 42);
        Ok(())
    }
}
}

The test fails if the function returns Err. This is especially useful when your test involves multiple fallible operations.

#[should_panic]

Use #[should_panic] when you expect a function to panic. You can optionally check the panic message:

#![allow(unused)]
fn main() {
pub fn validate_age(age: i32) -> i32 {
    if age < 0 || age > 150 {
        panic!("Invalid age: {age}. Must be between 0 and 150.");
    }
    age
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn test_negative_age_panics() {
        validate_age(-1);
    }

    #[test]
    #[should_panic(expected = "Invalid age")]
    fn test_panic_message() {
        validate_age(200);
    }
}
}

2. Test Organization

Rust supports three kinds of tests, each with a different scope:

KindLocationCompiles asTests…
Unit testssrc/*.rs inside #[cfg(test)]Part of the cratePrivate + public API
Integration teststests/*.rs directorySeparate cratePublic API only
Doc tests/// comments in sourceSeparate compilationExamples in documentation

Unit Tests: Colocated with Code

Unit tests live inside the module they test. This is different from C# where test projects are always separate — in Rust, tests sit right next to the code they exercise:

#![allow(unused)]
fn main() {
// src/temperature.rs
pub struct Celsius(pub f64);
pub struct Fahrenheit(pub f64);

impl Celsius {
    pub fn to_fahrenheit(&self) -> Fahrenheit {
        Fahrenheit(self.0 * 9.0 / 5.0 + 32.0)
    }

    fn is_valid(&self) -> bool {
        self.0 >= -273.15
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn boiling_point() {
        let c = Celsius(100.0);
        let f = c.to_fahrenheit();
        assert!((f.0 - 212.0).abs() < f64::EPSILON);
    }

    #[test]
    fn can_test_private_functions() {
        // Rust allows unit tests to access private items!
        assert!(Celsius(20.0).is_valid());
        assert!(!Celsius(-300.0).is_valid());
    }
}
}

Integration Tests: The tests/ Directory

Files in a top-level tests/ directory are compiled as separate crates. They can only access your crate’s public API:

my_crate/
├── src/
│   └── lib.rs
├── tests/
│   ├── basic_operations.rs
│   └── edge_cases.rs
└── Cargo.toml
// tests/basic_operations.rs
use my_crate::{add, divide};

#[test]
fn test_add_from_outside() {
    assert_eq!(add(10, 20), 30);
}

#[test]
fn test_divide_from_outside() {
    assert!(divide(1.0, 0.0).is_err());
}

Each file in tests/ is a separate test binary. To share helper code between integration tests, put it in tests/common/mod.rs (the mod.rs naming prevents Cargo from treating the helper file itself as a test suite).

Doc Tests: Executable Examples

Code blocks inside /// documentation comments are compiled and run as tests:

#![allow(unused)]
fn main() {
/// Adds two numbers.
///
/// # Examples
///
/// ```
/// use my_crate::add;
/// assert_eq!(add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}
}

Doc tests serve double duty: they verify your examples are correct and provide documentation for users. If the example compiles but shouldn’t be run, use no_run. If it shouldn’t even compile (to show error examples), use compile_fail:

#![allow(unused)]
fn main() {
/// ```no_run
/// // This compiles but we don't want to run it in tests
/// std::process::exit(0);
/// ```
///
/// ```compile_fail
/// // This demonstrates a compile error
/// let x: i32 = "not a number";
/// ```
}

When to Use Which

QuestionAnswer
Testing private helper functions?Unit test — only they have access
Testing your public API as a consumer would?Integration test
Ensuring documentation examples stay correct?Doc test
Quick check of a single function?Unit test
End-to-end workflow across modules?Integration test

3. Running Tests

Basic Commands

# Run all tests (unit, integration, doc)
cargo test

# Run only unit tests (lib)
cargo test --lib

# Run only unit tests (bin)
cargo test --bins
cargo test --bin hello

# Run only integration tests
cargo test --test basic_operations
cargo test --test '*'

# Run only doc tests
cargo test --doc

# Run tests in a specific package (workspace)
cargo test -p my_crate

Filtering by Name

# Run tests whose name contains "divide"
cargo test divide

# Run tests in a specific module
cargo test temperature::tests

# Run a single, exact test
cargo test -- --exact test_add

Useful Flags

# Show println! output (normally captured on success)
cargo test -- --nocapture

# Run tests sequentially (default is parallel)
cargo test -- --test-threads=1

# Run only ignored tests
cargo test -- --ignored

# Run all tests including ignored
cargo test -- --include-ignored

# Show which tests are running (without running them)
cargo test -- --list

#[ignore] for Slow Tests

Mark expensive tests with #[ignore] so they don’t slow down your normal test runs:

#![allow(unused)]
fn main() {
#[test]
#[ignore = "requires network access"]
fn test_api_integration() {
    // This test calls an external API and takes seconds
    // Only runs with: cargo test -- --ignored
}
}

Cargo Nextest — A Faster Test Runner

cargo-nextest is a drop-in replacement for cargo test with better performance and output:

# Install
cargo install cargo-nextest

# Run all tests (same as cargo test, but faster and prettier)
cargo nextest run

# Filter by name
cargo nextest run divide

# List tests
cargo nextest list

Nextest runs each test as a separate process, providing better isolation and parallel performance. It also gives clearer output on failures.

4. Testing Patterns

Testing Private Functions

Unlike C# where you’d need [InternalsVisibleTo] or reflection to test private methods, Rust unit tests can test private functions directly — because they’re inside the same module:

#![allow(unused)]
fn main() {
fn internal_hash(data: &[u8]) -> u64 {
    // private implementation detail
    data.iter().fold(0u64, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as u64))
}

pub fn is_valid_hash(data: &[u8], expected: u64) -> bool {
    internal_hash(data) == expected
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_internal_hash_directly() {
        let hash = internal_hash(b"hello");
        assert_ne!(hash, 0);
        // Same input always produces same output
        assert_eq!(internal_hash(b"hello"), hash);
    }
}
}

Test Helpers and Setup

Rust doesn’t have [SetUp] / [TearDown] attributes. Instead, use regular helper functions or builder patterns:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    // Helper function — the Rust equivalent of [SetUp]
    fn setup_calculator() -> Calculator {
        Calculator::with_precision(2)
    }

    #[test]
    fn test_add() {
        let calc = setup_calculator();
        assert_eq!(calc.calculate(Operation::Add, 1.0, 2.0), Ok(3.0));
    }

    #[test]
    fn test_subtract() {
        let calc = setup_calculator();
        assert_eq!(calc.calculate(Operation::Subtract, 5.0, 3.0), Ok(2.0));
    }
}
}

Mocking with Traits

Rust doesn’t have a built-in mocking framework like Moq or Mockito. Instead, use trait-based dependency injection — define behavior behind a trait, then provide a mock implementation in tests:

#![allow(unused)]
fn main() {
// Define behavior as a trait
trait WeatherService {
    fn get_temperature(&self, city: &str) -> Result<f64, String>;
}

// Production implementation
struct RealWeatherService;

impl WeatherService for RealWeatherService {
    fn get_temperature(&self, city: &str) -> Result<f64, String> {
        // HTTP call to weather API...
        Ok(20.0)
    }
}

// Code under test depends on the trait, not the implementation
fn should_wear_jacket(service: &dyn WeatherService, city: &str) -> bool {
    match service.get_temperature(city) {
        Ok(temp) => temp < 15.0,
        Err(_) => true, // When in doubt, bring a jacket
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // Test mock — no framework needed
    struct MockWeatherService {
        temperature: Result<f64, String>,
    }

    impl WeatherService for MockWeatherService {
        fn get_temperature(&self, _city: &str) -> Result<f64, String> {
            self.temperature.clone()
        }
    }

    #[test]
    fn cold_weather_needs_jacket() {
        let service = MockWeatherService { temperature: Ok(5.0) };
        assert!(should_wear_jacket(&service, "Zurich"));
    }

    #[test]
    fn warm_weather_no_jacket() {
        let service = MockWeatherService { temperature: Ok(25.0) };
        assert!(!should_wear_jacket(&service, "Barcelona"));
    }

    #[test]
    fn error_means_jacket() {
        let service = MockWeatherService {
            temperature: Err("API down".to_string()),
        };
        assert!(should_wear_jacket(&service, "Unknown"));
    }
}
}

This pattern is idiomatic Rust and works without any external crate. In Day 4, the ESP32-C3 exercises use exactly this approach: a TemperatureSensorHal trait defines sensor behavior, with a real ESP32 implementation for hardware and a MockTemperatureSensor for desktop testing. For more complex mocking needs, crates like mockall can auto-generate mock implementations from traits.

Testing Async Code

If you use async Rust, the #[tokio::test] attribute creates a runtime for your test:

#[tokio::test]
async fn test_async_fetch() {
    let result = fetch_data("https://example.com").await;
    assert!(result.is_ok());
}

This requires tokio as a dev-dependency with the macros and rt features:

[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }

5. Code Coverage with cargo llvm-cov

Code coverage measures which lines, branches, and functions your tests actually execute. It’s a useful tool for finding untested code — but high coverage numbers don’t guarantee correctness. Focus on testing critical paths rather than chasing 100%.

Setup

# Install
cargo install cargo-llvm-cov
rustup component add llvm-tools-preview

Basic Usage

# Run tests and show coverage summary
cargo llvm-cov

# Generate HTML report and open in browser
cargo llvm-cov --open

# Coverage for the whole workspace
cargo llvm-cov --workspace

Example Output

Filename                Regions    Missed     Cover   Lines      Missed     Cover
---------------------------------------------------------------------------------------
src/calculator.rs            12         2    83.33%      45           3    93.33%
src/lib.rs                    8         0   100.00%      30           0   100.00%
---------------------------------------------------------------------------------------
TOTAL                        20         2    90.00%      75           3    96.00%

The HTML report highlights covered lines in green and uncovered lines in red — a quick way to spot gaps.

CI/CD Integration

Add coverage to your GitHub Actions pipeline:

name: Coverage

on: [push, pull_request]

jobs:
  coverage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: llvm-tools-preview
      - uses: taiki-e/install-action@cargo-llvm-cov
      - run: cargo llvm-cov --workspace --lcov --output-path lcov.info
      - uses: codecov/codecov-action@v5
        with:
          files: lcov.info

Coverage Best Practices

  • Coverage finds untested code, not bugs — a covered line can still contain a bug
  • Focus on critical paths — business logic, error handling, edge cases
  • Don’t chase 100% — some code (e.g., Display impls, CLI boilerplate) isn’t worth testing exhaustively
  • Watch coverage trends — a sudden drop often signals forgotten tests for new code

6. Property-Based Testing

Standard unit tests check specific examples: “does add(2, 3) return 5?” Property-based testing takes a different approach: “does add(a, b) always equal add(b, a) for all a and b?” The framework generates hundreds of random inputs and checks that your invariants hold.

Using proptest

[dev-dependencies]
proptest = "1"
#[cfg(test)]
mod tests {
    use proptest::prelude::*;

    fn reverse(s: &str) -> String {
        s.chars().rev().collect()
    }

    proptest! {
        #[test]
        fn reversing_twice_gives_original(s in "\\PC*") {
            // For any string, reversing twice should return the original
            assert_eq!(reverse(&reverse(&s)), s);
        }

        #[test]
        fn reverse_preserves_length(s in "\\PC*") {
            // .len() returns byte length; reversing chars preserves total bytes
            assert_eq!(reverse(&s).len(), s.len());
        }

        #[test]
        fn addition_is_commutative(a in 0i64..1000, b in 0i64..1000) {
            assert_eq!(a + b, b + a);
        }
    }
}

When proptest finds a failing input, it shrinks it to the smallest reproducible case — making debugging much easier than staring at a random 200-character string.

When to Use Property-Based Tests

SituationApproach
Known specific inputs and expected outputsStandard #[test]
Mathematical invariants (commutativity, associativity)proptest
Parsers: parse(format(x)) == xproptest
“This should never panic for any input”proptest
Testing a function against a simpler reference implementationproptest

Property-based tests are particularly good at catching edge cases you’d never think to write manually — empty strings, integer overflow, Unicode boundary conditions, etc.

Exercise: Test a MarkdownProcessor

In this exercise you’ll practise the testing techniques covered above by implementing a small Markdown-to-text processor and its test suite. You get the type signatures and the tests — your job is to make every test pass.

Setup

# Copy the starter into your workspace
cp -r solutions/day3/14_testing/ mysolutions/day3/14_testing/
cd mysolutions/day3/14_testing
cargo test          # all tests should fail initially

What you implement

pub struct MarkdownProcessor;

impl MarkdownProcessor {
    pub fn new() -> Self;

    /// Strip all markdown formatting, return plain text.
    pub fn to_plain_text(&self, input: &str) -> String;

    /// Extract all links as (text, url) pairs from `[text](url)`.
    pub fn extract_links(&self, input: &str) -> Vec<(String, String)>;

    /// Count headings by level (1–6).
    pub fn count_headings(&self, input: &str) -> HashMap<u8, usize>;

    /// **bold** → UPPERCASE, *italic* → lowercase.
    /// Panics on unmatched `**` markers.
    pub fn transform_emphasis(&self, input: &str) -> String;
}

What the tests cover

#TestTechnique
1plain_text_strips_headingsassert_eq!
2plain_text_strips_bold_and_italicassert_eq!
3plain_text_converts_links_to_textassert_eq!
4extract_links_finds_all_linksassert_eq! on Vec
5extract_links_returns_empty_for_no_linksassert!(_.is_empty())
6count_headings_by_levelHashMap assertions
7transform_emphasis_bold_to_uppercaseassert_eq!
8transform_emphasis_italic_to_lowercaseassert_eq!
9transform_emphasis_panics_on_unmatched_bold#[should_panic(expected = "...")]
10round_trip_plain_text_is_stableResult<(), String> return
11large_document_performance#[ignore] — run with cargo test -- --ignored

Tips

  • Start with to_plain_text — most other functions build on the same parsing logic.
  • Use str::trim_start_matches to strip leading # characters.
  • For extract_links, search for [ then ]( then ) in sequence.
  • The #[should_panic] test expects the exact substring "Unmatched bold markers".
  • Run cargo test -- --nocapture to see println! output from your code while debugging.

Solution

The reference solution is in solutions/day3/14_testing/src/lib.rs.

Summary

WhatHow
Write a test#[test] fn name() { ... }
Check equalityassert_eq!(actual, expected)
Expect a panic#[should_panic(expected = "msg")]
Return Result from testfn test() -> Result<(), E> { ... }
Test private functionsPut tests in #[cfg(test)] mod tests inside the same file
Integration teststests/*.rs directory
Doc testsCode blocks in /// comments
Run all testscargo test
Filter testscargo test name_filter
See outputcargo test -- --nocapture
Skip slow tests#[ignore], run with cargo test -- --ignored
Code coveragecargo llvm-cov --open
Property testingproptest! macro with random input generators