Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

Learn RustChapter 7: Errors and tests

Lesson 7.3 · 12 min

Writing tests

Chatterbox now has a dozen commands and a sentence for every mistake. Each time you change one line, any of them could quietly break, and checking them all by hand means typing every command again. A test is a promise you write down once, and the computer checks it for you every time, forever.

Adds to ChatterboxChatterbox gets its first tests.

Checking by hand does not last

Think about what you did after lesson 7.2. To be sure Chatterbox still worked, you would start it, type a name, then /say, /last two, /dance, a long message, a question, and read every answer. That takes a minute. Now imagine doing it after every change, for months. Nobody does, so sooner or later a sentence changes by accident and nobody notices.

A test is a small function that does that checking for you. It calls a piece of your program with an input you chose, and says what the answer must be. You write it once. After that, one command runs every test you have ever written, in about a second, and tells you if any promise is broken. The computer never gets bored and never skips one.

Here is the smallest useful test, on a tiny program separate from Chatterbox.

A program with one test. Running the program does not run the test.
fn shout(text: &str) -> String {
text.to_uppercase()
}
 
fn main() {
println!("{}", shout("hello"));
}
 
#[cfg(test)]
mod tests {
use super::*;
 
#[test]
fn shout_makes_capitals() {
assert_eq!(shout("hello"), "HELLO");
}
}
$ cargo run
HELLO

What each line means

The program printed HELLO and nothing else. The test is in the file but the program never ran it: tests have their own command, which you meet in a moment. Read the new lines from the top.

  • #[test] is an attribute: a label in #[...] that tells the compiler something about the item under it. This one says "this function is a test". A test takes nothing and usually gives nothing back (it may also return a Result<(), E>, so it can use ? from lesson 7.2; an Err then counts as a failure).
  • #[cfg(test)] says "only build what follows when testing" (cfg is short for configuration). Your finished program does not carry its tests around, so they cost it nothing.
  • mod tests { ... } puts the tests in a named box of their own, a module. Modules get a full lesson in chapter 8; for now it is the conventional place for tests, kept apart from the real code.
  • use super::*; reaches out of that box. super means "the code around this module" and * means "every name in it", so the tests can call shout as if they were next to it.
  • assert_eq!(left, right) is the check itself: the two values must be equal.

Here is the rule that makes all of it work. A test passes if its function finishes, and fails if it panics, the emergency stop you met with unwrap() on a None. The assert macros are simply tidy ways to panic with a useful message when something is wrong. Each test runs on its own thread, a separate line of work inside the program, so one test panicking is recorded and the rest carry on.

You writeIt panics (the test fails) when
assert!(x)x is false
assert_eq!(a, b)a and b differ; it prints both
assert!(matches!(v, pattern))v does not fit the pattern (lesson 5.5)
r.unwrap_err()r was an Ok: it gives you the reason inside an Err, the mirror image of unwrap()

The assert macros work in ordinary code too, which makes them easy to try. Guess what this program does.

PredictTwo promises about reply
fn reply(message: &str) -> String {
format!("you said: {message}")
}
 
fn main() {
assert_eq!(reply("hi"), "you said: hi");
assert!(reply("").is_empty());
println!("all good");
}

Does this compile — and if it does, does it run?

  • It compiles and runs
  • It compiles, then panics
  • Compile error E0308
Reveal the answer

It compiles, then panics at run time. The first promise holds. The second is wrong about the program: reply("") is "you said: ", which is not empty, so assert! panics and all good is never printed. That is a test doing its job: either the code or the promise is wrong, and now you know.

Running them: cargo test

Chatterbox gets its first tests in this lesson (you will see them below). In a terminal, in the project folder, the command is cargo test. This is a real run:

cargo test on Chatterbox v0.7 after this lesson (a real run; the folder path is shortened)
$ cargo test
   Compiling chatterbox v0.7.0 (~/chatterbox)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.71s
     Running unittests src/main.rs (target/debug/deps/chatterbox-b65eb7a24000c288)

running 7 tests
test tests::help_has_a_short_form ... ok
test tests::last_needs_a_number ... ok
test tests::plain_text_is_chat ... ok
test tests::reply_echoes_and_answers_questions ... ok
test tests::say_needs_text ... ok
test tests::reply_refuses_long_messages ... ok
test tests::unknown_commands_are_errors ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Read it from the top. Cargo builds a special version of the program with the tests switched on (that is what #[cfg(test)] waits for), then runs it. Each test gets one line: its full name, tests:: plus the function's name, and ok. The order is not the order in the file, because the tests run at the same time on several threads and report as they finish. The last line is the verdict: seven passed, none failed.

What a failing test looks like

A test earns its keep on the day it fails. So break something on purpose: change the word said to typed inside reply, the kind of small edit anyone might make while tidying, and run the tests again.

The same project with one word changed in reply (a real run)
$ cargo test
   Compiling chatterbox v0.7.0 (~/chatterbox)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.24s
     Running unittests src/main.rs (target/debug/deps/chatterbox-b65eb7a24000c288)

running 7 tests
test tests::help_has_a_short_form ... ok
test tests::plain_text_is_chat ... ok
test tests::last_needs_a_number ... ok
test tests::reply_refuses_long_messages ... ok
test tests::say_needs_text ... ok
test tests::unknown_commands_are_errors ... ok
test tests::reply_echoes_and_answers_questions ... FAILED

failures:

---- tests::reply_echoes_and_answers_questions stdout ----

thread 'tests::reply_echoes_and_answers_questions' (92541362) panicked at src/main.rs:387:9:
assertion `left == right` failed
  left: "you typed: hi"
 right: "you said: hi"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::reply_echoes_and_answers_questions

test result: FAILED. 6 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--bin chatterbox`

Six still pass, and one is named with FAILED. Underneath, the report says which test, the exact line of the assert (src/main.rs:387), and both sides of the comparison: left is what the code produced, right is what the test promised. You did not have to run Chatterbox or type anything. The broken sentence was found in a fraction of a second, by a line you wrote once.

Chatterbox v0.7: its first tests

How do you choose what to test? Start with the things you would otherwise check by hand: each promise the program makes to the person typing. For Chatterbox that is how a line is understood, and what the bot says back. The seven tests sit at the bottom of main.rs, under a comment:

  • plain_text_is_chat: ordinary text becomes Command::Chat holding that text. It uses matches! with an extra condition, if t == "hello there", after the pattern: the pattern must fit and the condition must be true.
  • help_has_a_short_form: /help and /h both mean Help.
  • say_needs_text, last_needs_a_number and unknown_commands_are_errors pin down the CommandError sentences from lesson 7.2. Each calls parse, takes the reason with unwrap_err(), turns it into text with to_string() (which uses the Display block) and compares it word for word.
  • reply_echoes_and_answers_questions and reply_refuses_long_messages check the three kinds of answer. The long message is made with "x".repeat(MAX_LEN + 1), text repeated that many times: one byte over the limit, written using the limit itself, so the test still means the same thing if MAX_LEN ever changes.

Nothing else in the program changed, and it behaves exactly as before: cargo run never sees the tests.

Chatterbox v0.7 after lesson 7.3: the whole program (green lines are new in this lesson)
src/main.rs47 new lines
// Chatterbox: a small chat program, built one lesson at a time.
use std::collections::HashMap;
use std::fmt;
use std::io;
 
/// What the bot is called. A constant: fixed for the whole program.
const BOT: &str = "Chatterbox";
/// The version as a pair of numbers: (major, minor).
const VERSION: (u32, u32) = (0, 7);
/// The longest message we accept, in bytes.
const MAX_LEN: usize = 200;
/// What /help prints.
const HELP: &str = "commands: /help, /again, /history, /count, /stats, /remember <name> <fact>, /recall <name>, /find <word>, /last <n>, /say <text>, /shout <text>, /debug <text>, /quit (or /h, /q, /exit)";
 
/// One line of the conversation: who said it, and what they said.
struct Message {
author: String,
text: String,
}
 
impl Message {
/// Make a Message from two pieces of borrowed text.
fn new(author: &str, text: &str) -> Message {
Message {
author: String::from(author),
text: String::from(text),
}
}
 
/// The message as one transcript line: "you: hello".
fn to_line(&self) -> String {
format!("{}: {}", self.author, self.text)
}
 
/// The text in capitals, for /shout.
fn shout(&self) -> String {
self.text.to_uppercase()
}
}
 
/// Everything a line of input can mean. One enum, so a `match` on it must
/// handle every kind of line: the compiler checks that nothing is forgotten.
#[derive(Debug)]
enum Command {
Empty,
Help,
Quit,
Again,
History,
Count,
Stats,
Remember(String, String),
Recall(Option<String>),
Find(String),
Last(usize),
Say(String),
Shout(String),
Debug(String),
Chat(String),
}
 
/// Everything that can be wrong with a line of input. parse() returns one of
/// these instead of a Command, and main prints it.
#[derive(Debug)]
enum CommandError {
Unknown(String),
Missing(String, String),
Usage(String),
NotACount(String),
}
 
/// How a CommandError reads when printed with {}.
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CommandError::Unknown(word) => {
write!(f, "I don't know the command {}. Try /help.", word)
}
CommandError::Missing(verb, example) => {
write!(f, "{} what? Try /{} {}", verb, verb, example)
}
CommandError::Usage(form) => write!(f, "try {}", form),
CommandError::NotACount(text) => write!(f, "'{}' is not a count. Try /last 3", text),
}
}
}
 
/// The text after a command word, or the error that says it is missing.
fn need(line: &str, verb: &str, example: &str) -> Result<String, CommandError> {
match arg(line) {
Some(text) => Ok(String::from(text)),
None => Err(CommandError::Missing(
String::from(verb),
String::from(example),
)),
}
}
 
/// How many messages /last should show. "two" or "-1" is an error.
fn parse_count(text: &str) -> Result<usize, CommandError> {
match text.parse::<usize>() {
Ok(n) => Ok(n),
Err(_) => Err(CommandError::NotACount(String::from(text))),
}
}
 
/// Turn one line of input into a Command, or say what is wrong with it.
/// Each `?` means: if this step failed, return its error right here.
fn parse(line: &str) -> Result<Command, CommandError> {
let cmd = match first_word(line) {
"" => Command::Empty,
"/help" | "/h" => Command::Help,
"/quit" | "/q" | "/exit" => Command::Quit,
"/again" => Command::Again,
"/history" => Command::History,
"/count" => Command::Count,
"/stats" => Command::Stats,
"/remember" => {
let usage = CommandError::Usage(String::from("/remember <name> <fact>"));
let rest = match arg(line) {
Some(rest) => rest,
None => return Err(usage),
};
match arg(rest) {
Some(fact) => Command::Remember(String::from(first_word(rest)), String::from(fact)),
None => return Err(usage),
}
}
"/recall" => match arg(line) {
Some(name) => Command::Recall(Some(String::from(name))),
None => Command::Recall(None),
},
"/find" => Command::Find(need(line, "find", "hello")?),
"/last" => Command::Last(parse_count(&need(line, "last", "3")?)?),
"/debug" => Command::Debug(need(line, "debug", "/say hi")?),
"/say" => Command::Say(need(line, "say", "hello")?),
"/shout" => Command::Shout(need(line, "shout", "hello")?),
word => {
if is_command(word) {
return Err(CommandError::Unknown(String::from(word)));
}
Command::Chat(String::from(line))
}
};
Ok(cmd)
}
 
/// The first line Chatterbox prints.
fn banner() -> String {
format!("{} v{}.{}", BOT, VERSION.0, VERSION.1)
}
 
/// The greeting, with a name filled in.
fn greeting(name: &str) -> String {
let mut text = format!("Hi {}! I'm {}.", name, BOT);
text.push_str(" Ask me anything.");
text
}
 
/// True when the text is a command: it starts with a slash.
fn is_command(text: &str) -> bool {
text.starts_with('/')
}
 
/// The first word of the text: a slice (&str) pointing into it, not a copy.
fn first_word(text: &str) -> &str {
match text.find(' ') {
Some(i) => &text[..i],
None => text,
}
}
 
/// The text after the command word, if there is any: Some("hello") for
/// "/say hello", None for a bare "/say". No more "" standing in for nothing.
fn arg(line: &str) -> Option<&str> {
match line.find(' ') {
Some(i) => {
let rest = line[i + 1..].trim();
if rest.is_empty() { None } else { Some(rest) }
}
None => None,
}
}
 
/// What the bot says back to an ordinary message (not a command).
fn reply(message: &str) -> String {
if message.len() > MAX_LEN {
format!("that is too long (the limit is {} bytes)", MAX_LEN)
} else if message.ends_with('?') {
String::from("Good question! I can only echo for now. Ask me again in chapter 9.")
} else {
format!("you said: {}", message)
}
}
 
fn main() {
let mut messages_seen: u32 = 0;
let quiet = false;
let mut last: Option<String> = None; // the last thing you said, for /again
let mut history: Vec<Message> = Vec::new(); // everything said so far, in order
let mut facts: HashMap<String, String> = HashMap::new(); // name -> fact, for /recall
 
println!("{}", banner());
println!("What should I call you?");
let mut name_line = String::new();
io::stdin().read_line(&mut name_line).unwrap();
let name = name_line.trim();
if !quiet {
println!("{}", greeting(name));
}
 
let mut line = String::new();
loop {
line.clear();
let bytes = io::stdin().read_line(&mut line).unwrap();
if bytes == 0 {
break; // no more input: the keyboard was closed
}
let message = line.trim();
let cmd = match parse(message) {
Ok(cmd) => cmd,
Err(e) => {
println!("bot: {}", e);
continue;
}
};
match cmd {
Command::Empty => {} // nothing typed: nothing to answer
Command::Quit => {
println!("bot: bye, {}!", name);
break;
}
Command::Help => println!("bot: {}", HELP),
Command::Again => {
if let Some(text) = &last {
println!("bot: {}", reply(text));
} else {
println!("bot: nothing to repeat yet");
}
}
Command::History => {
if history.is_empty() {
println!("bot: nothing said yet");
} else {
for (i, m) in history.iter().enumerate() {
println!("{}. {}", i + 1, m.to_line());
}
}
}
Command::Count => {
let bytes: usize = history.iter().map(|m| m.text.len()).sum();
println!("bot: {} messages, {} bytes of text", history.len(), bytes);
}
Command::Stats => {
// One chain of steps: your messages -> their words -> a count per word.
let mut counts: HashMap<String, u32> = HashMap::new();
for word in history
.iter()
.filter(|m| m.author == "you")
.flat_map(|m| m.text.split_whitespace())
{
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
let mut top: Vec<(String, u32)> = counts.into_iter().collect();
// most used first; ties in alphabetical order, so the answer never changes
top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let words: Vec<String> = top
.iter()
.take(3)
.map(|(word, n)| format!("{} ({})", word, n))
.collect();
if words.is_empty() {
println!("bot: nothing said yet");
} else {
println!("bot: your top words: {}", words.join(", "));
}
}
Command::Remember(key, fact) => {
println!("bot: got it: {} is {}", key, fact);
facts.insert(key, fact);
}
Command::Recall(Some(key)) => match facts.get(&key) {
Some(fact) => println!("bot: {} is {}", key, fact),
None => println!("bot: I don't know anything about {}", key),
},
Command::Recall(None) => {
if facts.is_empty() {
println!("bot: I don't remember anything yet. Try /remember");
} else {
// A HashMap has no order, so sort the names before printing them.
let mut names = Vec::new();
for name in facts.keys() {
names.push(name.as_str());
}
names.sort();
println!("bot: I remember: {}", names.join(", "));
}
}
Command::Find(word) => {
// The closure borrows `word` from here: a small function made on the spot.
let hits: Vec<&Message> = history
.iter()
.filter(|m| m.author == "you" && m.text.contains(&word))
.collect();
if hits.is_empty() {
println!("bot: you never said {}", word);
} else {
for m in hits {
println!("{}", m.to_line());
}
}
}
Command::Last(n) => {
// Start n messages from the end, or at the beginning if there are fewer.
let start = if n >= history.len() {
0
} else {
history.len() - n
};
for m in &history[start..] {
println!("{}", m.to_line());
}
}
Command::Say(text) => println!("bot: {}", text),
Command::Shout(text) => println!("bot: {}", Message::new("you", &text).shout()),
Command::Debug(text) => println!("bot: {:?}", parse(&text)),
Command::Chat(text) => {
messages_seen += 1;
let said = Message::new("you", &text);
let answer = Message::new("bot", &reply(&said.text));
println!("bot: {}", answer.text);
history.push(said);
history.push(answer);
// Message::new made the history its own copy, so `text` itself can
// be handed to `last` for /again: no extra copy needed.
last = Some(text);
}
}
}
println!("messages so far: {}", messages_seen);
}
 
// Tests: small functions that check the program does what we say it does.
// `cargo test` builds them and runs every one; the program itself never sees them.
#[cfg(test)]
mod tests {
use super::*;
 
#[test]
fn plain_text_is_chat() {
assert!(matches!(parse("hello there"), Ok(Command::Chat(t)) if t == "hello there"));
}
 
#[test]
fn help_has_a_short_form() {
assert!(matches!(parse("/help"), Ok(Command::Help)));
assert!(matches!(parse("/h"), Ok(Command::Help)));
}
 
#[test]
fn say_needs_text() {
assert_eq!(
parse("/say").unwrap_err().to_string(),
"say what? Try /say hello"
);
}
 
#[test]
fn last_needs_a_number() {
assert!(matches!(parse("/last 3"), Ok(Command::Last(3))));
assert_eq!(
parse("/last two").unwrap_err().to_string(),
"'two' is not a count. Try /last 3"
);
}
 
#[test]
fn unknown_commands_are_errors() {
assert_eq!(
parse("/dance").unwrap_err().to_string(),
"I don't know the command /dance. Try /help."
);
}
 
#[test]
fn reply_echoes_and_answers_questions() {
assert_eq!(reply("hi"), "you said: hi");
assert!(reply("are you there?").starts_with("Good question!"));
}
 
#[test]
fn reply_refuses_long_messages() {
let long = "x".repeat(MAX_LEN + 1);
assert!(reply(&long).starts_with("that is too long"));
}
}
You type
Sam
first
second
/last 2
/last two
/last
/last 99
/quit
Chatterbox prints (a real run)
Chatterbox v0.7
What should I call you?
Hi Sam! I'm Chatterbox. Ask me anything.
bot: you said: first
bot: you said: second
you: second
bot: you said: second
bot: 'two' is not a count. Try /last 3
bot: last what? Try /last 3
you: first
bot: you said: first
you: second
bot: you said: second
bot: bye, Sam!
messages so far: 2

Download this version (zip)Unzip, then cargo run inside the folder.

Try itYour turn: write a function and its tests

Chatterbox might one day notice when you are shouting. Write is_shouting: it is true when the text has at least one letter and none of its letters are lowercase, so "STOP!" is shouting but "Stop" and "123!" are not. Then finish the two tests at the bottom, which say todo!() for now: each should check the case its comment describes. The hidden tests check your function; your own tests have to pass too.

Chatterbox might one day notice when you are shouting. Write is_shouting: it is true when the text has at least one letter and none of its letters are lowercase, so "STOP!" is shouting but "Stop" and "123!" are not. Then finish the two tests at the bottom, which say todo!() for now: each should check the case its comment describes. The hidden tests check your function; your own tests have to pass too.

fn is_shouting(text: &str) -> bool {
todo!()
}
 
fn main() {
println!("{}", is_shouting("STOP THAT!"));
}
 
#[cfg(test)]
mod tests {
use super::*;
 
#[test]
fn capitals_are_shouting() {
assert!(is_shouting("STOP"));
}
 
#[test]
fn mixed_case_is_not_shouting() {
todo!() // check that "Stop that" is not shouting
}
 
#[test]
fn no_letters_is_not_shouting() {
todo!() // check that "123!" is not shouting
}
}

Hidden tests (7): check_empty_text_is_not_shouting check_no_letters_is_not_shouting check_shouting_with_spaces_and_marks check_one_small_letter_is_enough_to_stop_it capitals_are_shouting no_letters_is_not_shouting mixed_case_is_not_shouting

This exercise runs real Rust in your browser through the official Rust Playground, so it needs JavaScript.

Self-check

Say it out loud, then reveal

Self-checkRetrieval beats re-reading
What makes a test pass or fail?Reveal
A test passes if its function finishes and fails if it panics. assert! and assert_eq! panic when their promise is broken.
Why are tests put inside <code>#[cfg(test)] mod tests</code>?Reveal
So they are only built by cargo test. The finished program does not carry them, and use super::*; lets them call the code around them.
In a failed <code>assert_eq!</code>, what are left and right?Reveal
The two values that were compared: here, what the code produced and what the test promised.
What does <code>unwrap_err()</code> give you?Reveal
The reason inside an Err. If the value was an Ok it panics, which in a test means the test fails.

That is lesson 7.3. Tick it off so the course remembers where you are.

Questions people ask

How do I write a test in Rust?

Write a function with #[test] above it, call the code you want to check, and use assert_eq! or assert! to say what the answer must be. Put tests in a module marked #[cfg(test)], usually called tests.

How do I run tests in Rust?

Run cargo test in the project folder. It builds the program with the tests switched on, runs every test and prints ok or FAILED for each, then a summary line.

What does cfg(test) mean in Rust?

It tells the compiler to build the code that follows only when testing. Your normal program, built with cargo run or cargo build, leaves the tests out.

What does use super::* do inside a tests module?

It brings every name from the surrounding code into the tests module, so the tests can call your functions directly by name.

Why do my Rust tests run in a different order each time?

cargo test runs tests at the same time on several threads, so they finish and report in whatever order they happen to complete. Each test must not depend on another one running first.

Learn Rust by building a chat app · Lesson 7.3 · every compiler message above is real output from rustc 1.99.0 (b940084d7 2026-09-28) (edition 2024) · Vibe Engines · 2026