Unit Testing

Writing Unit Tests

Writing effective unit tests structures each test around a single, well-defined behavior using the Arrange-Act-Assert (AAA) pattern, descriptive test function names, and explicit coverage of normal paths, boundary conditions, and error states.

+-------------------------------------------------------------------+
|                  Arrange - Act - Assert (AAA)                     |
|                                                                   |
|  1. Arrange: Construct input values, fixtures, or initial state   |
|  2. Act:     Invoke the single function or method under test      |
|  3. Assert:  Verify the returned value and post-conditions        |
+-------------------------------------------------------------------+

Structuring Tests with Arrange-Act-Assert

Separating setup, execution, and verification keeps unit tests easy to read and makes failure diagnostics pinpoint the exact broken behavior.

#[derive(Debug, PartialEq, Eq)]
pub enum TransferError {
    ZeroAmount,
    InsufficientFunds { available: u64, requested: u64 },
}

pub struct Account {
    balance: u64,
}

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

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

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

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

    #[test]
    fn withdraw_deducts_amount_and_returns_remaining_balance() {
        // 1. Arrange
        let mut account = Account::new(100);

        // 2. Act
        let outcome = account.withdraw(40);

        // 3. Assert
        assert_eq!(outcome, Ok(60));
        assert_eq!(account.balance(), 60);
    }

    #[test]
    fn withdraw_exact_balance_leaves_zero() {
        let mut account = Account::new(50);
        assert_eq!(account.withdraw(50), Ok(0));
        assert_eq!(account.balance(), 0);
    }

    #[test]
    fn withdraw_rejects_zero_amount_without_changing_balance() {
        let mut account = Account::new(75);
        assert_eq!(account.withdraw(0), Err(TransferError::ZeroAmount));
        assert_eq!(account.balance(), 75);
    }

    #[test]
    fn withdraw_exceeding_balance_returns_insufficient_funds() {
        let mut account = Account::new(30);
        assert_eq!(
            account.withdraw(50),
            Err(TransferError::InsufficientFunds {
                available: 30,
                requested: 50,
            })
        );
        assert_eq!(account.balance(), 30);
    }
}

fn main() {
    let mut acct = Account::new(100);
    println!("Balance after withdrawing 40: {:?}", acct.withdraw(40));
}
Balance after withdrawing 40: Ok(60)

Table-Driven Tests for Boundary Conditions

When testing a pure function across many input-output pairs (such as empty inputs, single-element inputs, Unicode boundaries, or integer limits), a table-driven loop inside a test avoids repetitive boilerplate while reporting the exact failing case via a custom assertion message:

pub fn slugify(title: &str) -> String {
    title
        .split(|c: char| !c.is_ascii_alphanumeric())
        .filter(|part| !part.is_empty())
        .map(str::to_ascii_lowercase)
        .collect::<Vec<_>>()
        .join("-")
}

fn main() {
    let cases = [
        ("Hello, World!", "hello-world"),
        ("  Multiple   Spaces  ", "multiple-spaces"),
        ("Rust 2024: Edition", "rust-2024-edition"),
        ("---", ""),
        ("", ""),
    ];

    for (input, expected) in cases {
        let actual = slugify(input);
        assert_eq!(
            actual, expected,
            "slugify({input:?}) produced {actual:?}, expected {expected:?}"
        );
    }
    println!("All {} table-driven test cases passed", cases.len());
}
All 5 table-driven test cases passed