Skip to content

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"
Enter
Wait+Screen "Ready"
State target/snapshots/ready.json
Screenshot target/snapshots/ready.png

The 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.

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

Use waits as synchronization points:

Type "my-tui --scripted"
Enter
Wait+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 1
Env TZ UTC
Env BETAMAX_TEST 1

Use Set CursorBlink false when animation frame differences do not matter to the test.

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"
Enter
Wait+Screen "Loaded 12 rows"
State target/snapshots/loaded.json
Sleep 500ms

Keep 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.

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 message

Start 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.00s
Shell: running
Run 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: ignore

The 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.

To check a snapshot from a Rust test:

  1. Run betamax run tests/fixtures/profile.tape.
  2. Read target/snapshots/profile.json.
  3. Deserialize it with serde_json.
  4. 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.

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.

Cargo.toml
[dev-dependencies]
betamax-core = "0.3"
indoc = "2"
insta = { version = "1", features = ["json"] }
tests/snapshot.rs
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.

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 as viewport_text.
  • File, assert_json_snapshot!(value): the expected value goes to tests/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.

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:

.gitignore
*.pending-snap
*.snap.new
tests/snapshots/*.png
Terminal window
cargo insta test # run the tests and collect pending snapshots
cargo insta review # accept or reject each change

Install 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.

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.