tui-snapshot-tester
Snapshot testing for terminal UI apps (TUIs) - captures rendered terminal frames as deterministic SVG / text snapshots, diffs them on every run, surfaces a reviewable HTML report on failure, and supports `--snapshot-update` to accept changes intentionally. Wraps `pytest-textual-snapshot` for Python Textual apps; provides equivalent recipes for Ratatui (Rust) `insta` snapshots, Charm Bracelet (Go) `teatest` golden files, and Ink (Node) `ink-testing-library`. Use for any TUI where layout regressions otherwise reach users via `screenshot looks wrong in terminal`.
tui-snapshot-tester
Overview
Per textual-testing:
"Snapshot testing for TUI apps with pytest-textual-snapshot" - the plugin generates "SVG screenshot" files from your app.
A TUI snapshot test:
This catches layout regressions (clipped text, wrong column widths, broken borders) that exit-code / output assertions miss.
When to use
For headless CLIs that emit text only, prefer bats-testing + cli-output-conventions; TUI snapshots are only needed for layout-rich UIs.
Step 1 - Install (Python / Textual)
Per txt:
pip install pytest-textual-snapshotThis pulls Textual + the snap_compare pytest fixture.
Step 2 - First snapshot test
Per txt (verbatim):
def test_calculator(snap_compare):
assert snap_compare("path/to/calculator.py")First run fails (no baseline). Per txt:
"Only ever run pytest with
--snapshot-updateif you're happy with how the output looks on the left hand side of the snapshot report."
Workflow:
Step 3 - Drive interactions before capture
Per txt:
# Press keys before snapshotting
assert snap_compare("path/to/calculator.py", press=["1", "2", "3"])
# Custom terminal dimensions
assert snap_compare("path/to/calculator.py", terminal_size=(50, 100))
# Run setup code (mouse hover, etc.)
async def run_before(pilot) -> None:
await pilot.hover("#number-5")
assert snap_compare("path/to/calculator.py", run_before=run_before)The Pilot API per txt:
async def test_keys():
app = RGBApp()
async with app.run_test() as pilot:
await pilot.press("r")
assert app.screen.styles.background == Color.parse("red")pilot.press() / pilot.click() / pilot.pause() simulate user input.
Step 4 - CI integration (Textual)
Per txt:
"work well in CI on all supported operating systems, and the snapshot report is just an HTML file which can be exported as a build artifact."
jobs:
tui-snapshot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with: { python-version: '3.13' }
- run: pip install pytest-textual-snapshot
- name: Run snapshot tests
run: pytest tests/ui/
- uses: actions/upload-artifact@v4
if: failure()
with: { name: snapshot-report, path: snapshot_report.html }Reviewer downloads snapshot_report.html from the failed CI artifact, opens it locally, and decides accept / reject.
Step 5 - Equivalent for Rust / Ratatui
Use insta for snapshot testing:
# Cargo.toml
[dev-dependencies]
insta = "1"
ratatui = { version = "0.29", features = ["test-buffer"] }#[test]
fn renders_main_view() {
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal.draw(|f| draw_main(f, &app_state())).unwrap();
insta::assert_snapshot!(terminal.backend().to_string());
}Update with cargo insta accept (or cargo insta review for interactive triage). Snapshots stored in tests/snapshots/.
Step 6 - Equivalent for Go / Bubble Tea
Use teatest:
func TestApp(t *testing.T) {
tm := teatest.NewTestModel(t, NewModel(), teatest.WithInitialTermSize(80, 24))
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
teatest.RequireEqualOutput(t, tm.FinalOutput(t,
teatest.WithFinalTimeout(2*time.Second)))
}teatest.RequireEqualOutput writes / compares against testdata/<test-name>.golden. Update with -update:
go test -update ./...Step 7 - Equivalent for Node / Ink
Use ink-testing-library:
import { render } from 'ink-testing-library';
import App from './app';
test('renders welcome screen', () => {
const { lastFrame } = render(<App />);
expect(lastFrame()).toMatchSnapshot();
});Jest's built-in toMatchSnapshot writes to __snapshots__/. Update with jest -u.
Step 8 - Determinism
Snapshots fail randomly if any of these vary:
Without determinism, snapshot tests become noise → team disables.
Step 9 - Snapshot review discipline
Treat snapshot diffs like test diffs in code review:
Pair with the visual-baseline-curator discipline (sister plugin): same review pattern for browser screenshots.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
--snapshot-update in CI by default | Defeats regression detection; baselines drift silently. | Updates only on developer machine + reviewed in PR (Step 2). |
| Snapshots with timestamps / random IDs | Random failures; team disables tests. | Mock clock + seed PRNG (Step 8). |
| One giant snapshot per app | Any change forces full baseline review; reviewer skips. | Per-screen / per-state snapshots. |
| Skipping Step 8 (determinism) | Cross-OS / cross-machine diffs. | LC_ALL=C + pin OS in CI. |
| No HTML report artifact | Reviewer can't see the diff; rejects blindly or rubber-stamps. | Upload snapshot_report.html artifact (Step 4). |