Testing Strategy

A terminal is unusually testable — if you built the boundaries. The core is a pure function from bytes to state, which means most of the system can be tested with no PTY, no shell, no window, and no clock.

Five kinds of test, each catching a different class of bug:

KindCatchesSpeedNeeds
UnitPer-sequence semantics, encoding, mathmicrosecondsNothing
GoldenRegressions in whole-stream behaviormillisecondsRecorded byte streams
PTY integrationThe OS layer: spawn, line discipline, resize, signals~100 msA PTY and real programs
DifferentialDivergence from a reference implementationmillisecondsvte or a real terminal
InteractiveMissing features real programs needmanualvim, top, less, ssh

The pyramid is unusually bottom-heavy here, and that is correct: the expensive tests should confirm things the cheap ones cannot.

              ▲     interactive  (a handful, manual, documented)
             ╱ ╲    differential (dozens, automated)
            ╱   ╲   PTY integration (dozens)
           ╱     ╲  golden (hundreds)
          ╱───────╲ unit (thousands)

Unit Tests

The workhorse. Every escape sequence, every key encoding, every piece of geometry math.

The four properties (from the teaching method):

  1. Name the sequence in a comment, with its raw bytes.
  2. Assert one thing.
  3. The failure message states the rule, not just the numbers.
  4. Test through the public API so it survives refactors.
#![allow(unused)]
fn main() {
#[test]
fn erase_in_line_zero_erases_from_the_cursor_to_the_end() {
    // CSI 0 K (EL) — Erase in Line, mode 0: cursor to end of line, inclusive.
    // Generated by: printf 'abcdef\033[3D\033[K'
    let mut t = Terminal::new(3, 10, cfg());
    t.advance(b"abcdef\x1b[1;4H\x1b[K");
    assert_eq!(t.snapshot_text().lines().next().unwrap(), "abc",
               "EL 0 must erase from the cursor column inclusive to the line end");
}
}

What to unit test

AreaCases
Escape-sequence parsingEvery state transition; split input at chunk sizes 1/2/3/7/13/∞; bounded params; malformed input
Cursor movementEach of CUU/CUD/CUF/CUB/CUP/CHA/VPA; clamping at all four boundaries; origin mode; pending-wrap clearing
Screen mutationsPrint, wrap, pending wrap, wide chars, combining marks, the spacer invariant
ScrollingLF at the region bottom, RI at the top, SU/SD, scrollback feeding rules, alt-screen exclusion
Erase commandsED 0/1/2/3, EL 0/1/2, ECH; the current-background rule; boundaries
Mode changesEvery mode's set/reset effect; defaults; DECRQM replies; ANSI vs. private namespaces
Input encodingEvery row of the key table, in every relevant mode
Grid resizingTruncate and reflow; cursor tracking; scrollback interaction; degenerate sizes (1×1)

The boundary rule for test cases

Every sequence gets at least three tests: the normal case, the boundary case, and the degenerate case.

#![allow(unused)]
fn main() {
#[test] fn cuf_moves_forward() { /* normal: CSI 5 C from column 0 */ }
#[test] fn cuf_clamps_at_the_last_column() { /* boundary: CSI 999 C */ }
#[test] fn cuf_with_no_parameter_moves_one() { /* degenerate: CSI C */ }
}

Almost every real terminal bug is at a boundary. Testing only the normal case is testing the part that was never going to break.


Property Tests

Some invariants are better stated once than enumerated:

#![allow(unused)]
fn main() {
#[test]
fn structural_invariants_hold_under_random_operations() {
    let mut t = Terminal::new(10, 20, cfg());
    for op in random_operations(50_000) {
        t.advance(&op);
        // 1. The cursor is always in bounds.
        assert!(t.cursor().row < 10 && t.cursor().col < 20);
        // 2. Every row has exactly `cols` cells.
        for r in 0..10 { assert_eq!(t.screen().line(r).len(), 20); }
        // 3. Every wide cell is followed by exactly one spacer, and every spacer
        //    is preceded by a wide cell.
        assert_spacer_invariant(&t);
        // 4. The scroll region is valid.
        assert!(t.scroll_top() < t.scroll_bottom() && t.scroll_bottom() < 10);
        // 5. Scrollback never exceeds the configured limit.
        assert!(t.screen().scrollback().len() <= t.config().scrollback_limit);
    }
}
}

Five invariants, fifty thousand operations, one test. It will find things you did not think to enumerate.


PTY Integration Tests

These test the layer unit tests cannot reach: real processes, a real line discipline, real signals.

The four required cases from the brief:

#![allow(unused)]
fn main() {
#[test]
fn plain_text() {
    let out = mini_term(&["--rows", "5", "--cols", "20"], &["printf", "hello\\n"]);
    assert_eq!(out.trim_end(), "hello");
}

#[test]
fn sgr_color() {
    // Exercises: CSI parsing, SGR handling, per-cell style storage, and reset.
    let out = mini_term_debug(&["--rows", "5", "--cols", "20"],
                              &["printf", "\\033[31mred\\033[0m\\n"]);
    assert!(out.contains("(0,0) fg=Indexed(1)"));
    assert!(!out.contains("(0,3) fg=Indexed(1)"));
}

#[test]
fn wrapping_at_the_terminal_width() {
    // Exercises: wrapping, pending wrap, and the wrapped-line flag.
    let out = mini_term(&["--rows", "5", "--cols", "80"],
                        &["python3", "-c", "print('x' * 100)"]);
    let lines: Vec<&str> = out.lines().collect();
    assert_eq!(lines[0].len(), 80);
    assert_eq!(lines[1].len(), 20);
}

#[test]
fn interactive_read_through_the_line_discipline() {
    // THE integration test. It passes only if: the PTY was allocated, the child
    // got a controlling terminal, the line discipline echoed, canonical mode
    // released the line on Enter, and the core rendered both.
    // Six layers, one assertion.
    let out = mini_term_with_input(
        &["--rows", "5", "--cols", "40"],
        &["bash", "-c", "read -p 'Name: ' name; echo \"Hello $name\""],
        b"World\n");
    assert!(out.contains("Name: World"));
    assert!(out.contains("Hello World"));
}
}

Additional PTY tests worth having

TestProves
stty size inside reports the size you setTIOCSWINSZ before fork
test -t 0 prints YESThe child is on a real terminal
ps -o sid,tty shows a session leader with a ptssetsid + TIOCSCTTY
A resize triggers a redraw in the childSIGWINCH propagation
The child's exit code is propagatedSIGCHLD + waitpid
^C interrupts a foreground jobJob control end to end
A 100 KB paste arrives intactPartial-write handling
yes does not freeze the harnessRead/render decoupling

Making them reliable

ProblemFix
Timing flakinessWait for content, never sleep. wait_for_text(needle, timeout).
Environment differencesA fixed environment: TERM, LANG, PATH, PS1, no LINES/COLUMNS
A hung child blocks CIA timeout on every test, snapshotting whatever exists
Shell startup filesbash --norc --noprofile
Platform differencesUse /bin/sh where possible; mark Linux-only tests explicitly
Zombie processesKill and reap in a Drop guard on the test harness
#![allow(unused)]
fn main() {
/// Wait for content, never sleep. A fixed sleep is either flaky or slow, and
/// usually manages both.
fn wait_for_text(pty: &mut Pty, term: &mut Terminal, needle: &str, timeout: Duration) -> bool {
    let deadline = Instant::now() + timeout;
    let mut buf = [0u8; 65536];
    while Instant::now() < deadline {
        if let Ok(n) = pty.read_timeout(&mut buf, Duration::from_millis(20)) {
            term.advance(&buf[..n]);
            if term.snapshot_text().contains(needle) { return true; }
        }
    }
    false
}
}

What Not to Test

Honesty about the limits keeps the suite valuable:

Do not testWhyInstead
Exact pixel outputFont rendering differs by version, platform, and hintingTest RenderSnapshot
Timing-dependent behaviorFlaky by constructionTest the state machine, inject time
The windowing library's behaviorNot your codeTest your translation layer
Every one of 256 palette colorsThe 16th and the 200th test the same codeTest the boundaries: 0, 15, 16, 231, 232, 255
Interactive programs in CISlow, flaky, version-dependentGolden tests from recorded sessions

The CI Pipeline

# What should run on every push, in this order (fastest-failing first).
- cargo fmt --check
- cargo clippy --workspace -- -D warnings
- bash scripts/boundary-audit.sh          # the eight boundary checks
- cargo test --workspace                  # unit + golden + integration
- cargo build --target wasm32-unknown-unknown -p terminal-core -p terminal-protocol
- cargo fuzz run parse -- -max_total_time=60     # a short fuzz on every push
- cargo test --workspace -- --ignored     # slow/interactive-adjacent tests
# Nightly: a long fuzz run, and the differential suite against vte.

Test on both Linux and macOS. Any difference in terminal-core behavior is a bug — it is supposed to be pure. Differences in terminal-pty are expected and are exactly what the platform matrix is for.


Coverage: What to Measure and What to Ignore

cargo install cargo-llvm-cov
cargo llvm-cov --workspace --html
CrateTargetWhy
terminal-protocol>95%Pure logic; anything uncovered is untested logic
terminal-core>90%Same
terminal-input>95%A table; there is no excuse
terminal-render-model>85%Mostly transformation
terminal-pty>70%Error paths need fault injection
terminal-mux>70%Event loops are awkward
terminal-guiDo not measureKeep it thin instead

Coverage is a smell detector, not a goal. An uncovered branch in terminal-core is a question: "which escape sequence reaches this, and why is it not in the corpus?"


Validation / Self-check

  1. Name the five kinds of test and the class of bug each catches.
  2. What are the four properties of a good unit test here?
  3. Why does every sequence get a normal, a boundary, and a degenerate test?
  4. Name five structural invariants worth property-testing.
  5. What does the read -p integration test prove that printf does not?
  6. Why "wait for content" rather than sleep?
  7. Name five sources of flakiness in PTY tests and the fix for each.
  8. What should you not test, and what do you test instead in each case?
  9. Why run the suite on both Linux and macOS, and what would a terminal-core difference mean?
  10. Which crate should you not measure coverage on, and why?

Next: Golden and Differential Tests.