Deryks _dev

Full-Stack Web Developer & Technical Leader

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

A test that has never failed has told you nothing.
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 failed
Before 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

That's the whole argument for tests, in one bugYou'd have found this eventually — by opening a directory, seeing a blank tab, and wondering. The test found it in the ten seconds it took to ask "what about a path ending in a slash?", before it ever reached a user, and it will now keep finding it if anyone rewrites that method.

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, unchanged

Second — 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))
}
Read the new 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.
Only one other line changed above the testsBuffer::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?

  • Compiles it only when running tests
  • Runs each test in a separate process
  • Grants the module access to private items
  • Marks every function inside it as a test

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 yields the empty piece after the last slash
  • Paths ending in a slash are rejected as invalid
  • The borrow of self.path ended too early
  • Trailing separators are stripped by the filesystem

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?

  • The one rejoining every run into the original
  • The one checking keywords get the keyword colour
  • The one on an unterminated string literal
  • None — that needs a running egui window

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.