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.feature::GifStatusis now#[non_exhaustive]and gainedFresh { gif_path, hash }andStale { gif_path, current, committed }variants used by the newFeatureDemo::gif_mode(GifMode::CheckOnly)freshness mode.CheckOnlyis a read-only verification path: it never invokes VHS and never mutates the filesystem, so it works on read-only CI mounts and against missing output directories. Existing match arms must add a fallback_arm and any field destructuring must use the..rest-pattern. The companion helpersfeature::compute_frame_hashandfeature::hash_sidecar_pathare now public so external tooling can reproduce the same hash thatFeatureDemowrites to the on-disk sidecar.- The feature-demo surface (
FeatureDemo,FeatureMeta,FeatureResult,GifMode,GifStatus) is now re-exported fromtestty::prelude, souse testty::prelude::*;consumers can callFeatureDemo::new(..).gif_mode(..)without an explicituse testty::feature::..;import.
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) | | Eventually | Poll a frame
predicate until it succeeds | .eventually(timeout, poll, predicate) | | Capture |
Snapshot the current terminal state | .capture() | | CaptureLabeled | Labeled
snapshot for proof reports | .capture_labeled("init", "App launched") |
Predicate-driven waits with eventually
Step::eventually polls a closure against the live PTY frame on every tick and returns
the moment the predicate returns Ok(()). On timeout, the executor surfaces the last
AssertionFailure
the predicate produced through PtySessionError::Assertion, so the structured failure
context flows into the proof report instead of a generic timeout panic.
Use it whenever you would otherwise fight to pick a stable_ms/timeout_ms that is
neither too long (wasted wall time) nor too short (flaky CI). It composes any
MatchResult-returning predicate, so you can wait on assertion::match_* matchers,
recipe checks, or your own frame inspection logic:
use Duration;
use *;
let scenario = new
.write_text
// Step::Sleep guesses how long the UI takes to repaint and risks
// returning before the counter increments.
.sleep_ms
// Step::WaitForText only checks for a literal substring and cannot
// express richer predicates such as "selected tab is highlighted".
.wait_for_text
// Step::eventually polls the predicate every 50ms and returns the
// instant `Counter: 3` appears anywhere in the frame.
.eventually
.capture;
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)
.args // Forward CLI args to the binary
.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 — wide-character continuation cells are skipped (the leading
// cell already supplies the full glyph), while real blank columns stay as
// spaces so column positions in the rendered terminal are preserved.
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.
Soft-batched assertions (SoftAssertions)
SoftAssertions collects multiple match_* failures against one captured frame and
panics once at scope end with every recorded message in record order, instead of failing
fast on the first miss. Pass each MatchResult to check; passing results are dropped
and failing ones are stored.
use ;
use Region;
let region = top_row;
Use SoftAssertions::into_failures to take ownership of the recorded failures before
the accumulator goes out of scope; that path suppresses the end-of-scope panic so the
caller can render the failures themselves.
To attach every recorded failure to the most recent ProofCapture::assertions entry on
a ProofReport, construct the accumulator with SoftAssertions::with_report. The
report must already hold at least one capture — call ProofReport::add_capture first,
otherwise with_report panics at the bind site so misconfigurations surface immediately
instead of silently dropping failures from the proof report.
use ;
use ProofReport;
use Region;
let mut report = new;
report.add_capture;
let mut soft = with_report;
soft.check;
soft.check;
// Each recorded failure also lands on the latest capture's assertions
// list so structured proof backends can render every failure together.
let _ = soft.into_failures;
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, Eventually, 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.