<< Back / Lesson 10 / Text Editor GUI in Rust / ~30 minutes
Tests That Find Something
Three stages — and the second one goes red on a bug you shipped in lesson 9.
Your mission says this repo is a work sample. A Rust repo with no #[test]in it invites exactly one question in an interview, and it isn't a friendly one.
But tests written to have tests are worthless. The point of this lesson is that one of them fails — on real code you wrote an hour ago — and that the effort of making another one testable improves the design.
Write them where you're most likely to be wrong: the edges, the empty case, the input nobody thought about.
Where you're starting
Lesson 9's 246 lines, with tabs. Check againstlesson 9's checkpoint.
adding removing existing code, shown only for context
Your first test module
cargo test runs, and passes.
Rust puts unit tests in the same file as the code they test, at the bottom.One edit — append this to the very end of main.rs:
editor/src/main.rsthe very bottom of the file
#[cfg(test)]
mod tests {
use super::*;
fn buffer_with(text: &str) -> Buffer {
Buffer {
path: "test.rs".to_owned(),
text: text.to_owned(),
status: String::new(),
}
}
#[test]
fn line_col_starts_at_one_one() {
assert_eq!(buffer_with("abc").line_col(0), (1, 1));
}
#[test]
fn line_col_counts_newlines() {
let b = buffer_with("ab\ncd");
assert_eq!(b.line_col(2), (1, 3));
assert_eq!(b.line_col(3), (2, 1));
assert_eq!(b.line_col(5), (2, 3));
}
#[test]
fn line_col_counts_characters_not_bytes() {
let b = buffer_with("caf\u{e9}x");
assert_eq!(b.line_col(5), (1, 6));
}
#[test]
fn line_col_past_the_end_is_the_end() {
assert_eq!(buffer_with("ab").line_col(999), (1, 3));
}
}Four things carry the weight here:
#[cfg(test)]— compile this module only undercargo test. It costs nothing in the shipped binary.use super::*;— the tests are a child module, so they need to pull in the parent's items. This is why private functions are testable in Rust without any ceremony.#[test]— marks a function as a test. No registration, no naming convention.assert_eq!— on failure it prints both values, which is why it beatsassert!(a == b).
The names are sentences: line_col_past_the_end_is_the_end. When one fails, the name is the first thing you read, and it should tell you what broke without opening the file. Nobody has ever been helped by a test calledtest_line_col_2.
Notice the last two. Counting characters rather than bytes is thelesson 4 trap, pinned down so it can't come back. And an index past the end is the sort of thing a cursor does during a delete — worth knowing it returns the end rather than panicking.
cargo test
→ test result: ok. 4 passed; 0 failed
Test the thing you wrote last
A red test — on code from lesson 9.
Buffer::name() is a one-liner that chops a path down to a filename. It's the newest code in the project and the least examined. Two edits. First, inside the test module:
editor/src/main.rsin mod tests
#[test]
fn name_of_a_bare_filename() {
assert_eq!(buffer_with("").name(), "test.rs");
}
#[test]
fn name_of_a_nested_path() {
let mut b = buffer_with("");
b.path = "src/widgets/main.rs".to_owned();
assert_eq!(b.name(), "main.rs");
}
#[test]
fn name_of_a_trailing_slash() {
let mut b = buffer_with("");
b.path = "src/".to_owned();
assert_eq!(b.name(), "src");
}cargo test
test tests::name_of_a_bare_filename ... ok
test tests::name_of_a_nested_path ... ok
test tests::name_of_a_trailing_slash ... FAILED
---- tests::name_of_a_trailing_slash stdout ----
assertion `left == right` failed
left: ""
right: "src"
test result: FAILED. 6 passed; 1 failedBefore reading on: self.path.rsplit('/').next() on "src/" returned an empty string. Why?
rsplit splits from the right and yields the piece after the last separator first. For "src/" the last / is at the end, so the first piece is the empty string between it and the end of the text. It's not an error — it's the correct answer to a question that was asked badly.
A blank tab label isn't fatal, but it's the kind of thing a reviewer clicks on in the first minute. Second edit — fix it by trimming the trailing separator before splitting, and rejecting an empty answer:
editor/src/main.rsin impl Buffer
fn name(&self) -> &str {
self.path.rsplit('/').next().unwrap_or(&self.path)
self.path
.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|name| !name.is_empty())
.unwrap_or(&self.path)
}.filter(…) on an Option turns Some(x) intoNone when the predicate fails, so an empty result falls through tounwrap_or and you get the whole path back rather than nothing at all. That's the Option combinator family from lesson 5, earning its keep.
cargo test
→ test result: ok. 7 passed; 0 failed
Make the untestable part testable
The scanner gets tested, and the code gets better in the process.
highlight can't be tested: it needs a &egui::Ui, which only exists mid-frame inside a running application. But the decisions it makes — which run of characters is which colour — need no egui at all. They're tangled up with the drawing, so untangle them. Four edits.
First — split the function in half. The scanning half returns a list of runs:
editor/src/main.rsreplacing the head of fn highlight
/// Split `text` into coloured runs and hand egui a ready-made layout.
fn highlight(ui: &egui::Ui, text: &str, wrap_width: f32) -> Arc<egui::Galley> {
let font = egui::TextStyle::Monospace.resolve(ui.style());
let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width;
if text.len() > HIGHLIGHT_LIMIT { … } // the whole early return
/// Split `text` into runs of one colour each. Pure: no egui, no drawing,
/// so it can be tested directly.
fn scan(text: &str) -> Vec<(&str, egui::Color32)> {
let mut runs = Vec::new();
let mut rest = text;
while !rest.is_empty() {
the if-chain that picks each run's colour, unchangedSecond — the bottom of the loop pushes instead of appending, and the function returns the list:
editor/src/main.rsin fn scan, the end of the loop
the job.append(…) call that used to end the loop — delete all nine lines of it
runs.push((chunk, colour));
rest = &rest[chunk.len()..];
}
ui.ctx().fonts_mut(|f| f.layout_job(job))
runs
}Third — a new highlight that turns runs into a galley. It's short, because all the thinking now happens elsewhere:
editor/src/main.rsbelow fn scan
/// Lay `text` out for egui, colouring it unless the file is too big.
fn highlight(ui: &egui::Ui, text: &str, wrap_width: f32) -> Arc<egui::Galley> {
let font = egui::TextStyle::Monospace.resolve(ui.style());
let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width;
let runs = if text.len() > HIGHLIGHT_LIMIT {
vec![(text, PLAIN)]
} else {
scan(text)
};
for (chunk, colour) in runs {
job.append(
chunk,
0.0,
egui::TextFormat {
font_id: font.clone(),
color: colour,
..Default::default()
},
);
}
ui.ctx().fonts_mut(|f| f.layout_job(job))
}highlight againThe size limit used to be an early return buried in the middle of a scanner. Now it'sif too big { one run } else { scan it } — the whole policy in three lines, where someone can see it. Making the code testable didn't just enable the tests; it moved a decision somewhere it can be read.Fourth — the tests, which need no Ui at all:
editor/src/main.rsin mod tests
#[test]
fn scan_colours_keywords_only() {
let runs = scan("let x");
assert_eq!(runs[0], ("let", KEYWORD));
assert_eq!(runs[2], ("x", PLAIN));
}
#[test]
fn scan_handles_an_unterminated_string() {
let runs = scan("s = \"oops");
assert_eq!(runs.last().unwrap(), &("\"oops", STRING));
}
#[test]
fn scan_reassembles_the_original_text() {
let text = "fn a() { // hi\n let s = \"x\";\n}\n";
let joined: String = scan(text).iter().map(|(c, _)| *c).collect();
assert_eq!(joined, text);
}The last one is the most valuable test in the file and the least obvious. It doesn't check any colour — it checks that gluing every run back together reproduces the input exactly. That single property catches a dropped character, a duplicated one, a slice off by a byte, and the infinite loop you'd get if a run ever came back empty. Properties like that are worth more than a dozen assertions about specific colours.
Note .unwrap() in a test — the thinglesson 2 warned you off. In tests it's correct: a panic is the failure report, and there's no user to protect. The rule was never "never unwrap", it was "don't claim something can't fail unless you can defend it".
cargo test
→ test result: ok. 10 passed; 0 failed
cargo run
Highlighting still works — the refactor changed structure, not behaviour.
Where you ended up
331 lines, 10 tests passing. Two regions changed: highlight split in two, and name fixed — plus the new test module at the very bottom.
editor/src/main.rsfn scan and fn highlight, replacing the old highlight
/// Split `text` into runs of one colour each. Pure: no egui, no drawing,
/// so it can be tested directly.
fn scan(text: &str) -> Vec<(&str, egui::Color32)> {
let mut runs = Vec::new();
let mut rest = text;
while !rest.is_empty() {
the same if-chain as lesson 7
}
runs
}
/// Lay `text` out for egui, colouring it unless the file is too big.
fn highlight(ui: &egui::Ui, text: &str, wrap_width: f32) -> Arc<egui::Galley> {
let font = egui::TextStyle::Monospace.resolve(ui.style());
let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width;
let runs = if text.len() > HIGHLIGHT_LIMIT {
vec![(text, PLAIN)]
} else {
scan(text)
};
for (chunk, colour) in runs {
job.append(
chunk,
0.0,
egui::TextFormat {
font_id: font.clone(),
color: colour,
..Default::default()
},
);
}
ui.ctx().fonts_mut(|f| f.layout_job(job))
}
/// One open file.Buffer::name gains the trailing-slash fix below. Everything else —Buffer, Editor, fn ui and main — is exactly as lesson 9 left it.editor/src/main.rsin impl Buffer
self.path
.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|name| !name.is_empty())
.unwrap_or(&self.path)
}
}editor/src/main.rsthe very bottom of the file
#[cfg(test)]
mod tests {
use super::*;
fn buffer_with(text: &str) -> Buffer {
Buffer {
path: "test.rs".to_owned(),
text: text.to_owned(),
status: String::new(),
}
}
#[test]
fn line_col_starts_at_one_one() {
assert_eq!(buffer_with("abc").line_col(0), (1, 1));
}
#[test]
fn line_col_counts_newlines() {
let b = buffer_with("ab\ncd");
the remaining line_col and name tests
#[test]
fn scan_colours_keywords_only() {
let runs = scan("let x");
assert_eq!(runs[0], ("let", KEYWORD));
assert_eq!(runs[2], ("x", PLAIN));
}
#[test]
fn scan_handles_an_unterminated_string() {
let runs = scan("s = \"oops");
assert_eq!(runs.last().unwrap(), &("\"oops", STRING));
}
#[test]
fn scan_reassembles_the_original_text() {
let text = "fn a() { // hi\n let s = \"x\";\n}\n";
let joined: String = scan(text).iter().map(|(c, _)| *c).collect();
assert_eq!(joined, text);
}
}331 lines; cargo test green, cargo clippy silent.
Check yourself
What does #[cfg(test)] on the test module do?
It's conditional compilation, so the tests add nothing to the shipped binary. Access to private items comes from being a child module plususe super::*, and each test still needs its own #[test].↑ Review: the four pieces (stage 1)
Why did name() return an empty string for the path "src/"?
rsplit works from the right, and the first piece it yields is whatever sits after the final separator — here, nothing. The function was answering correctly; the question was wrong.↑ Review: the failing test (stage 2)
Which test would catch a scanner that silently drops a character?
Colour assertions only check the runs they name. Rejoining every run and comparing to the input is a property of the whole output, so it catches dropped characters, duplicates, off-by-one slices and empty runs at once.↑ Review: the property test (stage 3)
Read this before lesson 11
Primary source:The Rust Programming Language, Chapter 11 — Writing Automated Tests(Brown University interactive edition).
Chapter 11.1 covers #[test], assert! and#[should_panic]; 11.2 covers running a subset withcargo test name_of, which you'll want the moment you have thirty of them; 11.3 draws the line between unit tests like today's and integration tests in a separatetests/ directory. You've just written the first kind.