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.
Installation
Add testty as a dev-dependency in the crate that contains your E2E tests:
[]
= "0.8"
= "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].
Upgrading
The curated stable surface lives at testty::prelude and is locked down by the
tests/public_api.rs tripwire test. Any breaking change to a prelude item or to the
documented auxiliary items (testty::recipe,
testty::snapshot::DEFAULT_UPDATE_ENV_VAR, SnapshotConfig::with_update_env_var,
SnapshotConfig::with_update_mode, SnapshotConfig::is_update_mode) requires a
deliberate update to that test and a testty major version bump.
When upgrading from earlier 0.x releases:
- Replace
use testty::scenario::Scenario;/use testty::session::*;imports withuse testty::prelude::*;. - The
artifact,calibration, andoverlaymodules have been removed. Use the proof pipeline backends and the snapshot testing helpers instead. testty::snapshot::is_update_mode()has been removed. UseSnapshotConfig::is_update_mode(&self)instead, which now resolves the per-config env var or the programmatic override set viaSnapshotConfig::with_update_env_var/SnapshotConfig::with_update_mode.- Snapshot baseline updates still default to
TUI_TEST_UPDATE=1. Custom CI integrations can override the trigger viaSnapshotConfig::with_update_env_varand read the default fromtestty::snapshot::DEFAULT_UPDATE_ENV_VAR. SnapshotConfigis now#[non_exhaustive]. Replace any struct-literal construction (SnapshotConfig { .. }) withSnapshotConfig::new(..)plus thewith_*builder chain.SnapshotErrorand its struct-shaped variants (Mismatch,MissingBaseline,FrameMismatch) are now#[non_exhaustive].matcharms must include a fallback_arm and any field destructuring must use the..rest-pattern. TheMissingBaselinevariant also carries a newupdate_env_varfield that surfaces the configured trigger in the error message.
Quick start
Most users only need the curated [testty::prelude]:
use *;
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 assert_* functions panic with detailed messages on failure, including the match
position, actual colors/styles, and region contents.
Result-returning matchers (match_*)
Every assert_* has a match_* sibling that returns
Result<(), Box<AssertionFailure>> (aliased as MatchResult) instead of panicking. The
failure is boxed so the Ok path stays a single pointer-sized return and the Result
enum stays small. Use the matcher form when you need to compose, retry, soft-batch, or
surface failures into a proof report — for example, when polling the live PTY frame for
a condition or when collecting multiple expectations against one capture.
use ;
use Region;
let region = top_row;
let result: MatchResult = match_text_in_region;
if let Err = result
Adding the match_* siblings is a strictly additive change: all existing assert_*
callers keep working without modification, so this evolution does not require a major
version bump for downstream crates.
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
The variable name is configurable via
SnapshotConfig::with_update_env_var;
the default is exposed as testty::snapshot::DEFAULT_UPDATE_ENV_VAR.
For tests and other programmatic callers,
SnapshotConfig::with_update_mode
injects the update-mode decision directly. This bypasses the environment variable, which
is the recommended way to drive update behavior from a test without mutating
process-global env state through unsafe std::env::set_var calls:
let config = new
.with_update_mode;
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 | | proof | Proof pipeline: report
collector, backend trait, and format implementations | | 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 | | prelude | Curated re-exports for the common
testty workflow (use testty::prelude::*;) |
Full example
A complete E2E test exercising tab navigation:
use *;
use ;
use SnapshotConfig;
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.