Skip to editor content
learningrust.orglesson 20 of 26

Testing in Rust

Rust has first-class support for testing built directly into the language and toolchain. No external testing framework required — you can write tests alongside your code, and cargo test handles the rest. In this lesson, you'll learn how to write meaningful tests that give you confidence your code works correctly.

Your First Test

Tests in Rust are functions annotated with #[test]. They live in the same file as your code, inside a module marked with #[cfg(test)]. The cfg(test) attribute tells the compiler to only include this module when running tests, keeping your production binary lean.

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

fn is_even(n: i32) -> bool {
    n % 2 == 0
}

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

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

    #[test]
    fn test_is_even() {
        assert!(is_even(4));
        assert!(is_even(0));
        assert!(!is_even(7));
    }
}

fn main() {
    println!("add(2, 3) = {}", add(2, 3));
    println!("is_even(4) = {}", is_even(4));
}

The use super::* line imports everything from the parent module so your tests can access the functions being tested.

One thing to know before you press Run

The Run button on this page is not cargo test. It compiles the snippet as a plain binary and executes fn main — the test cfg is off, so the #[cfg(test)] module is stripped out before compilation and no #[test] function is ever called. That is why every example in this lesson also carries a fn main that exercises the same functions directly.

This is not a quirk of one website. cargo build and cargo run behave exactly the same way: #[cfg(test)] code is only included under cargo test, and only cargo test walks the list of #[test] functions and calls them. A test that is never invoked cannot fail, and a test that cannot fail tells you nothing. Hold on to that, because the next program is built entirely around it.

Predict

The test below asserts something plainly false: it claims a 3 kg parcel costs 99.0. Decide exactly what this program prints, and whether it succeeds or fails, before you run it.

fn shipping_cost(weight_kg: f64) -> f64 {
  if weight_kg <= 1.0 { 4.0 } else { 4.0 + (weight_kg - 1.0) * 2.5 }
}

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

  #[test]
  fn heavy_parcel_costs_more() {
      // 3 kg is really 4.0 + 2.0 * 2.5 = 9.0, so this assertion is wrong.
      assert_eq!(shipping_cost(3.0), 99.0);
  }
}

fn main() {
  println!("1kg  -> {}", shipping_cost(1.0));
  println!("3kg  -> {}", shipping_cost(3.0));
}

It prints 1kg -> 4 and 3kg -> 9, then exits successfully. The deliberately false assert_eq!(shipping_cost(3.0), 99.0) never evaluates, because the test cfg is off and the whole module is stripped before the binary is built. So when you press Run on any snippet in this lesson, read the result as "my main works", never as "my tests pass" — a stripped test cannot report anything at all. Every check in this lesson that has to genuinely execute is therefore written as an assert! or assert_eq! inside fn main, which is the same trick you will use in the capstone CLI when you want a self-checking program rather than a test suite.

Assertion Macros

Rust provides three core assertion macros, each suited for different scenarios:

  • assert!(expr) — panics if the expression is false
  • assert_eq!(left, right) — panics if the two values are not equal, showing both values on failure
  • assert_ne!(left, right) — panics if the two values are equal

Prefer assert_eq! over assert!(a == b) — when a test fails, assert_eq! prints both values so you can immediately see what went wrong. There is a sharper reason too, and it costs teams real bugs: assert!(x.is_some()) and assert_eq!(x, Some(expected)) look like the same check but are not remotely as strong. The first only asks did I get an answer; the second asks did I get the right answer.

The program below is exactly that trap. It compiles, it runs clean, and it reports All checks passed. — and the answer it prints underneath is wrong. Commit a hypothesis about which of its three checks is asleep before you change a line:

Debug

parse_setting is supposed to split 'name=value' and trim the spaces off BOTH halves. The program prints 'All checks passed.' and then prints values that are visibly not trimmed. One of the three checks was written too weakly to notice. Say which check is asleep and why before you change anything, then strengthen it AND fix the function.

/// Splits "name=value" into its two halves, trimming the spaces around each.
fn parse_setting(line: &str) -> Option<(String, String)> {
  let (name, value) = line.split_once('=')?;
  Some((name.trim().to_string(), value.to_string()))
}

fn main() {
  let good = parse_setting("  host = localhost  ");
  assert!(good.is_some(), "a line containing '=' should parse");

  let bad = parse_setting("just-a-word");
  assert!(bad.is_none(), "a line with no '=' should be rejected");

  let trimmed = parse_setting("port =  8080 ");
  assert!(trimmed.is_some(), "the value should come back trimmed");

  println!("All checks passed.");
  println!("{:?}", parse_setting("  host = localhost  "));
  println!("{:?}", parse_setting("port =  8080 "));
}

Expected output: All checks passed. Some(("host", "localhost")) Some(("port", "8080"))

The check that was asleep is the third one. It promises "the value should come back trimmed" in its message and then asserts only trimmed.is_some() — and Some(("port", " 8080 ")) is a Some, so an untrimmed answer satisfies it perfectly. Behind it sat the real defect: parse_setting calls .trim() on the name but not on the value. Strengthening the checks to assert_eq!(trimmed, Some(("port".to_string(), "8080".to_string()))) makes the bug impossible to hide, because now the check names the value it wants rather than merely demanding that some value arrive. That is the practical form of the rule above: an assertion is only as strong as the thing it compares against, and is_some() compares against almost nothing.

You can also attach a custom message to any assertion:

fn celsius_to_fahrenheit(c: f64) -> f64 {
    c * 9.0 / 5.0 + 32.0
}

fn grade_label(score: u32) -> &'static str {
    match score {
        90..=100 => "A",
        80..=89  => "B",
        70..=79  => "C",
        60..=69  => "D",
        _        => "F",
    }
}

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

    #[test]
    fn test_freezing_point() {
        let result = celsius_to_fahrenheit(0.0);
        assert_eq!(result, 32.0, "Freezing point should be 32°F, got {}", result);
    }

    #[test]
    fn test_boiling_point() {
        let result = celsius_to_fahrenheit(100.0);
        assert_eq!(result, 212.0, "Boiling point should be 212°F, got {}", result);
    }

    #[test]
    fn test_grade_boundaries() {
        assert_eq!(grade_label(95), "A");
        assert_eq!(grade_label(85), "B");
        assert_eq!(grade_label(75), "C");
        assert_eq!(grade_label(55), "F");
        assert_ne!(grade_label(90), "B", "Score 90 should be an A, not a B");
    }
}

fn main() {
    println!("0°C = {}°F", celsius_to_fahrenheit(0.0));
    println!("Score 85 = grade {}", grade_label(85));
}

Testing for Panics

Sometimes the correct behavior is to panic. Use #[should_panic] to assert that a function panics under specific conditions. You can be more precise by specifying an expected string that must appear in the panic message.

fn divide(a: f64, b: f64) -> f64 {
    if b == 0.0 {
        panic!("Cannot divide by zero!");
    }
    a / b
}

fn get_item(items: &[i32], index: usize) -> i32 {
    if index >= items.len() {
        panic!("Index {} out of bounds for length {}", index, items.len());
    }
    items[index]
}

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

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

    #[test]
    #[should_panic(expected = "Cannot divide by zero")]
    fn test_divide_by_zero() {
        divide(5.0, 0.0);
    }

    #[test]
    #[should_panic(expected = "out of bounds")]
    fn test_index_out_of_bounds() {
        let items = vec![1, 2, 3];
        get_item(&items, 10);
    }
}

fn main() {
    let result = divide(10.0, 2.0);
    println!("10 / 2 = {}", result);

    let items = vec![10, 20, 30];
    println!("Item at index 1: {}", get_item(&items, 1));
}

Testing with Result

Tests can also return Result<(), E> instead of panicking. This lets you use the ? operator inside tests, making it easy to test code that returns Result values:

use std::num::ParseIntError;

fn parse_and_double(s: &str) -> Result<i32, ParseIntError> {
    let n: i32 = s.trim().parse()?;
    Ok(n * 2)
}

fn words_longer_than(text: &str, min_len: usize) -> Vec<&str> {
    text.split_whitespace()
        .filter(|word| word.len() > min_len)
        .collect()
}

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

    #[test]
    fn test_parse_valid_number() -> Result<(), String> {
        let result = parse_and_double("21").map_err(|e| e.to_string())?;
        assert_eq!(result, 42);
        Ok(())
    }

    #[test]
    fn test_parse_invalid_returns_error() {
        let result = parse_and_double("not_a_number");
        assert!(result.is_err(), "Expected an error for invalid input");
    }

    #[test]
    fn test_words_longer_than() {
        let text = "the quick brown fox jumps";
        let long_words = words_longer_than(text, 3);
        assert_eq!(long_words, vec!["quick", "brown", "jumps"]);
        assert!(!long_words.contains(&"the"), "'the' is too short to be included");
        assert!(!long_words.contains(&"fox"), "'fox' is too short to be included");
    }
}

fn main() {
    match parse_and_double("21") {
        Ok(n) => println!("Doubled: {}", n),
        Err(e) => println!("Error: {}", e),
    }

    let text = "the quick brown fox";
    println!("Long words: {:?}", words_longer_than(text, 3));
}

A test that returns Result leans entirely on a rule you met long before this lesson. Close the page and answer this from memory before reading on:

Recall

Without scrolling up: in Option and Result you learned what the ? operator does to a Result. In the test above, the line let result = parse_and_double('21').map_err(|e| e.to_string())?; sits directly before an assert_eq!. What happens to the rest of the test body if that call returns an Err?

? is early return, exactly as it was in Option and Result — it hands you the Ok value or returns the Err out of the function on the spot, skipping every line below it. That is the whole reason a test is allowed to return Result<(), E>: it makes ? legal in the body, and the harness reads an Ok return as a pass and an Err return as a failure. The .map_err(|e| e.to_string()) is only there to line the error type up with the String the signature promises, because ? converts error types through From and ParseIntError does not become a String by itself.

Organizing Tests

As your codebase grows, keep tests close to the code they verify. A common pattern is a tests submodule at the bottom of each source file. For tests that verify how multiple modules work together, Rust supports integration tests in a dedicated tests/ directory at your project root — these are compiled as separate crates and test your public API.

Within a test module, you can use helper functions freely:

fn normalize_username(name: &str) -> String {
    name.trim().to_lowercase().replace(' ', "_")
}

fn is_valid_username(name: &str) -> bool {
    let normalized = normalize_username(name);
    !normalized.is_empty()
        && normalized.len() >= 3
        && normalized.len() <= 20
        && normalized.chars().all(|c| c.is_alphanumeric() || c == '_')
}

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

    // A helper that creates test cases and checks them all
    fn assert_valid(names: &[&str]) {
        for name in names {
            assert!(
                is_valid_username(name),
                "Expected '{}' to be valid",
                name
            );
        }
    }

    fn assert_invalid(names: &[&str]) {
        for name in names {
            assert!(
                !is_valid_username(name),
                "Expected '{}' to be invalid",
                name
            );
        }
    }

    #[test]
    fn test_valid_usernames() {
        assert_valid(&["alice", "bob_smith", "user123", "  Alice  "]);
    }

    #[test]
    fn test_invalid_usernames() {
        assert_invalid(&["ab", "", "this_username_is_way_too_long_for_our_system", "has spaces!"]);
    }

    #[test]
    fn test_normalization() {
        assert_eq!(normalize_username("  Alice Smith  "), "alice_smith");
        assert_eq!(normalize_username("BOB"), "bob");
    }
}

fn main() {
    let names = ["alice", "  Bob Smith  ", "ab", ""];
    for name in &names {
        let normalized = normalize_username(name);
        let valid = is_valid_username(name);
        println!("'{}' -> '{}' (valid: {})", name, normalized, valid);
    }
}

Try It Yourself

Reading about assertions is not the same as writing a check that would actually catch a regression. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out and a battery of assert_eq! calls sits below them in fn main. Run it as-is and it panics immediately, naming the first check that did not pass. Implement each function until every check passes and it prints All checks passed.

The checks are in main rather than in a #[cfg(test)] module for the reason the Predict at the top of this lesson established: a #[cfg(test)] module is stripped out entirely here, so a check that has to execute has to live in main. Everything the checks demand is taught above — split_once, trim, and the assert_eq!-names-the-expected-value discipline the Debug block argued for. The test cases include the awkward ones deliberately: a line with no separator, a value padded with spaces, and a list containing no ERROR at all.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

// A tiny log-line parser with its own test battery. Every check is an
// assert_eq! inside main, because a #[cfg(test)] module is stripped here and
// never executed — only cargo test includes and calls them.

#[derive(Debug, PartialEq)]
struct Entry {
  level: String,
  message: String,
}

// TODO 1: split "LEVEL: message" into an Entry, trimming BOTH halves.
//   Return None when the line contains no ':'. str::split_once does the split.
//   parse_entry("WARN: disk slow") -> Some(Entry { level: "WARN", message: "disk slow" })
fn parse_entry(line: &str) -> Option<Entry> {
  let _ = line;
  None
}

// TODO 2: count how many lines parse into an Entry whose level is exactly "ERROR".
//   Lines that do not parse are simply not counted.
//   count_errors(&["ERROR: x", "info: y"]) -> 1
fn count_errors(lines: &[&str]) -> usize {
  let _ = lines;
  0
}

// TODO 3: return the message of the FIRST line whose level is "ERROR",
//   or None when there is no such line.
//   first_error_message(&["INFO: a", "ERROR: b"]) -> Some("b".to_string())
fn first_error_message(lines: &[&str]) -> Option<String> {
  let _ = lines;
  None
}

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  let lines = [
      "INFO: booted",
      "ERROR:  disk full  ",
      "not a log line",
      "ERROR: timeout",
  ];

  assert_eq!(
      parse_entry("  WARN : disk slow "),
      Some(Entry { level: "WARN".to_string(), message: "disk slow".to_string() }),
      "parse_entry should split on ':' and trim BOTH halves"
  );
  assert_eq!(
      parse_entry("not a log line"),
      None,
      "parse_entry should return None when the line has no ':'"
  );
  assert_eq!(
      count_errors(&lines),
      2,
      "count_errors should count only the lines whose level is exactly ERROR"
  );
  assert_eq!(
      first_error_message(&lines),
      Some("disk full".to_string()),
      "first_error_message should return the FIRST ERROR line's trimmed message"
  );
  assert_eq!(
      first_error_message(&["INFO: quiet"]),
      None,
      "first_error_message should return None when no ERROR line exists"
  );

  println!("All checks passed.");
  println!("Errors: {}", count_errors(&lines));
  println!("First error: {:?}", first_error_message(&lines));
}

Expected output: All checks passed. Errors: 2 First error: Some("disk full")

Once it passes, try two variations and predict each before running:

  1. Weaken a check. Replace the fourth check with assert!(first_error_message(&lines).is_some(), "there should be an error");, delete the fifth check as well, and then break first_error_message by dropping the .find(…) filter so it returns the first parsed line instead — Some("booted"). Predict whether the program still prints All checks passed. before running. It does: Some("booted") is a Some, so the weakened check is satisfied by the wrong answer, and only the final println! reveals it. Leave the fifth check in place and it catches the break on its own — first_error_message(&["INFO: quiet"]) now returns Some("quiet") rather than None — which is the same point from the other side: the check that names an exact value is the one that does the work. This is the Debug block's lesson reproduced by your own hand.
  2. Delete a case. Remove the last check — the one asserting first_error_message(&["INFO: quiet"]) is None — and then implement first_error_message with .unwrap() on the find result. Decide what happens before running. The remaining checks all pass, because every one of them uses a list that does contain an ERROR; the panic only appears the day a real log has none. A test suite is only as good as the inputs it thinks to include.

Key Takeaways

  • Annotate test functions with #[test] and wrap them in a #[cfg(test)] module so they are excluded from release builds
  • Use assert_eq! and assert_ne! over bare assert! when comparing values — they print both sides on failure, making debugging faster
  • Add custom messages to assertions with a format string argument: assert_eq!(a, b, "Expected {} but got {}", b, a)
  • Use #[should_panic(expected = "...")] to assert that panics occur and include the right message
  • Tests can return Result<(), E> to use the ? operator, keeping test code clean when working with fallible functions
  • Extract shared setup into helper functions within the test module to keep individual tests focused and avoid repetition
  • Compiling a test and running one are different events: only cargo test calls your #[test] functions, so a green cargo run (or a green Run button on this page) says your tests compile, never that they pass
  • An assertion is only as strong as the value it compares against — assert!(x.is_some()) passes on a wrong answer, assert_eq!(x, Some(expected)) does not
  • Pick test inputs for the awkward cases (empty input, missing separator, padded whitespace, no match at all); a suite that only feeds a function well-formed data will pass on the day the function breaks

Pro Tip: Run cargo test -- --show-output to see println! output from your tests even when they pass. By default, Rust captures and hides output from passing tests and shows it only for failing ones. This is invaluable when you're debugging a subtle failure and want to trace what's happening inside a test without changing it to eprintln!. There is also --no-capture, which lets tests print while they run rather than collecting the output until the end — handy for a hanging test, but because tests run in parallel the lines from different tests interleave. (You will still see the older spelling --nocapture in the wild; it is a deprecated alias for --no-capture.)

Next Steps

With testing skills in place, you're ready to work with the file system. Next, we'll learn how to read, write, and manipulate files using Rust's standard library.

Next lesson

File I/O

Learn how to read from and write to files in Rust using std::fs and std::io traits

25 min