Rust in Practice #6 Testing: From Parser Unit Tests to CLI Integration Tests
Parser (Part 3), aggregation (Part 4), parallelism (Part 5) — with the features stacked up, it is time to install a safety net before next part’s major surgery (release optimization, cross-compilation). Rust ships its test runner with the language, so there is no framework selection phase. We start with cargo test and nothing else.
Unit tests: verification that lives next to the code #
Rust unit tests live in the same file as their subject. We attach them under the parser module.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_normal_line() {
let line = r#"203.0.113.9 - - [23/Aug/2026:10:12:01 +0900] "GET /api/users HTTP/1.1" 200 512"#;
let entry = parse_line(line).unwrap();
assert_eq!(entry.ip, "203.0.113.9");
assert_eq!(entry.method, "GET");
assert_eq!(entry.path, "/api/users");
assert_eq!(entry.status, 200);
assert_eq!(entry.bytes, 512);
}
#[test]
fn dash_bytes_becomes_zero() {
let line = r#"203.0.113.9 - - [23/Aug/2026:10:12:01 +0900] "HEAD / HTTP/1.1" 301 -"#;
assert_eq!(parse_line(line).unwrap().bytes, 0);
}
#[test]
fn empty_line_is_empty_error() {
assert!(matches!(parse_line(" "), Err(ParseError::Empty)));
}
#[test]
fn garbage_is_rejected() {
assert!(parse_line("a line in a totally different format").is_err());
}
}#[cfg(test)] is a conditional-compilation directive — this module compiles only under cargo test — so no test code enters the shipped binary. Case selection is the real substance here: one normal line; the - bytes value we decided in Part 3 to accept leniently; the empty line we decided “fails, but doesn’t count as a failure”; and outright garbage. Every design decision made while building the parser becomes documented as a test case. When the kind of error matters, the matches! macro checks a pattern like Err(ParseError::Empty) directly (basics Part 5’s pattern matching working in tests too). And the foreshadowing pays off: deriving PartialEq on LogEntry in Part 3 was preparation for assert_eq!.
Worth restating from basics Part 6: unwrap is legitimate in tests. In a test, a failure is a test failure — which is exactly the desired behavior.
Integration tests: running from the user’s seat #
If unit tests are parts inspection, integration tests are finished-product inspection. Files in the project root’s tests/ directory compile as separate binaries that use the crate under the same conditions as an outside user. For a CLI tool, finished-product inspection means “actually run it and look at the output,” and the tool for that is assert_cmd.
cargo add --dev assert_cmd predicates tempfile--dev marks these as development/test-only dependencies, unrelated to the shipped binary.
// tests/cli.rs
use assert_cmd::Command;
use predicates::prelude::*;
use std::io::Write;
#[test]
fn stats_counts_lines() {
let mut log = tempfile::NamedTempFile::new().unwrap();
writeln!(log, r#"203.0.113.9 - - [t] "GET /a HTTP/1.1" 200 10"#).unwrap();
writeln!(log, r#"203.0.113.9 - - [t] "GET /b HTTP/1.1" 500 20"#).unwrap();
writeln!(log, "broken line").unwrap();
Command::cargo_bin("loglens").unwrap()
.args(["stats", log.path().to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("parsed ok: 2"))
.stdout(predicate::str::contains("parse failed: 1"));
}
#[test]
fn missing_file_fails_with_context() {
Command::cargo_bin("loglens").unwrap()
.args(["stats", "no-such-file.log"])
.assert()
.failure()
.stderr(predicate::str::contains("failed to open log file"));
}Command::cargo_bin("loglens") locates and runs the freshly built real binary, and the chain after assert() verifies the exit code (success/failure) and the output (string conditions from predicates). The second test matters especially: it verifies that the error message designed in Part 2 — the “what we were trying to do” context plus exit code 1 — actually reaches the user. Error handling is a feature, and features get tests. Test data goes into temporary files created by tempfile, deleted automatically when the test ends (basics Part 3’s drop, on the job here too).
The fact that cargo test is parallel #
cargo testOne line runs all unit and integration tests — and by default, in parallel across threads. Faster tests come with one rule: tests that share a file path or global state will wobble with ordering. Using tempfile above instead of a fixed path is not only about automatic cleanup; it gives each test an independent file so parallel runs cannot interfere. Part 4’s “sort HashMap output before printing” also collects its dividend here: a tool whose output order changes per run cannot have integration tests at all.
Summary #
- Unit tests sit next to the code under
#[cfg(test)]. The parser’s design decisions (lenient-, empty lines not counted as failures) become its test cases. matches!for error-kind checks andPartialEq+assert_eq!for struct comparison are the standard kit.- Integration tests inspect the finished product from
tests/. assert_cmd runs the real binary and verifies exit codes and output. - Error messages are a feature. The “missing file” message and its exit code are guarded by a test.
cargo testruns in parallel by default. Independent tempfile-based files per test, and sorted stable output, are the preconditions.- Next part is the first half of shipping: tightening the release profile for a smaller, faster binary, and cross-compiling for other operating systems.