RustArena

Step 4 of 6

Testing failures: should_panic and Result

Code that rejects bad input is just as important to test as code that handles good input. Rust gives you two tools, matching the two ways code reports failure.

#[should_panic]: the code is supposed to panic

Some functions panic on misuse: indexing past the end, or a withdraw that refuses to overdraw. Add #[should_panic] under #[test], and the test passes only if the code panics. Add expected = "..." to also check the panic message contains that text, so the test doesn't pass by accident because of some other panic:

#[test]
#[should_panic(expected = "insufficient funds")]
fn spending_more_than_the_balance_panics() {
    let mut wallet = Wallet { balance: 10 };
    wallet.spend(25);
}

In the cargo test output, these tests are marked: test tests::spending_more_than_the_balance_panics - should panic ... ok.

Tests that return Result

Most Rust code reports errors with Result. You can check those with plain asserts (assert!(r.is_err()), assert_eq!(r, Ok(250))), and a test function may also return Result<(), E> itself. Then you can use ? inside it: if any step returns Err, the test fails with that error. It reads like normal code instead of a pile of .unwrap()s:

#[test]
fn parses_and_spends() -> Result<(), String> {
    let amount = parse_amount("250")?; // an Err here fails the test
    let mut wallet = Wallet { balance: 1000 };
    wallet.spend(amount);
    assert_eq!(wallet.balance, 750);
    Ok(())
}

The same checks work in a normal program, so here they are, running:

fn parse_amount(s: &str) -> Result<u64, String> {
    let n: u64 = s.trim().parse().map_err(|_| format!("not a number: {s:?}"))?;
    if n == 0 {
        return Err(String::from("amount must be positive"));
    }
    Ok(n)
}

fn main() {
    println!("{:?}", parse_amount("250"));
    println!("{:?}", parse_amount("0"));
    println!("{:?}", parse_amount("ten"));
    assert!(parse_amount("-3").is_err());
    assert!(matches!(parse_amount(" 7 "), Ok(7)));
    println!("checks passed");
}
Ok(250)
Err("amount must be positive")
Err("not a number: \"ten\"")
checks passed

matches!(value, pattern) (from the enums module) is handy in tests when you care about the shape of a value but not every detail, like "it's an Err of any kind".

Ignoring slow tests

#[ignore] under #[test] skips a test in a normal run (it shows as ignored). Run those with cargo test -- --ignored. Teams use it for tests that are slow or need a network.

Common mistakes

Your task

Write tests for Wallet::spend and parse_amount:

  1. A #[should_panic(expected = "insufficient funds")] test that spends more than the balance.
  2. A test that spending the whole balance leaves exactly 0.
  3. A test that returns Result<(), String> and uses ? on parse_amount with an amount that has spaces around it (like " 250 ").
  4. A test that parse_amount("0") is an error.

Challenge 2

parse_hex_color reads colors like #ff8000 into (red, green, blue). It needs the #, exactly 6 hex digits, and returns Err otherwise. Write at least 3 tests; at least one must return Result<(), String> and use ?.

Challenge 3

A Grid stores width * height cells row by row. get(row, col) panics with a message containing out of bounds for a bad position, and try_get returns None instead. Write:

Previous: Tests that catch bugs Next: Fixing bugs test-first