Terminal testing
Test an interactive terminal program by running it in a tape, waiting for expected output, and saving state JSON for assertions.
Output target/snapshots/app.gif
Type "my-app --demo"EnterWait+Screen "Ready"State target/snapshots/ready.jsonScreenshot target/snapshots/ready.pngThe tape fails if expected text does not appear before the configured timeout. The PNG helps with visual review, while State JSON can be compared in snapshot tests.
What to assert
Section titled “What to assert”Prefer asserting text and important style spans instead of every pixel. Pixel assertions are useful for renderer work, but they are often brittle for application behavior tests.
Good assertions include:
- expected viewport text
- expected scrollback text
- selected style spans for important colors or attributes
- cursor visibility and location when it affects behavior
- whether a prompt, status line, or selected row appears
- whether hidden setup avoided extra visible frames
Stable tapes
Section titled “Stable tapes”Use waits as synchronization points:
Type "my-tui --scripted"EnterWait+Screen "Loaded 12 rows"Prefer Wait+Screen for TUIs because cursor location may move as the program draws. Use regex waits
for dynamic output:
Wait+Screen /Loaded [0-9]+ rows/Use Env to make application behavior deterministic:
Env NO_COLOR 1Env TZ UTCEnv BETAMAX_TEST 1Use Set CursorBlink false when animation frame differences do not matter to the test.
Wait before sleep
Section titled “Wait before sleep”After an action, use Wait, Wait+Line, or Wait+Screen to check the expected text before
continuing. Add Sleep if viewers need time to read the recording.
Type "my-tui --scripted"EnterWait+Screen "Loaded 12 rows"State target/snapshots/loaded.jsonSleep 500msKeep validation sleeps short and rare. If a sleep is compensating for behavior that cannot be matched with visible text, a prompt, or a status line, keep it as small as the program allows and leave a tape comment explaining what external effect it covers.
When a tape fails
Section titled “When a tape fails”A wait that times out, a failed assertion, a missing Require program, or a shell that exits early
fails the tape. Betamax then saves a failure bundle so you can see what happened without running the
tape again:
target/betamax-failures/24623-1792732654799368000-command-5/├── report.txt which command failed, at which line, after how long, and what it awaited├── report.json the same report for scripts├── actual.png screenshot at the failure├── actual.json state JSON at the failure├── cells.json visible cells with styles└── diff.txt the error messageStart with report.txt:
Tape command 5 of 5 failed at line 6 after 1.04s.
Command: Wait+Screen@1s "Ready"Waiting: screen to match text "Ready" within 1.00sShell: runningRun time: 1.57s since the shell started
Error: timed out waiting for screen to match text `Ready`
Recent commands: line 1 Set Shell "bash" line 2 Set TypingSpeed 0 line 4 Type "printf 'loading...\\n'" line 5 Enter> line 6 Wait+Screen@1s "Ready"
Screen (87x28, cursor at column 2, row 2): > printf 'loading...\n' loading... >The screen shows the program printed loading... and returned to the prompt, so it never got as
far as printing Ready. Open actual.png when colors or layout matter.
Choose another location with betamax run --failure-dir <dir> or BETAMAX_FAILURE_DIR. See
Failure bundles for the report.json fields.
In CI, upload the bundles when the job fails:
- name: Terminal tests run: betamax run tests/app.tape- name: Upload failure bundles if: ${{ failure() }} uses: actions/upload-artifact@v7 with: name: betamax-failures path: target/betamax-failures if-no-files-found: ignoreThe GitHub Action runs Betamax from its working-directory, so bundles land in
target/betamax-failures under that directory. Add the same upload step after the action.
Snapshot workflow
Section titled “Snapshot workflow”To check a snapshot from a Rust test:
- Run
betamax run tests/fixtures/profile.tape. - Read
target/snapshots/profile.json. - Deserialize it with
serde_json. - Assert targeted fields directly, or snapshot the whole file with
insta.
For tests that call Betamax directly from Rust, betamax-core exposes tape parsing, the runner,
and terminal state types. See the betamax-core API docs.
Snapshot state with insta
Section titled “Snapshot state with insta”A Rust test can run a tape with betamax-core and snapshot the screen with
insta. The
compiled example
drives a small full-screen task picker, presses j, and snapshots the result twice: the styled
rows to a file and the plain text inline. It runs in Betamax’s CI on Linux and macOS.
[dev-dependencies]betamax-core = "0.3"indoc = "2"insta = { version = "1", features = ["json"] }use betamax_core::{Runner, Tape};use indoc::formatdoc;
#[test]fn selection_moves_down() { let app = env!("CARGO_BIN_EXE_my-app"); let source = formatdoc! {r#" Set Shell "{app}" Set Columns 30 Set Rows 8 Wait+Screen "> write tape" Type "j" Wait+Screen "> run test" Screenshot tests/snapshots/selection_moves_down.png "#}; let tape = Tape::parse(&source).unwrap(); let artifacts = Runner::new().run_artifacts(&tape).unwrap(); let state = artifacts.final_state.unwrap();
insta::assert_json_snapshot!(state.styled_viewport()); insta::assert_snapshot!(state.viewport_text, @" Tasks write tape > run test review snapshot
j move · q quit ");}Set Shell starts the program directly, so no typed command line lands on the screen. Each wait
uses Wait+Screen on text that only appears once the input has taken effect; Wait alone checks
only the cursor’s line.
The file snapshot, tests/snapshots/snapshot__selection_moves_down.snap, holds each row’s text
with styles on the spans that have them:
[ [["Tasks", { "fg": "#dfe5ee", "bg": "#487fd4", "bold": true }]], [" write tape"], [["> run test", { "fg": "#52aa60", "bold": true }]], [" review snapshot"], [], [["j move · q quit", { "faint": true }]]]styled_viewport() writes each style next to its text. The state JSON viewport field refers to
styles by number instead, and those numbers shift when an earlier row’s style changes, so a
snapshot of it would show changes on rows that look the same. See
Snapshot styles from Rust for an example.
The cursor, title, and working directory are left out because they can change without a visible
difference. Snapshot state.styled_scrollback() too when the program prints to the main screen
rather than the alternate screen.
Inline or file snapshots
Section titled “Inline or file snapshots”Insta can keep the expected value in the test source or in a file next to it:
- Inline,
assert_snapshot!(value, @"…"): the expected value sits in the test, next to the tape that produced it. Use it for short values a reader checks by eye, such asviewport_text. - File,
assert_json_snapshot!(value): the expected value goes totests/snapshots/. Use it for styled rows, which run to dozens of lines once each style is written out.
Both kinds fail the test when the screen changes, and cargo insta review accepts either kind:
accepting an inline snapshot rewrites the string in the test source.
Inline text can miss two kinds of change. Insta strips the indentation that every line shares before comparing, so a screen that shifts right by whole columns still matches. Plain text also has no color or attributes. The styled-row file snapshot catches both, so keep it alongside inline text.
Review snapshot changes
Section titled “Review snapshot changes”When the screen changes, insta writes a pending .snap.new file beside a file snapshot, or a
.pending-snap file beside the test for inline snapshots. The tape’s Screenshot writes the PNG
next to the file snapshots, so the reviewer can see the screen while reading the diff. Ignore the
PNG and the pending files in git:
*.pending-snap*.snap.newtests/snapshots/*.pngcargo insta test # run the tests and collect pending snapshotscargo insta review # accept or reject each changeInstall the reviewer with cargo install cargo-insta. In CI, insta does not write pending files
and fails the test on any difference.
betamax-core builds Ghostty’s terminal library from source, so the test machine needs Zig 0.15.2
on PATH. See Build from source. It supports
macOS and Linux; Windows is not supported.
Choosing artifacts for tests
Section titled “Choosing artifacts for tests”State JSON should be the primary assertion artifact. Screenshots are best as debugging attachments when a snapshot changes. GIFs are useful when reviewing interaction timing, but they should usually not be the only automated assertion.
See Examples for scrollback.tape, text-styles.tape, and outputs.tape,
which exercise the most useful testing paths.
See a worked example of developing a feature with Betamax.
To run tapes in CI and review the results on pull requests, use the GitHub Action.