Skip to content
AgileLogics
7 min read

Writing Tests

Prerequisites: Working with JSON using serde

Testing is built into Rust and Cargo: no extra framework is needed to start. A test is an ordinary function marked with #[test]. cargo test compiles your code in test mode, runs every test (in parallel, on several threads) and reports which ones passed. A test fails if it panics.

There are three kinds of tests:

  • Unit tests sit next to the code they test, in a tests module inside the same file. They can call private functions.
  • Integration tests live in a top-level tests/ directory. Each file there is compiled as a separate crate that uses your library only through its public API, exactly as a user would.
  • Doc tests are code examples inside documentation comments; the next lesson covers them.

Syntax

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]            // only compiled for `cargo test`, not in normal builds
mod tests {
    use super::*;       // bring the parent module's items into scope

    #[test]
    fn adds_two_numbers() {
        assert_eq!(add(2, 3), 5);
    }
}
Macro Fails when
assert!(cond) cond is false
assert_eq!(left, right) the values differ (prints both)
assert_ne!(left, right) the values are equal

All three accept an optional message with format arguments: assert!(x > 0, "x was {x}").

Example

A library package named bank, src/lib.rs:

#[derive(Debug, PartialEq)]
pub enum BankError {
    InsufficientFunds { needed: u64, available: u64 },
}

pub struct Account {
    balance: u64,
}

impl Account {
    pub fn new(balance: u64) -> Self {
        Account { balance }
    }

    pub fn balance(&self) -> u64 {
        self.balance
    }

    pub fn deposit(&mut self, amount: u64) {
        assert!(amount > 0, "deposit must be positive");
        self.balance += amount;
    }

    pub fn withdraw(&mut self, amount: u64) -> Result<(), BankError> {
        if amount > self.balance {
            return Err(BankError::InsufficientFunds { needed: amount, available: self.balance });
        }
        self.balance -= amount;
        Ok(())
    }

    pub fn monthly_fee(&self) -> u64 {
        fee_for(self.balance)
    }
}

fn fee_for(amount: u64) -> u64 {
    amount / 100 // private helper: 1% fee
}

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

    #[test]
    fn deposit_increases_balance() {
        let mut acct = Account::new(10);
        acct.deposit(5);
        assert_eq!(acct.balance(), 15);
    }

    #[test]
    fn withdraw_too_much_is_an_error() {
        let mut acct = Account::new(10);
        let result = acct.withdraw(50);
        assert_eq!(result, Err(BankError::InsufficientFunds { needed: 50, available: 10 }));
        assert_eq!(acct.balance(), 10, "balance must not change on failure");
    }

    #[test]
    fn private_functions_can_be_tested() {
        assert_eq!(fee_for(250), 2);
    }

    #[test]
    #[should_panic(expected = "deposit must be positive")]
    fn zero_deposit_panics() {
        Account::new(0).deposit(0);
    }

    // A test can return Result and use `?`; an Err fails the test.
    #[test]
    fn withdraw_then_check() -> Result<(), BankError> {
        let mut acct = Account::new(100);
        acct.withdraw(40)?;
        assert_eq!(acct.balance(), 60);
        Ok(())
    }
}

An integration test, tests/transfers.rs (it can only use the public API):

use bank::Account;

#[test]
fn several_operations() {
    let mut acct = Account::new(0);
    acct.deposit(100);
    acct.withdraw(30).unwrap();
    assert_eq!(acct.balance(), 70);
}

Run everything with cargo test.

Expected output

running 5 tests
test tests::deposit_increases_balance ... ok
test tests::private_functions_can_be_tested ... ok
test tests::withdraw_then_check ... ok
test tests::withdraw_too_much_is_an_error ... ok
test tests::zero_deposit_panics - should panic ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test several_operations ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Cargo also prints Running ... headers and a doc-test section (with 0 tests here). Because tests run in parallel, the order of the test ... ok lines and the timings can vary between runs.

Useful cargo test options

cargo test withdraw              # run only tests whose name contains "withdraw"
cargo test --test transfers      # run only the integration test file tests/transfers.rs
cargo test -- --nocapture        # show println! output from passing tests
cargo test -- --test-threads=1   # run tests one at a time
cargo test -- --ignored          # run tests marked #[ignore]

Arguments before -- go to Cargo; arguments after it go to the test binary.

Common mistakes

  • Forgetting use super::*; inside mod tests: error[E0433]: failed to resolve: use of undeclared type `Account` .
  • Leaving out #[cfg(test)]. The tests still run, but test-only code and imports are compiled into your normal build, with unused warnings.
  • Using assert!(a == b) instead of assert_eq!(a, b). Both work, but only assert_eq! shows the two values when it fails. It needs the values to implement PartialEq and Debug.
  • A #[should_panic] test without expected = "..." passes for any panic, including an unrelated bug. Give the expected message text.
  • Writing integration tests for a binary-only package (no src/lib.rs). Files in tests/ can only use a library crate; move the logic into lib.rs.
  • Tests that depend on each other or share files. They run in parallel and in no fixed order, so each test should set up its own data.

Practice

Write pub fn is_leap_year(year: u32) -> bool (divisible by 4, except centuries, unless divisible by 400) with a test module containing: a test that 2024 and 2000 are leap years, a test that 1900 and 2023 are not, and a test returning Result<(), String> that fails with a message if 1600 is not a leap year.

Show solution
pub fn is_leap_year(year: u32) -> bool {
    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}

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

    #[test]
    fn leap_years() {
        assert!(is_leap_year(2024));
        assert!(is_leap_year(2000));
    }

    #[test]
    fn common_years() {
        assert!(!is_leap_year(1900), "centuries are not leap years");
        assert!(!is_leap_year(2023));
    }

    #[test]
    fn year_1600() -> Result<(), String> {
        if is_leap_year(1600) {
            Ok(())
        } else {
            Err(String::from("1600 should be a leap year"))
        }
    }
}

cargo test reports test result: ok. 3 passed; 0 failed; ....