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:
| Kind | Catches | Speed | Needs |
|---|---|---|---|
| Unit | Per-sequence semantics, encoding, math | microseconds | Nothing |
| Golden | Regressions in whole-stream behavior | milliseconds | Recorded byte streams |
| PTY integration | The OS layer: spawn, line discipline, resize, signals | ~100 ms | A PTY and real programs |
| Differential | Divergence from a reference implementation | milliseconds | vte or a real terminal |
| Interactive | Missing features real programs need | manual | vim, 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):
- Name the sequence in a comment, with its raw bytes.
- Assert one thing.
- The failure message states the rule, not just the numbers.
- 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
| Area | Cases |
|---|---|
| Escape-sequence parsing | Every state transition; split input at chunk sizes 1/2/3/7/13/∞; bounded params; malformed input |
| Cursor movement | Each of CUU/CUD/CUF/CUB/CUP/CHA/VPA; clamping at all four boundaries; origin mode; pending-wrap clearing |
| Screen mutations | Print, wrap, pending wrap, wide chars, combining marks, the spacer invariant |
| Scrolling | LF at the region bottom, RI at the top, SU/SD, scrollback feeding rules, alt-screen exclusion |
| Erase commands | ED 0/1/2/3, EL 0/1/2, ECH; the current-background rule; boundaries |
| Mode changes | Every mode's set/reset effect; defaults; DECRQM replies; ANSI vs. private namespaces |
| Input encoding | Every row of the key table, in every relevant mode |
| Grid resizing | Truncate 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
| Test | Proves |
|---|---|
stty size inside reports the size you set | TIOCSWINSZ before fork |
test -t 0 prints YES | The child is on a real terminal |
ps -o sid,tty shows a session leader with a pts | setsid + TIOCSCTTY |
| A resize triggers a redraw in the child | SIGWINCH propagation |
| The child's exit code is propagated | SIGCHLD + waitpid |
^C interrupts a foreground job | Job control end to end |
| A 100 KB paste arrives intact | Partial-write handling |
yes does not freeze the harness | Read/render decoupling |
Making them reliable
| Problem | Fix |
|---|---|
| Timing flakiness | Wait for content, never sleep. wait_for_text(needle, timeout). |
| Environment differences | A fixed environment: TERM, LANG, PATH, PS1, no LINES/COLUMNS |
| A hung child blocks CI | A timeout on every test, snapshotting whatever exists |
| Shell startup files | bash --norc --noprofile |
| Platform differences | Use /bin/sh where possible; mark Linux-only tests explicitly |
| Zombie processes | Kill 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 test | Why | Instead |
|---|---|---|
| Exact pixel output | Font rendering differs by version, platform, and hinting | Test RenderSnapshot |
| Timing-dependent behavior | Flaky by construction | Test the state machine, inject time |
| The windowing library's behavior | Not your code | Test your translation layer |
| Every one of 256 palette colors | The 16th and the 200th test the same code | Test the boundaries: 0, 15, 16, 231, 232, 255 |
| Interactive programs in CI | Slow, flaky, version-dependent | Golden 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
| Crate | Target | Why |
|---|---|---|
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-gui | Do not measure | Keep 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
- Name the five kinds of test and the class of bug each catches.
- What are the four properties of a good unit test here?
- Why does every sequence get a normal, a boundary, and a degenerate test?
- Name five structural invariants worth property-testing.
- What does the
read -pintegration test prove thatprintfdoes not? - Why "wait for content" rather than
sleep? - Name five sources of flakiness in PTY tests and the fix for each.
- What should you not test, and what do you test instead in each case?
- Why run the suite on both Linux and macOS, and what would a
terminal-coredifference mean? - Which crate should you not measure coverage on, and why?