Skip to lesson

learningrust.org / intermediate / 20-testing-in-rust · lesson 20 of 26

TL;DR

Learn how to write unit tests, integration tests, and use Rust's built-in testing framework to ensure your code is correct

Key concepts

  • Rust testing
  • Rust unit tests
  • Rust integration tests
  • Rust cargo test
  • Rust test framework

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.

What You'll Learn

By the end of this lesson you will finish a log-line parser that checks itself: three functions you implement, and a battery of assertions below them that refuses to print All checks passed. until every one of them is right. The part that catches people is not writing assertions at all — it is writing one so weak it passes on a wrong answer, which is the bug you will debug halfway down the page before you write the battery yourself.

This is the capability the capstone's taskwork CLI stands on. Its parsing and filtering are covered by two batteries — a #[cfg(test)] mod tests for the #[test] vocabulary, and a second set of assertions inside main — and this lesson is where you learn why a serious program carries both. You arrive here able to return a Result when parsing fails and to split a fallible chain with ? (Error Handling); what is new is deciding what a check has to compare against before it is worth writing down.

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));
}
Continue learning

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

Reading a failed assertion

A lesson about testing owes you the sight of a test failing. Here is a real one, produced by the toolchain behind this page's Run button (rustc 1.97.1) from assert_eq!(shipping_cost(3.0), 99.0, "a 3 kg parcel"); in fn main:

thread 'main' (576) panicked at /tmp/main.rs:6:5:
assertion `left == right` failed: a 3 kg parcel
  left: 9.0
 right: 99.0
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Four parts, each with a job, and they are worth naming because a failed assertion is a panic, not a compiler diagnostic — there is no --> arrow, no ^^^ span underlining the offending expression, and no help: suggesting a fix. All you are given is:

  • thread 'main' … panicked at /tmp/main.rs:6:5 — where. The file, the line, the column of the failing macro. The parenthesised number is an operating-system thread id and differs from run to run; ignore it. When the panic comes from inside a library rather than your own code, this is the first thing to check: a location under /rustc/… or in someone else's file means the bad value was yours and the panic site is not where you should start reading.
  • **assertion \left == right` failed** — *which macro*. assert_eq!saysleft == right, assert_ne!saysleft != right, and a bare assert!echoes your expression back at you and nothing else —assert!(a == b)fails withassertion failed: a == b, which names the comparison but neither of the values that failed it. That third form is the one that tells you least, and it is the argument for reaching for assert_eq!` first.
  • : a 3 kg parcel — your custom message, appended to the same line when you supply one. This is the only part you control, and it is where the case's name belongs.
  • left: 9.0 / right: 99.0 — what actually happened. left is the first argument, right the second, always in that order — so assert_eq!(actual, expected) reads as left is what I got, right is what I wanted, and swapping the arguments silently reverses that reading for anyone debugging later.

The note: line is the routine's fourth step: RUST_BACKTRACE=1 adds the chain of calls that reached the panic, which is what you want when the failing assertion sits in a helper that several tests share and the message alone cannot tell you which caller sent the bad value. It does not run here — the Run button sets no environment variables — but it is the next thing to reach for at a terminal. An assertion panic exits with status 101, not 1, so a script that only checks for a non-zero exit will see the failure while one that checks specifically for 1 will not.

Under cargo test the same panic is wrapped rather than replaced: the harness prints test tests::heavy_parcel_costs_more ... FAILED, then repeats the four-part panic verbatim under a failures: heading, then a test result: FAILED. 0 passed; 1 failed; summary line. The parts you just learned to read are the same parts; the harness only adds the name of the test that produced them.

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.

Strengthening one check, three stages

Here is that argument as a progression on a single function, worked in full. Each stage checks the same function; what changes is how much a wrong implementation could get past it. Read the comments for why each stage was chosen over the one above, not for what the line does:

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

fn main() {
    // Stage 1 - the weakest check that still "tests" something. It only asks
    // whether a string came back at all, so every wrong answer also passes it.
    assert!(!normalize_username("  Alice  ").is_empty());

    // Stage 2 - name the expected value. assert_eq! is chosen over
    // assert!(a == b) because the failure prints both sides: you learn what you
    // got, not only that you did not get what you wanted.
    assert_eq!(normalize_username("  Alice  "), "alice");

    // Stage 3 - pick the input that can distinguish two plausible
    // implementations. "Bob Smith" separates replacing the inner space from
    // dropping it; "  Alice  " cannot, because trim removes its spaces first.
    // The message names the RULE, so a future failure explains itself.
    assert_eq!(
        normalize_username("Bob Smith"),
        "bob_smith",
        "an inner space becomes an underscore, it is not dropped"
    );

    println!("All checks passed.");
}

Stage 3 is not a stronger assertion than stage 2 — both are assert_eq! against an exact value. It is a stronger case. Change the implementation's replace(' ', "_") to replace(' ', "") and run it: stages 1 and 2 still pass, because " Alice " has no inner space for the difference to show up in, and only stage 3 fails. Choosing the input is half the work, and it is the half a green suite never tells you that you skipped.

Now take the fade one step further yourself. is_valid_username further down this page rejects a name shorter than three characters. Write the two assert_eq! calls that pin that boundary — one input that must be accepted, one that must be rejected — and ask which pair of lengths you have to choose to tell >= 3 apart from > 3. Any pair further from the boundary passes both implementations, which is the same failure as stage 2's.

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"))

Continue learning

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?

Continue learning

? 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.

If you have written tests in another language, the shape of all this should feel familiar, and it is worth being precise about which part is the same and which part genuinely is not:

Transfer

A Jest suite in JavaScript and a PHPUnit suite in PHP both work like this: you write functions with a particular shape in files the runner is configured to find, the runner loads those files, calls each test in turn, and prints a pass or fail line per test. Rust's #[test] plus cargo test does the same job. Which statement names what genuinely carries across, rather than a resemblance that breaks down?

Continue learning

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);
    }
}

Arrange, act, assert — in the only order that compiles

Every test body above has the same three beats: build the input, call the thing, check the answer. Writing each step out with its own name exposes something the one-liner hides — a check that both asserts on a value and takes it apart has to do those in one particular order, because expect consumes what it is called on. That is the ownership rule from earlier in the track, showing up in the middle of a test.

The lines below run inside fn main, against the helper you are about to implement in the build task — fn parse_entry(line: &str) -> Option<(String, String)>, which splits a log line on its first : and trims both halves, returning None when there is no colon. Here is the frame they drop into, with the padded input the check is built around:

fn parse_entry(line: &str) -> Option<(String, String)> {
    let (l, m) = line.split_once(':')?;
    Some((l.trim().to_string(), m.trim().to_string()))
}

fn main() {
    let raw = "  ERROR :  disk full  ";
    // the five shuffled lines go here
}

Arrange the code

These five lines are the body of a check: they parse a padded log line, assert that it parsed at all, unwrap it, take the two halves apart, and print them. The pieces are shuffled. Put them in the order that runs — then answer why this order and not another, and single out the one pair whose swap fails for a reason that has nothing to do with a name being undeclared. What is different about that pair?

  1. let (level, message) = entry;
  2. assert!(parsed.is_some(), "a line with a colon must parse");
  3. let parsed = parse_entry(raw);
  4. let entry = parsed.expect("already checked above");
  5. println!("checked {} / {}", level, message);
Continue learning

Two rules were doing the work there, and only one of them is about tests. expect taking self by value is the ordinary ownership rule from Ownership & Borrowing, met in the one place it is easy to miss: you assert on a value while you still own it, then consume it. The other rule is the one this whole lesson turns on — put the lines in the right order and hand raw a line with no colon, and every name is still in scope, so the program compiles and the failure moves to run time as a panic at the assertion. Order is a compile-time property; correctness is not, which is the entire reason the assertion has to exist at all.

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")

Continue learning

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.

Carrying This Forward

One rule from this lesson outlives it, and it is worth stating as a habit rather than as a fact about assert_eq!: an assertion is only as strong as the thing it compares against. Every lesson after this one ends in a build task with a battery of checks in fn main, and each of those batteries is an opportunity to write the weak version. When you add a check to any of them, ask the two questions this lesson asked: what wrong answer would still pass this? and which input would tell the two implementations apart?

You will see the vocabulary again as well. From here on, the build tasks that verify something worth testing carry both batteries: a #[cfg(test)] mod tests with real #[test] functions, and a set of assertions inside main. That is not duplication for its own sake, and the reason is the one established at the top of this page — the Run button compiles the snippet as a plain binary, so the #[cfg(test)] module is stripped and no #[test] function is ever called here. The main battery is the one that executes for you; the #[cfg(test)] module is the one that would execute under cargo test on your own machine, and it is there so the shape you will actually write in a real project is the shape you have been reading. The capstone's taskwork does exactly this, and says so.

Capstone milestone

Milestone — the test battery. The capstone's taskwork CLI parses task lines and filters them, and both halves are pinned by checks: a #[cfg(test)] mod tests for the vocabulary, and a set of assertions inside main that actually execute on this page's Run button. The build task you just finished is that battery in miniature. Confirm you can write a check that a wrong answer cannot get past.

Hint: You do not need the capstone's task model yet — this confirms the assertion discipline it rests on. In taskwork, the #[test] functions cover the parser and the priority filter, while the main battery is what proves to you, here, that the program you are reading actually works.

  • Wrote a check with assert_eq! against an exact expected value rather than assert! against a property like is_some
  • Chose at least one input for the awkward case — a missing separator, padding, or a list with no match at all — and can say which wrong implementation it rules out
  • Read a real assertion failure and can name its four parts: the location, which macro failed, your custom message, and the left and right values
  • Can say why a check that must execute on this page has to live in fn main rather than in a #[cfg(test)] module
Continue learning

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.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.