From qa-cli-tools
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`.
How this skill is triggered — by the user, by Claude, or both
Slash command
/qa-cli-tools:tui-snapshot-testerThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Per [textual-testing][txt]:
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.
For headless CLIs that emit text only, prefer
bats-testing +
cli-output-conventions;
TUI snapshots are only needed for layout-rich UIs.
Per txt:
pip install pytest-textual-snapshot
This pulls Textual + the snap_compare pytest fixture.
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:
pytest. It fails; emits an HTML report at
snapshot_report.html.pytest --snapshot-update
to accept it.__snapshots__/ directory.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.
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.
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/.
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 ./...
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.
Snapshots fail randomly if any of these vary:
freezegun;
Ratatui: pass now as state; Ink: use vi.useFakeTimers()).LC_ALL=C in CI.Without determinism, snapshot tests become noise → team disables.
Treat snapshot diffs like test diffs in code review:
--snapshot-update commit
on diff) defeats the purpose.Pair with the visual-baseline-curator
discipline (sister plugin): same review pattern for browser
screenshots.
| 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). |
terminal_size=).pytest-textual-snapshot, snap_compare,
--snapshot-update, Pilot.press / Pilot.click / Pilot.pause,
HTML report, CI workflow.insta (Rust): https://insta.rs/.teatest (Go Bubble Tea): https://github.com/charmbracelet/x/tree/main/exp/teatest.ink-testing-library (Node Ink): https://github.com/vadimdemedes/ink-testing-library.bats-testing - exit-code + text
output testing (text CLIs); pair with TUI snapshots for
layout regression.visual-baseline-curator - sister-plugin baseline discipline (browser).npx claudepluginhub testland/qa --plugin qa-cli-toolsGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.