Modules, Visibility and Testing · about 45 min

Testing Well: Edge Cases, Panics and Test Design

Why this matters

Writing a test is easy; writing tests that actually catch bugs takes some technique. A test suite that passes no matter what the code does is worse than none, because it gives false confidence. This lesson covers the tools for testing failure paths (panics and errors), patterns that keep tests short and thorough (table-driven tests, helpers), and a way to check that your tests are any good: break the code on purpose and see whether they notice.

Testing that code panics

Some functions are supposed to panic on misuse. #[should_panic] makes a test pass only if its body panics:

fn checked_index(v: &[i32], i: usize) -> i32 {
    if i >= v.len() {
        panic!("index {i} out of range for length {}", v.len());
    }
    v[i]
}

fn main() {
    println!("{}", checked_index(&[1, 2, 3], 1));
}

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

    #[test]
    #[should_panic(expected = "out of range")]
    fn rejects_bad_index() {
        checked_index(&[1, 2, 3], 3);
    }
}

Always include expected = "...": the test then passes only if the panic message contains that text. Without it, any panic counts, including one caused by a bug in the test itself.

Testing errors: tests that return Result

For functions returning Result, assert on the Err directly, or let the test return a Result and use ?:

fn parse_port(s: &str) -> Result<u16, String> {
    let n: u32 = s.parse().map_err(|_| format!("{s:?} is not a number"))?;
    if n == 0 || n > 65535 {
        return Err(format!("{n} is out of range"));
    }
    Ok(n as u16)
}

fn main() {
    println!("{:?}", parse_port("8080"));
}

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

    #[test]
    fn valid() -> Result<(), String> {
        assert_eq!(parse_port("80")?, 80);
        assert_eq!(parse_port("65535")?, 65535);
        Ok(())
    }

    #[test]
    fn out_of_range() {
        assert_eq!(parse_port("0"), Err(String::from("0 is out of range")));
        assert!(parse_port("70000").is_err());
    }

    #[test]
    fn not_a_number() {
        let err = parse_port("http").unwrap_err();
        assert!(err.contains("not a number"), "unexpected message: {err}");
    }
}

A test returning Result<(), E> fails if it returns Err, which lets you use ? in test code. Test the error paths as carefully as the happy path: that is where real-world bugs hide.

Table-driven tests

When many inputs exercise the same logic, put them in a table and loop:

#[test]
fn roman_numerals() {
    let cases = [(1, "I"), (4, "IV"), (9, "IX"), (14, "XIV"), (1994, "MCMXCIV")];
    for (n, expected) in cases {
        assert_eq!(to_roman(n), expected, "to_roman({n})");
    }
}

The message argument ("to_roman({n})") matters: without it, a failure tells you two strings differed but not which case. Tables make it cheap to add the edge case you just thought of.

Choosing test cases

For each function, think through the categories:

Boundaries deserve special attention: off-by-one errors (> versus >=) are the most common bug, and only a test exactly at the boundary catches them.

Are the tests any good? Break the code

A simple, powerful check: change the implementation in a small, wrong way (flip a > to >=, return a constant, delete a branch) and run the tests. If they still pass, they are not testing that behavior. Tools automate this as mutation testing (cargo-mutants in the wider ecosystem); the checkers for this lesson's exercises do exactly that with a handful of hand-made mutants.

Test helpers and organization

Tests are ordinary Rust code: factor repeated setup into helper functions inside the tests module (they need no #[test]). A few other tools you will meet in cargo projects:

Common mistakes

Recap

Going further: read the Rust Book chapter 11.2 and 11.3 ("Controlling How Tests Are Run", "Test Organization"), and try writing a doc test in a cargo library project with cargo test --doc.

Exercises

Exercise 1 · write code

Implement to a test suite

The tests describe fn parse_duration(s: &str) -> Result<u64, String>, which turns strings like 90s, 5m, 2h and 1h30m into seconds. Read the tests to learn the exact rules, then implement the function so every test passes (the checker runs them with rustc --test). Do not change the tests. main prints:

5400

File: main.rs

Starting point

fn parse_duration(s: &str) -> Result<u64, String> {
    Err(String::from("not implemented"))
}

fn main() {
    println!("{}", parse_duration("1h30m").unwrap());
}

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

    #[test]
    fn single_units() {
        let cases = [("90s", 90), ("5m", 300), ("2h", 7200), ("0s", 0)];
        for (input, expected) in cases {
            assert_eq!(parse_duration(input), Ok(expected), "input {input:?}");
        }
    }

    #[test]
    fn combined_units() {
        assert_eq!(parse_duration("1h30m"), Ok(5400));
        assert_eq!(parse_duration("1m1s"), Ok(61));
        assert_eq!(parse_duration("2h0m5s"), Ok(7205));
    }

    #[test]
    fn errors() -> Result<(), String> {
        for bad in ["", "10", "5x", "h", "1hh"] {
            if parse_duration(bad).is_ok() {
                return Err(format!("{bad:?} should be rejected"));
            }
        }
        Ok(())
    }
}

Example

expected output

5400
Hint 1

Walk the characters, collecting digits into a String; on a unit letter, parse the digits and add number times factor.

Hint 2

Errors: empty input, a unit with no digits before it (h, the second h in 1hh), an unknown letter, digits left over at the end (10).

Reference solution
fn parse_duration(s: &str) -> Result<u64, String> {
    if s.is_empty() {
        return Err(String::from("empty duration"));
    }
    let mut total = 0;
    let mut number = String::new();
    for c in s.chars() {
        if c.is_ascii_digit() {
            number.push(c);
            continue;
        }
        let factor = match c {
            'h' => 3600,
            'm' => 60,
            's' => 1,
            _ => return Err(format!("unknown unit {c:?}")),
        };
        if number.is_empty() {
            return Err(format!("missing number before {c:?}"));
        }
        let n: u64 = number.parse().map_err(|_| String::from("number too large"))?;
        total += n * factor;
        number.clear();
    }
    if !number.is_empty() {
        return Err(String::from("number without a unit"));
    }
    Ok(total)
}

fn main() {
    println!("{}", parse_duration("1h30m").unwrap());
}

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

    #[test]
    fn single_units() {
        let cases = [("90s", 90), ("5m", 300), ("2h", 7200), ("0s", 0)];
        for (input, expected) in cases {
            assert_eq!(parse_duration(input), Ok(expected), "input {input:?}");
        }
    }

    #[test]
    fn combined_units() {
        assert_eq!(parse_duration("1h30m"), Ok(5400));
        assert_eq!(parse_duration("1m1s"), Ok(61));
        assert_eq!(parse_duration("2h0m5s"), Ok(7205));
    }

    #[test]
    fn errors() -> Result<(), String> {
        for bad in ["", "10", "5x", "h", "1hh"] {
            if parse_duration(bad).is_ok() {
                return Err(format!("{bad:?} should be rejected"));
            }
        }
        Ok(())
    }
}

Exercise 2 · write code

Test a type, including its panics

Stack (a bounded stack) is implemented. Write tests in #[cfg(test)] mod tests covering:

  1. push then pop returns the values in reverse order
  2. pop on an empty stack returns None
  3. peek returns the top without removing it (len unchanged)
  4. pushing beyond the capacity panics with a message containing "stack full" (use #[should_panic(expected = "stack full")])

You need at least 4 tests. The checker runs them, then runs them against buggy versions of Stack, and they must catch every bug. Keep the // BEGIN IMPL / // END IMPL markers. main prints 3.

File: main.rs

Starting point

// BEGIN IMPL
struct Stack {
    items: Vec<i32>,
    cap: usize,
}

impl Stack {
    fn new(cap: usize) -> Self {
        Stack { items: Vec::new(), cap }
    }
    fn push(&mut self, x: i32) {
        if self.items.len() == self.cap {
            panic!("stack full (capacity {})", self.cap);
        }
        self.items.push(x);
    }
    fn pop(&mut self) -> Option<i32> {
        self.items.pop()
    }
    fn peek(&self) -> Option<i32> {
        self.items.last().copied()
    }
    fn len(&self) -> usize {
        self.items.len()
    }
}
// END IMPL

fn main() {
    let mut s = Stack::new(5);
    s.push(1);
    s.push(2);
    s.push(3);
    println!("{}", s.len());
}

Example

expected output

3
Hint 1

Each test creates its own Stack::new(...), does something, and asserts.

Hint 2

Put #[should_panic(expected = "stack full")] under #[test] for the overflow test; the test passes only if the body panics with that text.

Hint 3

Test the boundary exactly: with capacity 2, two pushes must succeed and the third must panic.

Reference solution
// BEGIN IMPL
struct Stack {
    items: Vec<i32>,
    cap: usize,
}

impl Stack {
    fn new(cap: usize) -> Self {
        Stack { items: Vec::new(), cap }
    }
    fn push(&mut self, x: i32) {
        if self.items.len() == self.cap {
            panic!("stack full (capacity {})", self.cap);
        }
        self.items.push(x);
    }
    fn pop(&mut self) -> Option<i32> {
        self.items.pop()
    }
    fn peek(&self) -> Option<i32> {
        self.items.last().copied()
    }
    fn len(&self) -> usize {
        self.items.len()
    }
}
// END IMPL

fn main() {
    let mut s = Stack::new(5);
    s.push(1);
    s.push(2);
    s.push(3);
    println!("{}", s.len());
}

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

    #[test]
    fn lifo_order() {
        let mut s = Stack::new(3);
        s.push(1);
        s.push(2);
        s.push(3);
        assert_eq!(s.pop(), Some(3));
        assert_eq!(s.pop(), Some(2));
        assert_eq!(s.pop(), Some(1));
    }

    #[test]
    fn pop_empty() {
        let mut s = Stack::new(1);
        assert_eq!(s.pop(), None);
    }

    #[test]
    fn peek_does_not_remove() {
        let mut s = Stack::new(2);
        s.push(7);
        assert_eq!(s.peek(), Some(7));
        assert_eq!(s.len(), 1);
        assert_eq!(s.peek(), Some(7));
    }

    #[test]
    #[should_panic(expected = "stack full")]
    fn overflow_panics() {
        let mut s = Stack::new(2);
        s.push(1);
        s.push(2);
        s.push(3);
    }
}

Exercise 3 · quiz

When does a should_panic test pass?

A test is marked #[test] #[should_panic(expected = "divide by zero")]. When does it pass?

Answer
  • When the body panics with a message containing "divide by zero"

should_panic inverts the rule: the test passes only if the body panics, and with expected, only if the panic message contains that text. Without expected, an unrelated panic (an index out of bounds in your setup code) would make the test pass for the wrong reason, so always give the expected message.