Capstone 2: testing the tracker

The tracker has four layers and each one can be tested without the others: the model needs no file, the storage needs a file but not a terminal, the output is a string, and the status is a number. The tests of this lesson follow those boundaries, and each section adds one group to the same file.

A test suite is a contract in executable form: it says which promises the layers keep, and it fails when a refactor breaks one. The four groups below are the four boundaries of the program, and each one can fail alone, which is the property that makes a failure easy to read.

The tests live in a #[cfg(test)] mod tests at the end of src/main.rs and run with cargo test. A binary crate can have a test module, and the module sees every private item of the crate, which is why no function has to be public for the tests.

The model without a file

The first four tests are the rules of the model: the title is trimmed, an empty title is refused, a task completes once, and the description shows the state and the due day. None of them touches the file system, so they run in microseconds and never interfere with each other.

use std::process::ExitCode; #[derive(Debug, Clone, Copy, PartialEq)] enum State { Open, Done, } #[derive(Debug, Clone, PartialEq)] struct Task { id: u32, title: String, state: State, due: Option<u32>, } #[derive(Debug, PartialEq)] #[allow(dead_code)] enum ModelError { EmptyTitle, AlreadyDone, AlreadyOpen, } #[allow(dead_code)] impl Task { fn new(id: u32, title: &str, due: Option<u32>) -> Result<Task, ModelError> { if title.trim().is_empty() { return Err(ModelError::EmptyTitle); } Ok(Task { id, title: title.trim().to_string(), state: State::Open, due, }) } fn complete(&mut self) -> Result<(), ModelError> { if self.state == State::Done { return Err(ModelError::AlreadyDone); } self.state = State::Done; Ok(()) } fn reopen(&mut self) -> Result<(), ModelError> { if self.state == State::Open { return Err(ModelError::AlreadyOpen); } self.state = State::Open; Ok(()) } fn describe(&self) -> String { let state = match self.state { State::Open => "open", State::Done => "done", }; match self.due { Some(due) => format!("#{} {} [{}] due {}", self.id, self.title, state, due), None => format!("#{} {} [{}] no due date", self.id, self.title, state), } } } fn state_name(state: State) -> &'static str { match state { State::Open => "open", State::Done => "done", } } #[allow(dead_code)] fn save(path: &str, tasks: &[Task]) -> std::io::Result<()> { let mut out = String::new(); for task in tasks { let due = task.due.map(|day| day.to_string()).unwrap_or_default(); out.push_str(&format!("{}|{}|{}|{}\n", task.id, state_name(task.state), due, task.title)); } std::fs::write(path, out) } #[allow(dead_code)] fn parse_line(line: &str) -> Option<Task> { let mut parts = line.split('|'); let id: u32 = parts.next()?.parse().ok()?; let state = match parts.next()? { "open" => State::Open, "done" => State::Done, _ => return None, }; let due_text = parts.next()?; let due = if due_text.is_empty() { None } else { Some(due_text.parse().ok()?) }; let title = parts.next()?; let mut task = Task::new(id, title, due).ok()?; if state == State::Done { task.complete().ok()?; } Some(task) } #[allow(dead_code)] fn load(path: &str) -> std::io::Result<(Vec<Task>, usize)> { let text = std::fs::read_to_string(path)?; let mut tasks = Vec::new(); let mut skipped = 0; for line in text.lines() { match parse_line(line) { Some(task) => tasks.push(task), None => skipped += 1, } } Ok((tasks, skipped)) } #[derive(Debug, Clone, PartialEq)] enum Command { List, Add { title: String, due: Option<u32> }, Done { id: u32 }, Help, } fn parse_args(args: &[String]) -> Result<Command, String> { let mut rest = args.iter(); let name = match rest.next() { Some(name) => name.as_str(), None => return Ok(Command::Help), }; match name { "list" => Ok(Command::List), "help" => Ok(Command::Help), "add" => { let title = rest.next().ok_or("add needs a title")?.clone(); let mut due = None; while let Some(flag) = rest.next() { match flag.as_str() { "--due" => { let value = rest.next().ok_or("--due needs a day")?; due = Some(value.parse().map_err(|_| format!("bad day: {value}"))?); } other => return Err(format!("unknown option {other}")), } } Ok(Command::Add { title, due }) } "done" => { let value = rest.next().ok_or("done needs an id")?; let id = value.parse().map_err(|_| format!("bad id: {value}"))?; Ok(Command::Done { id }) } other => Err(format!("unknown command {other}")), } } #[derive(Debug)] enum AppError { Usage(String), Model(ModelError), Missing(u32), Io(std::io::Error), } impl std::fmt::Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AppError::Usage(message) => write!(f, "{message}"), AppError::Model(ModelError::EmptyTitle) => write!(f, "a task needs a title"), AppError::Model(ModelError::AlreadyDone) => write!(f, "the task is already done"), AppError::Model(ModelError::AlreadyOpen) => write!(f, "the task is already open"), AppError::Missing(id) => write!(f, "no task {id}"), AppError::Io(error) => write!(f, "file error: {error}"), } } } impl From<ModelError> for AppError { fn from(error: ModelError) -> AppError { AppError::Model(error) } } impl From<std::io::Error> for AppError { fn from(error: std::io::Error) -> AppError { AppError::Io(error) } } impl AppError { fn status(&self) -> u8 { match self { AppError::Usage(_) => 2, _ => 1, } } } fn load_or_empty(path: &str) -> Vec<Task> { match load(path) { Ok((tasks, skipped)) => { if skipped > 0 { eprintln!("skipped {skipped} lines"); } tasks } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(error) => { eprintln!("cannot read {path}: {error}"); Vec::new() } } } fn next_id(tasks: &[Task]) -> u32 { tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1 } fn run(command: Command, path: &str) -> Result<(), AppError> { let mut tasks = load_or_empty(path); match command { Command::List => { print!("{}", render(&tasks)); } Command::Add { title, due } => { let id = next_id(&tasks); tasks.push(Task::new(id, &title, due)?); save(path, &tasks)?; println!("added {id}"); } Command::Done { id } => match tasks.iter_mut().find(|task| task.id == id) { Some(task) => { task.complete()?; save(path, &tasks)?; println!("done {id}"); } None => return Err(AppError::Missing(id)), }, Command::Help => print_help(), } Ok(()) } fn render(tasks: &[Task]) -> String { let mut out = String::new(); for task in tasks { out.push_str(&task.describe()); out.push('\n'); } out.push_str(&format!("{} tasks\n", tasks.len())); out } fn print_help() { println!("tasks list"); println!("tasks add <title> [--due <day>]"); println!("tasks done <id>"); println!("tasks help"); } fn main() -> ExitCode { let args: Vec<String> = std::env::args().skip(1).collect(); match parse_args(&args).map_err(AppError::Usage).and_then(|command| run(command, "tasks.txt")) { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("error: {error}"); ExitCode::from(error.status()) } } } #[cfg(test)] mod tests { use super::*; #[test] fn trims_the_title() { let task = Task::new(1, " write the model ", Some(110)).unwrap(); assert_eq!(task.title, "write the model"); assert_eq!(task.state, State::Open); } #[test] fn refuses_an_empty_title() { assert_eq!(Task::new(1, " ", None).unwrap_err(), ModelError::EmptyTitle); } #[test] fn completes_once() { let mut task = Task::new(1, "a", None).unwrap(); assert!(task.complete().is_ok()); assert_eq!(task.complete(), Err(ModelError::AlreadyDone)); assert_eq!(task.state, State::Done); } #[test] fn describes_a_task() { let mut task = Task::new(1, "write the model", Some(110)).unwrap(); assert_eq!(task.describe(), "#1 write the model [open] due 110"); task.complete().unwrap(); assert_eq!(task.describe(), "#1 write the model [done] due 110"); } }

cargo test

running 4 tests
test tests::completes_once ... ok
test tests::describes_a_task ... ok
test tests::refuses_an_empty_title ... ok
test tests::trims_the_title ... ok

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

The compiler lines that come before this block are left out; they name the profile and the test binary, and they say nothing about the tests. The four names are the four rules, so a failure in the run names the rule that broke rather than a line number.

The lesson on unit tests covers the attributes and the output of cargo test in full, including a filtered run and a failing one.

The storage through a temporary file

A storage test needs a file and must not use the real one. Each test builds a path from the process id and its own name, writes it, reads it back and removes it, so two tests never share a file and a failed test leaves nothing behind that the next run would read.

The three tests cover the round trip, the corrupt line and the missing file. The second one writes its input directly with std::fs::write, which is the only way to produce a file that save would never write.

use std::process::ExitCode; #[derive(Debug, Clone, Copy, PartialEq)] enum State { Open, Done, } #[derive(Debug, Clone, PartialEq)] struct Task { id: u32, title: String, state: State, due: Option<u32>, } #[derive(Debug, PartialEq)] #[allow(dead_code)] enum ModelError { EmptyTitle, AlreadyDone, AlreadyOpen, } #[allow(dead_code)] impl Task { fn new(id: u32, title: &str, due: Option<u32>) -> Result<Task, ModelError> { if title.trim().is_empty() { return Err(ModelError::EmptyTitle); } Ok(Task { id, title: title.trim().to_string(), state: State::Open, due, }) } fn complete(&mut self) -> Result<(), ModelError> { if self.state == State::Done { return Err(ModelError::AlreadyDone); } self.state = State::Done; Ok(()) } fn reopen(&mut self) -> Result<(), ModelError> { if self.state == State::Open { return Err(ModelError::AlreadyOpen); } self.state = State::Open; Ok(()) } fn describe(&self) -> String { let state = match self.state { State::Open => "open", State::Done => "done", }; match self.due { Some(due) => format!("#{} {} [{}] due {}", self.id, self.title, state, due), None => format!("#{} {} [{}] no due date", self.id, self.title, state), } } } fn state_name(state: State) -> &'static str { match state { State::Open => "open", State::Done => "done", } } #[allow(dead_code)] fn save(path: &str, tasks: &[Task]) -> std::io::Result<()> { let mut out = String::new(); for task in tasks { let due = task.due.map(|day| day.to_string()).unwrap_or_default(); out.push_str(&format!("{}|{}|{}|{}\n", task.id, state_name(task.state), due, task.title)); } std::fs::write(path, out) } #[allow(dead_code)] fn parse_line(line: &str) -> Option<Task> { let mut parts = line.split('|'); let id: u32 = parts.next()?.parse().ok()?; let state = match parts.next()? { "open" => State::Open, "done" => State::Done, _ => return None, }; let due_text = parts.next()?; let due = if due_text.is_empty() { None } else { Some(due_text.parse().ok()?) }; let title = parts.next()?; let mut task = Task::new(id, title, due).ok()?; if state == State::Done { task.complete().ok()?; } Some(task) } #[allow(dead_code)] fn load(path: &str) -> std::io::Result<(Vec<Task>, usize)> { let text = std::fs::read_to_string(path)?; let mut tasks = Vec::new(); let mut skipped = 0; for line in text.lines() { match parse_line(line) { Some(task) => tasks.push(task), None => skipped += 1, } } Ok((tasks, skipped)) } #[derive(Debug, Clone, PartialEq)] enum Command { List, Add { title: String, due: Option<u32> }, Done { id: u32 }, Help, } fn parse_args(args: &[String]) -> Result<Command, String> { let mut rest = args.iter(); let name = match rest.next() { Some(name) => name.as_str(), None => return Ok(Command::Help), }; match name { "list" => Ok(Command::List), "help" => Ok(Command::Help), "add" => { let title = rest.next().ok_or("add needs a title")?.clone(); let mut due = None; while let Some(flag) = rest.next() { match flag.as_str() { "--due" => { let value = rest.next().ok_or("--due needs a day")?; due = Some(value.parse().map_err(|_| format!("bad day: {value}"))?); } other => return Err(format!("unknown option {other}")), } } Ok(Command::Add { title, due }) } "done" => { let value = rest.next().ok_or("done needs an id")?; let id = value.parse().map_err(|_| format!("bad id: {value}"))?; Ok(Command::Done { id }) } other => Err(format!("unknown command {other}")), } } #[derive(Debug)] enum AppError { Usage(String), Model(ModelError), Missing(u32), Io(std::io::Error), } impl std::fmt::Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AppError::Usage(message) => write!(f, "{message}"), AppError::Model(ModelError::EmptyTitle) => write!(f, "a task needs a title"), AppError::Model(ModelError::AlreadyDone) => write!(f, "the task is already done"), AppError::Model(ModelError::AlreadyOpen) => write!(f, "the task is already open"), AppError::Missing(id) => write!(f, "no task {id}"), AppError::Io(error) => write!(f, "file error: {error}"), } } } impl From<ModelError> for AppError { fn from(error: ModelError) -> AppError { AppError::Model(error) } } impl From<std::io::Error> for AppError { fn from(error: std::io::Error) -> AppError { AppError::Io(error) } } impl AppError { fn status(&self) -> u8 { match self { AppError::Usage(_) => 2, _ => 1, } } } fn load_or_empty(path: &str) -> Vec<Task> { match load(path) { Ok((tasks, skipped)) => { if skipped > 0 { eprintln!("skipped {skipped} lines"); } tasks } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(error) => { eprintln!("cannot read {path}: {error}"); Vec::new() } } } fn next_id(tasks: &[Task]) -> u32 { tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1 } fn run(command: Command, path: &str) -> Result<(), AppError> { let mut tasks = load_or_empty(path); match command { Command::List => { print!("{}", render(&tasks)); } Command::Add { title, due } => { let id = next_id(&tasks); tasks.push(Task::new(id, &title, due)?); save(path, &tasks)?; println!("added {id}"); } Command::Done { id } => match tasks.iter_mut().find(|task| task.id == id) { Some(task) => { task.complete()?; save(path, &tasks)?; println!("done {id}"); } None => return Err(AppError::Missing(id)), }, Command::Help => print_help(), } Ok(()) } fn render(tasks: &[Task]) -> String { let mut out = String::new(); for task in tasks { out.push_str(&task.describe()); out.push('\n'); } out.push_str(&format!("{} tasks\n", tasks.len())); out } fn print_help() { println!("tasks list"); println!("tasks add <title> [--due <day>]"); println!("tasks done <id>"); println!("tasks help"); } fn main() -> ExitCode { let args: Vec<String> = std::env::args().skip(1).collect(); match parse_args(&args).map_err(AppError::Usage).and_then(|command| run(command, "tasks.txt")) { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("error: {error}"); ExitCode::from(error.status()) } } } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; fn temp_path(name: &str) -> PathBuf { let mut path = std::env::temp_dir(); path.push(format!("tasks-{}-{}", std::process::id(), name)); path } fn sample() -> Vec<Task> { let mut done = Task::new(2, "read the tests", None).unwrap(); done.complete().unwrap(); vec![Task::new(1, "write the model", Some(110)).unwrap(), done] } #[test] fn saves_and_loads() { let path = temp_path("round-trip.txt"); let tasks = sample(); save(path.to_str().unwrap(), &tasks).unwrap(); let (loaded, skipped) = load(path.to_str().unwrap()).unwrap(); std::fs::remove_file(&path).unwrap(); assert_eq!(loaded, tasks); assert_eq!(skipped, 0); } #[test] fn counts_a_corrupt_line() { let path = temp_path("corrupt.txt"); std::fs::write(&path, "1|open|110|a\nbroken\n2|done||b\n").unwrap(); let (loaded, skipped) = load(path.to_str().unwrap()).unwrap(); std::fs::remove_file(&path).unwrap(); assert_eq!(loaded.len(), 2); assert_eq!(skipped, 1); } #[test] fn reports_a_missing_file() { let path = temp_path("absent.txt"); let error = load(path.to_str().unwrap()).unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::NotFound); } }

cargo test

running 3 tests
test tests::reports_a_missing_file ... ok
test tests::counts_a_corrupt_line ... ok
test tests::saves_and_loads ... ok

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

The round trip compares the whole vector of tasks, which works because Task derives PartialEq; the comparison is strict about the state and the due day, which is what a storage test has to be. The missing file test asserts the kind rather than the text, for the reason the previous lesson gave.

A test that left its file behind would pass on the first run and fail on the second, which is the most expensive kind of test to debug. The lesson on testing files and time builds the same helper with a directory per test, and the lesson on paths covers temp_dir.

The output without a terminal

The list command used to print inside its arm, which leaves nothing for a test to compare. The program now builds the text in render and prints it in one call, so the same function produces the text a user sees and the string a test asserts.

The two tests check a list of two tasks and the empty list. The expected string ends with a newline, because the last line of the output has one too.

use std::process::ExitCode; #[derive(Debug, Clone, Copy, PartialEq)] enum State { Open, Done, } #[derive(Debug, Clone, PartialEq)] struct Task { id: u32, title: String, state: State, due: Option<u32>, } #[derive(Debug, PartialEq)] #[allow(dead_code)] enum ModelError { EmptyTitle, AlreadyDone, AlreadyOpen, } #[allow(dead_code)] impl Task { fn new(id: u32, title: &str, due: Option<u32>) -> Result<Task, ModelError> { if title.trim().is_empty() { return Err(ModelError::EmptyTitle); } Ok(Task { id, title: title.trim().to_string(), state: State::Open, due, }) } fn complete(&mut self) -> Result<(), ModelError> { if self.state == State::Done { return Err(ModelError::AlreadyDone); } self.state = State::Done; Ok(()) } fn reopen(&mut self) -> Result<(), ModelError> { if self.state == State::Open { return Err(ModelError::AlreadyOpen); } self.state = State::Open; Ok(()) } fn describe(&self) -> String { let state = match self.state { State::Open => "open", State::Done => "done", }; match self.due { Some(due) => format!("#{} {} [{}] due {}", self.id, self.title, state, due), None => format!("#{} {} [{}] no due date", self.id, self.title, state), } } } fn state_name(state: State) -> &'static str { match state { State::Open => "open", State::Done => "done", } } #[allow(dead_code)] fn save(path: &str, tasks: &[Task]) -> std::io::Result<()> { let mut out = String::new(); for task in tasks { let due = task.due.map(|day| day.to_string()).unwrap_or_default(); out.push_str(&format!("{}|{}|{}|{}\n", task.id, state_name(task.state), due, task.title)); } std::fs::write(path, out) } #[allow(dead_code)] fn parse_line(line: &str) -> Option<Task> { let mut parts = line.split('|'); let id: u32 = parts.next()?.parse().ok()?; let state = match parts.next()? { "open" => State::Open, "done" => State::Done, _ => return None, }; let due_text = parts.next()?; let due = if due_text.is_empty() { None } else { Some(due_text.parse().ok()?) }; let title = parts.next()?; let mut task = Task::new(id, title, due).ok()?; if state == State::Done { task.complete().ok()?; } Some(task) } #[allow(dead_code)] fn load(path: &str) -> std::io::Result<(Vec<Task>, usize)> { let text = std::fs::read_to_string(path)?; let mut tasks = Vec::new(); let mut skipped = 0; for line in text.lines() { match parse_line(line) { Some(task) => tasks.push(task), None => skipped += 1, } } Ok((tasks, skipped)) } #[derive(Debug, Clone, PartialEq)] enum Command { List, Add { title: String, due: Option<u32> }, Done { id: u32 }, Help, } fn parse_args(args: &[String]) -> Result<Command, String> { let mut rest = args.iter(); let name = match rest.next() { Some(name) => name.as_str(), None => return Ok(Command::Help), }; match name { "list" => Ok(Command::List), "help" => Ok(Command::Help), "add" => { let title = rest.next().ok_or("add needs a title")?.clone(); let mut due = None; while let Some(flag) = rest.next() { match flag.as_str() { "--due" => { let value = rest.next().ok_or("--due needs a day")?; due = Some(value.parse().map_err(|_| format!("bad day: {value}"))?); } other => return Err(format!("unknown option {other}")), } } Ok(Command::Add { title, due }) } "done" => { let value = rest.next().ok_or("done needs an id")?; let id = value.parse().map_err(|_| format!("bad id: {value}"))?; Ok(Command::Done { id }) } other => Err(format!("unknown command {other}")), } } #[derive(Debug)] enum AppError { Usage(String), Model(ModelError), Missing(u32), Io(std::io::Error), } impl std::fmt::Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AppError::Usage(message) => write!(f, "{message}"), AppError::Model(ModelError::EmptyTitle) => write!(f, "a task needs a title"), AppError::Model(ModelError::AlreadyDone) => write!(f, "the task is already done"), AppError::Model(ModelError::AlreadyOpen) => write!(f, "the task is already open"), AppError::Missing(id) => write!(f, "no task {id}"), AppError::Io(error) => write!(f, "file error: {error}"), } } } impl From<ModelError> for AppError { fn from(error: ModelError) -> AppError { AppError::Model(error) } } impl From<std::io::Error> for AppError { fn from(error: std::io::Error) -> AppError { AppError::Io(error) } } impl AppError { fn status(&self) -> u8 { match self { AppError::Usage(_) => 2, _ => 1, } } } fn load_or_empty(path: &str) -> Vec<Task> { match load(path) { Ok((tasks, skipped)) => { if skipped > 0 { eprintln!("skipped {skipped} lines"); } tasks } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(error) => { eprintln!("cannot read {path}: {error}"); Vec::new() } } } fn next_id(tasks: &[Task]) -> u32 { tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1 } fn run(command: Command, path: &str) -> Result<(), AppError> { let mut tasks = load_or_empty(path); match command { Command::List => { print!("{}", render(&tasks)); } Command::Add { title, due } => { let id = next_id(&tasks); tasks.push(Task::new(id, &title, due)?); save(path, &tasks)?; println!("added {id}"); } Command::Done { id } => match tasks.iter_mut().find(|task| task.id == id) { Some(task) => { task.complete()?; save(path, &tasks)?; println!("done {id}"); } None => return Err(AppError::Missing(id)), }, Command::Help => print_help(), } Ok(()) } fn render(tasks: &[Task]) -> String { let mut out = String::new(); for task in tasks { out.push_str(&task.describe()); out.push('\n'); } out.push_str(&format!("{} tasks\n", tasks.len())); out } fn print_help() { println!("tasks list"); println!("tasks add <title> [--due <day>]"); println!("tasks done <id>"); println!("tasks help"); } fn main() -> ExitCode { let args: Vec<String> = std::env::args().skip(1).collect(); match parse_args(&args).map_err(AppError::Usage).and_then(|command| run(command, "tasks.txt")) { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("error: {error}"); ExitCode::from(error.status()) } } } #[cfg(test)] mod tests { use super::*; #[test] fn renders_a_list() { let mut done = Task::new(2, "read the tests", None).unwrap(); done.complete().unwrap(); let tasks = vec![Task::new(1, "write the model", Some(110)).unwrap(), done]; let text = render(&tasks); assert_eq!( text, "#1 write the model [open] due 110\n#2 read the tests [done] no due date\n2 tasks\n" ); } #[test] fn renders_an_empty_list() { assert_eq!(render(&[]), "0 tasks\n"); } }

cargo test

running 2 tests
test tests::renders_a_list ... ok
test tests::renders_an_empty_list ... ok

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

A printer that writes directly to stdout is untestable in a unit test, because the test has no way to read what was written without a pipe around the whole process. The change from println! to a function that returns a String is small and turns the output into data. Comparing a whole text is a strict test and that is the point: a changed space, a changed order of the fields or a missing count fails it, and the diff shows exactly what moved. The cost is that every intentional change of the format edits the expected string, which is the review the change deserves.

The empty list is the case that a hand-written printer usually gets wrong, with a stray header or a missing count. The lesson on printing covers print! against println!, and the lesson on formatting covers the format! that builds the string.

The status that lies

A test that checks only that an error happened passes even when the program exits with zero, and a pipeline then treats the failure as a success. The last group of tests asserts the status itself: two for a usage error, one for a failed command, and the message that goes with each.

The fourth test calls the real handler on a temporary path that does not exist, which exercises the parser, the dispatch and the error type in one line without a process or a terminal.

use std::process::ExitCode; #[derive(Debug, Clone, Copy, PartialEq)] enum State { Open, Done, } #[derive(Debug, Clone, PartialEq)] struct Task { id: u32, title: String, state: State, due: Option<u32>, } #[derive(Debug, PartialEq)] #[allow(dead_code)] enum ModelError { EmptyTitle, AlreadyDone, AlreadyOpen, } #[allow(dead_code)] impl Task { fn new(id: u32, title: &str, due: Option<u32>) -> Result<Task, ModelError> { if title.trim().is_empty() { return Err(ModelError::EmptyTitle); } Ok(Task { id, title: title.trim().to_string(), state: State::Open, due, }) } fn complete(&mut self) -> Result<(), ModelError> { if self.state == State::Done { return Err(ModelError::AlreadyDone); } self.state = State::Done; Ok(()) } fn reopen(&mut self) -> Result<(), ModelError> { if self.state == State::Open { return Err(ModelError::AlreadyOpen); } self.state = State::Open; Ok(()) } fn describe(&self) -> String { let state = match self.state { State::Open => "open", State::Done => "done", }; match self.due { Some(due) => format!("#{} {} [{}] due {}", self.id, self.title, state, due), None => format!("#{} {} [{}] no due date", self.id, self.title, state), } } } fn state_name(state: State) -> &'static str { match state { State::Open => "open", State::Done => "done", } } #[allow(dead_code)] fn save(path: &str, tasks: &[Task]) -> std::io::Result<()> { let mut out = String::new(); for task in tasks { let due = task.due.map(|day| day.to_string()).unwrap_or_default(); out.push_str(&format!("{}|{}|{}|{}\n", task.id, state_name(task.state), due, task.title)); } std::fs::write(path, out) } #[allow(dead_code)] fn parse_line(line: &str) -> Option<Task> { let mut parts = line.split('|'); let id: u32 = parts.next()?.parse().ok()?; let state = match parts.next()? { "open" => State::Open, "done" => State::Done, _ => return None, }; let due_text = parts.next()?; let due = if due_text.is_empty() { None } else { Some(due_text.parse().ok()?) }; let title = parts.next()?; let mut task = Task::new(id, title, due).ok()?; if state == State::Done { task.complete().ok()?; } Some(task) } #[allow(dead_code)] fn load(path: &str) -> std::io::Result<(Vec<Task>, usize)> { let text = std::fs::read_to_string(path)?; let mut tasks = Vec::new(); let mut skipped = 0; for line in text.lines() { match parse_line(line) { Some(task) => tasks.push(task), None => skipped += 1, } } Ok((tasks, skipped)) } #[derive(Debug, Clone, PartialEq)] enum Command { List, Add { title: String, due: Option<u32> }, Done { id: u32 }, Help, } fn parse_args(args: &[String]) -> Result<Command, String> { let mut rest = args.iter(); let name = match rest.next() { Some(name) => name.as_str(), None => return Ok(Command::Help), }; match name { "list" => Ok(Command::List), "help" => Ok(Command::Help), "add" => { let title = rest.next().ok_or("add needs a title")?.clone(); let mut due = None; while let Some(flag) = rest.next() { match flag.as_str() { "--due" => { let value = rest.next().ok_or("--due needs a day")?; due = Some(value.parse().map_err(|_| format!("bad day: {value}"))?); } other => return Err(format!("unknown option {other}")), } } Ok(Command::Add { title, due }) } "done" => { let value = rest.next().ok_or("done needs an id")?; let id = value.parse().map_err(|_| format!("bad id: {value}"))?; Ok(Command::Done { id }) } other => Err(format!("unknown command {other}")), } } #[derive(Debug)] enum AppError { Usage(String), Model(ModelError), Missing(u32), Io(std::io::Error), } impl std::fmt::Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AppError::Usage(message) => write!(f, "{message}"), AppError::Model(ModelError::EmptyTitle) => write!(f, "a task needs a title"), AppError::Model(ModelError::AlreadyDone) => write!(f, "the task is already done"), AppError::Model(ModelError::AlreadyOpen) => write!(f, "the task is already open"), AppError::Missing(id) => write!(f, "no task {id}"), AppError::Io(error) => write!(f, "file error: {error}"), } } } impl From<ModelError> for AppError { fn from(error: ModelError) -> AppError { AppError::Model(error) } } impl From<std::io::Error> for AppError { fn from(error: std::io::Error) -> AppError { AppError::Io(error) } } impl AppError { fn status(&self) -> u8 { match self { AppError::Usage(_) => 2, _ => 1, } } } fn load_or_empty(path: &str) -> Vec<Task> { match load(path) { Ok((tasks, skipped)) => { if skipped > 0 { eprintln!("skipped {skipped} lines"); } tasks } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(error) => { eprintln!("cannot read {path}: {error}"); Vec::new() } } } fn next_id(tasks: &[Task]) -> u32 { tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1 } fn run(command: Command, path: &str) -> Result<(), AppError> { let mut tasks = load_or_empty(path); match command { Command::List => { print!("{}", render(&tasks)); } Command::Add { title, due } => { let id = next_id(&tasks); tasks.push(Task::new(id, &title, due)?); save(path, &tasks)?; println!("added {id}"); } Command::Done { id } => match tasks.iter_mut().find(|task| task.id == id) { Some(task) => { task.complete()?; save(path, &tasks)?; println!("done {id}"); } None => return Err(AppError::Missing(id)), }, Command::Help => print_help(), } Ok(()) } fn render(tasks: &[Task]) -> String { let mut out = String::new(); for task in tasks { out.push_str(&task.describe()); out.push('\n'); } out.push_str(&format!("{} tasks\n", tasks.len())); out } fn print_help() { println!("tasks list"); println!("tasks add <title> [--due <day>]"); println!("tasks done <id>"); println!("tasks help"); } fn main() -> ExitCode { let args: Vec<String> = std::env::args().skip(1).collect(); match parse_args(&args).map_err(AppError::Usage).and_then(|command| run(command, "tasks.txt")) { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("error: {error}"); ExitCode::from(error.status()) } } } #[cfg(test)] mod tests { use super::*; #[test] fn status_of_usage_is_two() { assert_eq!(AppError::Usage("fly".to_string()).status(), 2); } #[test] fn status_of_a_failure_is_one() { assert_eq!(AppError::Missing(3).status(), 1); assert_eq!(AppError::Model(ModelError::EmptyTitle).status(), 1); } #[test] fn a_bad_command_is_a_usage_error() { let args: Vec<String> = ["fly"].iter().map(|item| item.to_string()).collect(); let error = parse_args(&args).map_err(AppError::Usage).unwrap_err(); assert_eq!(error.status(), 2); assert_eq!(error.to_string(), "unknown command fly"); } #[test] fn a_missing_task_is_a_failure() { let args: Vec<String> = ["done", "99"].iter().map(|item| item.to_string()).collect(); let command = parse_args(&args).map_err(AppError::Usage).unwrap(); let path = std::env::temp_dir().join(format!("tasks-{}-missing.txt", std::process::id())); let error = run(command, path.to_str().unwrap()).unwrap_err(); assert_eq!(error.status(), 1); assert_eq!(error.to_string(), "no task 99"); } }

cargo test

running 4 tests
test tests::a_bad_command_is_a_usage_error ... ok
test tests::a_missing_task_is_a_failure ... ok
test tests::status_of_a_failure_is_one ... ok
test tests::status_of_usage_is_two ... ok

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

A suite of thirteen tests over four hundred lines of code is a reasonable ratio for a tool of this size, and the tests that matter most are the two about the status: they are the only ones a script depends on without ever seeing them.

The tests are named after the behaviour, not after the function, so the run reads as a list of promises. A status test is the only place where the number two is written down twice, and that duplication is deliberate: the shell depends on it and a test is how the dependency is kept.

The lesson on assertions covers assert_eq! and the message it prints on a failure, and the next lesson starts the third capstone, a search index over a folder of documents.

Exercises

  1. Add a test for the batch of lesson 131 that checks both error messages.
  2. Give each storage test its own directory and remove it with remove_dir_all.
  3. Write a test that calls run with a list command on a temporary file and compares the rendered output.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. Why does the model group need no file?

    Answer

    The model functions take values and return values; only the storage layer knows about files

    The boundary between model and storage is what makes the tests fast and independent.

  2. Why does each storage test build its own path?

    Answer

    So two tests never share a file and a failed test leaves nothing to read

    Isolation is the property that makes a test suite repeatable.

  3. What does render make possible?

    Answer

    A test can compare the exact text a user would see

    A function that returns the text can be asserted; one that prints cannot.

  4. Why does a test assert the exit status?

    Answer

    A pipeline sees only the status, so a wrong number is a silent failure

    The number is the interface to the shell and deserves its own test.

  5. What does a whole-text comparison in a test catch that a field check does not?

    Answer

    A changed space, order or missing count in the output

    The exact string covers the formatting, which field checks ignore.

  6. What does this program print? fn temp_path(name: &str) -> String { format!("{}-{}", std::process::id(), name) } fn main() { let first = temp_path("a.txt"); let second = temp_path("b.txt"); println!("{}", first.ends_with("-a.txt")); println!("{}", first == second); }

    Answer

    true / false

    The name ends with the label and the two paths differ in that label.

  7. What does this program print? fn render(tasks: &[(u32, &str)]) -> String { let mut out = String::new(); for (id, title) in tasks { out.push_str(&format!("#{id} {title}\n")); } out.push_str(&format!("{} tasks\n", tasks.len())); out } fn main() { let text = render(&[(1, "a"), (2, "b")]); println!("{}", text.lines().count()); print!("{text}"); }

    Answer

    3 / #1 a / #2 b / 2 tasks

    Two task lines and the count line make three, and the text prints after the number.

  8. What does this program print? #[derive(Debug, PartialEq, Clone)] struct Task { id: u32, title: String, } fn main() { let mut tasks = vec![Task { id: 1, title: "a".to_string() }]; let copy = tasks.clone(); tasks[0].title.push('b'); println!("{} {}", tasks == copy, tasks[0].title); }

    Answer

    false ab

    The clone kept the old title, so the vectors differ after the push.

  9. What does this program print? fn kind(text: &str) -> &'static str { if text.contains("NotFound") { "missing" } else if text.is_empty() { "empty" } else { "other" } } fn main() { println!("{} {} {}", kind("NotFound"), kind(""), kind("denied")); }

    Answer

    missing empty other

    The first contains the word, the second is empty and the third matches neither.

  10. What does this program print? fn main() { let statuses = [2u8, 1, 1, 2]; let mut counts = [0usize; 3]; for status in statuses { counts[status as usize] += 1; } println!("{} {} {}", counts[0], counts[1], counts[2]); }

    Answer

    0 2 2

    Two ones and two twos land in the buckets one and two.

The code on this page was built with cargo 1.96.1 and rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev

Banner image