testty
Rust-native TUI end-to-end testing framework. Drives a real TUI binary in a
pseudo-terminal, captures location-aware terminal state with vt100, and
provides a semantic assertion API for text, style, color, and region checks.
Scenarios can also be compiled into VHS
tapes for visual screenshot capture.
Quick start
Add testty as a dev-dependency in the crate that contains your E2E
tests:
[]
= "0.6"
= "3"
Inside this workspace, keep using shared workspace dependencies instead:
# crates/my-app/Cargo.toml
[]
= { = true }
= { = true }
Note:
workspace = truerequires matching entries in the rootCargo.tomlunder[workspace.dependencies].
Write a test that launches your binary, interacts with it, and asserts on the terminal state:
use recipe;
use Scenario;
use PtySessionBuilder;
Run the test:
Core concepts
Scenario
A Scenario is an ordered sequence of Step actions that describe a user
journey. Built with a fluent API, then executed in a PTY or compiled into a
VHS tape.
use Duration;
use Scenario;
let scenario = new
.wait_for_stable_frame // Wait for app to render
.press_key // Switch tab
.wait_for_stable_frame // Wait for re-render
.capture; // Snapshot the terminal
Steps
Each Step represents a single user action or wait condition:
| Step | Description | Example |
|---|---|---|
WriteText |
Type text into the terminal | .write_text("hello world") |
PressKey |
Send a named key press | .press_key("Enter") |
Sleep |
Pause for a fixed duration | .sleep_ms(200) |
WaitForText |
Poll until text appears | .wait_for_text("Ready", 5000) |
WaitForStableFrame |
Wait for rendering to stabilize | .wait_for_stable_frame(500, 5000) |
Capture |
Snapshot the current terminal state | .capture() |
CaptureLabeled |
Labeled snapshot for proof reports | .capture_labeled("init", "App launched") |
Supported key names: Enter, Tab, Escape / Esc, Backspace,
Up, Down, Left, Right, Home, End, Delete, PageUp, PageDown,
Space, Ctrl+<letter> (e.g., Ctrl+C). Unknown keys are sent as raw bytes.
PtySession and PtySessionBuilder
PtySession spawns the binary in a real PTY using portable-pty. Configure
it with PtySessionBuilder:
use PtySessionBuilder;
let builder = new
.size // Terminal dimensions (default: 80x24)
.env // Environment variables
.env
.workdir; // Working directory
// Option 1: Use with a Scenario
let frame = scenario.run.expect;
// Option 2: Manual control
let mut session = builder.spawn.expect;
session.write_text?;
session.press_key?;
let frame = session.wait_for_text?;
TerminalFrame
A TerminalFrame is a snapshot of the terminal state parsed through vt100.
It provides structured access to text, colors, and styles:
use TerminalFrame;
// Create from raw ANSI bytes (done automatically by PtySession)
let frame = new;
// Read text
let all_text = frame.all_text; // All visible text
let row_text = frame.row_text; // Text from row 0
let region_text = frame.text_in_region; // Text in a region
// Search for text (returns Vec<MatchedSpan>)
let matches = frame.find_text; // Find everywhere
let matches = frame.find_text_in_region; // Find in region
Region
A Region defines a rectangular area for scoped assertions:
use Region;
// Named constructors
let header = top_row; // First row, full width
let footer = footer; // Last row, full width
let full = full; // Entire terminal
let left = left_panel; // Left half
let right = right_panel; // Right half
let top_l = top_left; // Top-left quadrant
let top_r = top_right; // Top-right quadrant
// Percentage-based (col%, row%, width%, height%, cols, rows)
let upper = percent; // Top 60%
// Explicit coordinates
let custom = new; // col=10, row=5, 30x3
MatchedSpan
When text is found in a frame, you get a MatchedSpan with position, color,
and style metadata:
let matches = frame.find_text;
let span = &matches;
span.text; // "Projects"
span.rect; // Region { col, row, width, height }
span.foreground; // Option<CellColor>
span.background; // Option<CellColor>
span.style; // CellStyle (bold, italic, underline, inverse)
span.is_bold; // true if bold
span.is_highlighted; // true if bold, inverse, or has background color
span.has_fg; // true if foreground matches
span.has_bg; // true if background matches
Assertion API
Low-level assertions (assertion module)
use assertion;
use CellColor;
use Region;
let region = top_row;
// Text presence
assert_text_in_region;
assert_not_visible;
assert_match_count;
// Style checks
assert_span_is_highlighted;
assert_span_is_not_highlighted;
// Color checks
assert_text_has_fg_color;
assert_text_has_bg_color;
All assertion functions panic with detailed messages on failure, including the match position, actual colors/styles, and region contents.
Recipe helpers (recipe module)
High-level, composable helpers for common TUI patterns. Prefer these over raw assertions:
use recipe;
// Tabs
expect_selected_tab; // In header, highlighted
expect_unselected_tab; // In header, not highlighted
// Footer
expect_keybinding_hint; // Hint in footer row
expect_footer_action; // Action in footer row
// Content
expect_instruction_visible;
expect_dialog_title; // Upper 60% of terminal
expect_status_message; // Anywhere in frame
// Absence
expect_not_visible;
Snapshot testing
The framework supports two snapshot modes: frame text (semantic) and visual screenshot (pixel-level via VHS).
Frame text snapshots
Compare the terminal text content against a committed baseline:
use ;
let config = new;
assert_frame_snapshot_matches.expect;
Visual snapshots (VHS)
Compile a scenario into a VHS tape for pixel-level screenshot capture:
let tape = scenario.to_vhs_tape;
// Write tape to file
tape.write_to?;
// Execute the tape (requires VHS installed)
tape.execute?;
Updating baselines
Set TUI_TEST_UPDATE=1 to overwrite baselines with the current output:
TUI_TEST_UPDATE=1
Snapshot config tuning
For visual snapshots, configure pixel-level comparison thresholds:
let config = new
.with_thresholds;
Proof pipeline
The proof pipeline captures labeled terminal states during scenario execution
and renders them through swappable backends. Use capture_labeled() steps
and run_with_proof() to collect a ProofReport:
use Scenario;
use PtySessionBuilder;
use FrameTextBackend;
use ScreenshotStripBackend;
use GifBackend;
use HtmlBackend;
let scenario = new
.wait_for_stable_frame
.capture_labeled
.press_key
.wait_for_stable_frame
.capture_labeled;
let builder = new.size;
let = scenario.run_with_proof.expect;
// Render through any backend:
report.save.unwrap;
report.save.unwrap;
report.save.unwrap;
report.save.unwrap;
Proof formats
| Format | Backend | Output | Use case |
|---|---|---|---|
| Frame text | FrameTextBackend |
.txt |
CI logs, quick inspection |
| PNG strip | ScreenshotStripBackend |
.png |
Review comments, docs |
| Animated GIF | GifBackend |
.gif |
PR descriptions, demos |
| HTML report | HtmlBackend |
.html |
Detailed review with diffs and assertions |
Composable journeys
Journey provides reusable building blocks for declarative scenario authoring.
Instead of repeating low-level step sequences, compose scenarios from
pre-built journeys:
use Journey;
use Scenario;
let startup = wait_for_startup;
let navigate = navigate_with_key;
let input = type_and_confirm;
let snapshot = capture_labeled;
let scenario = new
.compose
.compose
.compose
.compose;
Built-in journeys
| Journey | Steps | Description |
|---|---|---|
wait_for_startup(stable_ms, timeout_ms) |
1 | Wait for app to render and stabilize |
navigate_with_key(key, expected, timeout) |
2 | Press key, wait for expected text |
type_and_confirm(text) |
2 | Type text and press Enter |
press_and_wait(key, ms) |
2 | Press key and sleep briefly |
capture_labeled(label, desc) |
1 | Capture with label for proofs |
Frame diffing
The diff engine computes cell-level differences between terminal frames and generates human-readable change summaries:
use FrameDiff;
use TerminalFrame;
let before = new;
let after = new;
let diff = compute;
assert!;
// Get changed regions with row/col spans
for region in diff.changed_regions
// Human-readable summaries
for line in diff.summary
Diffs are automatically computed between consecutive captures in a
ProofReport and displayed in the HTML proof backend.
Module overview
| Module | Purpose |
|---|---|
scenario |
Fluent builder for composing test scenarios from steps |
step |
Step enum: WriteText, PressKey, Sleep, WaitForText, WaitForStableFrame, Capture, CaptureLabeled |
session |
PTY executor: PtySession + PtySessionBuilder |
frame |
Terminal state parser: TerminalFrame, CellColor, CellStyle |
region |
Rectangular region definitions with named anchors |
locator |
MatchedSpan with text, position, color, and style metadata |
assertion |
Structured assertion functions with detailed failure messages |
recipe |
High-level helpers for tabs, footer, dialogs, status messages |
snapshot |
Baseline management: frame text and visual image comparison |
vhs |
VHS tape compiler for visual screenshot capture |
calibration |
Cell-to-pixel geometry mapping for overlay rendering |
artifact |
Failure artifact directory and capture storage |
overlay |
Pixel-level drawing on screenshots for visual debugging |
proof |
Proof pipeline: report collector, backend trait, and format implementations |
renderer |
Native bitmap font renderer for terminal frames to PNG images |
diff |
Cell-level frame diffing with changed region detection |
journey |
Composable journey building blocks for declarative test authoring |
feature |
FeatureDemo builder: scenario execution with hash-cached VHS GIF generation |
Full example
A complete E2E test exercising tab navigation:
use recipe;
use Scenario;
use PtySessionBuilder;
use ;
Examples
Run the showcase examples to see the framework features in action:
# Full proof pipeline: captures → frame-text, PNG strip, GIF, HTML
# Frame diffing with changed region detection
# Composable journeys and scenario building
Tips
- Use
wait_for_stable_frameinstead ofsleep_mswhen waiting for rendering. It adapts to actual render speed rather than hard-coding delays. - Use recipe helpers over raw assertions. They encode common TUI layout patterns (header tabs, footer hints) so you don't rebuild locator logic.
- Set deterministic terminal size (e.g., 80x24) to keep frame snapshots stable across machines.
- Isolate state with
tempfile::TempDirand environment variables so tests don't interfere with each other or the real app data. - E2E tests run automatically with
cargo test. Cargo builds the binary before running integration tests, soCARGO_BIN_EXE_*is always available.