Skip to content

Latest commit

 

History

History
323 lines (235 loc) · 10.6 KB

File metadata and controls

323 lines (235 loc) · 10.6 KB

Testing Guide

This guide covers the test paths that contributors and AI-assisted editors should reach for first.

Start with the right lane

SLT testing is easier if you pick the right layer up front.

Goal Tool
Verify rendering, layout, and widget behavior TestBackend
Verify keyboard/mouse/paste/resize interaction TestBackend + EventBuilder
Verify frame() / Backend contract semantics custom Backend + AppState + frame()
Verify shared-kernel parity parity/property tests using both TestBackend and frame()

Most tests should stay in the first two lanes.

Default tool: TestBackend

TestBackend renders one or more frames into an in-memory buffer without a real terminal.

use slt::TestBackend;

let mut tb = TestBackend::new(40, 10);
tb.render(|ui| {
    ui.text("Hello");
});

tb.assert_contains("Hello");

Use this for:

  • text and layout assertions
  • widget state changes
  • overlay and modal rendering checks
  • snapshot-style buffer inspection
  • multi-frame state checks for hooks, focus, and previous-frame hit testing

Simulating input with EventBuilder

use slt::{EventBuilder, KeyCode, TestBackend};

let mut tb = TestBackend::new(40, 10);
let events = EventBuilder::new()
    .key('h')
    .key_code(KeyCode::Tab)
    .click(4, 2)
    .build();

tb.run_with_events(events, |ui| {
    ui.text("interactive");
});

Use EventBuilder when the widget logic depends on keyboard, mouse, paste, or resize events.

Mouse and key chain wrappers (v0.19.1+)

EventBuilder ships convenience wrappers for events that previously required constructing raw Event values. The most useful ones to know:

Method Emits Use for
.click(x, y) mouse down + up at (x, y) most click tests
.mouse_up(x, y) mouse up only testing release-only handlers, drag end
.drag(x, y) mouse drag at (x, y) (button held) scrubbing sliders, resizing splits
.key_release(c) key release for c matched press/release pairs (e.g. modifier holds)
.focus_gained() terminal FocusGained event windows/tabs gaining focus
.focus_lost() terminal FocusLost event pause-on-blur, autosave

Click vs drag is the test gap most authors miss — a click is two events in the same cell, a drag is movement while a button is held:

// Click: down then up in the same cell
let events = EventBuilder::new()
    .click(10, 4)
    .build();

// Drag: emit drag events while moving across cells
let events = EventBuilder::new()
    .drag(10, 4)
    .drag(11, 4)
    .drag(12, 4)
    .mouse_up(12, 4)
    .build();

Use mouse_up when you want to assert that a handler only fires on release (common for "press-and-hold to drag, release to commit" patterns).

render() vs run_with_events() vs render_with_events()

Method Use when
render() One static frame, no input needed
run_with_events() One frame with events, default focus state is fine
render_with_events() One frame with explicit events and explicit focus bookkeeping

render_with_events() is the lowest-level helper. Use it when you need to control focus_index or prev_focus_count directly.

Multi-frame tests are normal in SLT

Immediate-mode UI often uses previous-frame data. That means some good tests intentionally render two or more frames.

Typical cases:

  • hover and click behavior that depends on previous-frame hit maps
  • focus movement across Tab / Shift+Tab
  • use_state() / use_memo() persistence
  • modal scope and overlay boundaries

If a widget looks “one frame delayed” in implementation, that is not automatically a bug. It is often the correct immediate-mode contract.

Backend contract tests

TestBackend is not the right tool for everything. When you want to verify the low-level runtime contract itself, write a tiny custom backend and drive frame() directly.

Good contract targets:

  • flush() errors propagate
  • ui.quit() returns Ok(false)
  • AppState persists across frames
  • resize changes are respected by the next frame

This is the right place to test the runtime boundary, not widget rendering details.

Parity and property tests

When you touch core lifecycle code, keep one more layer in mind:

  • parity tests compare TestBackend with a minimal custom Backend
  • property tests throw event sequences at both and lock the outputs together

Those tests are especially valuable for:

  • frame-kernel refactors
  • layout feedback changes
  • focus and interaction bookkeeping
  • backend/path deduplication

If you change internals and the public API stays the same, this test lane is often your best safety net.

Assertions that age well

tb.assert_contains("Saved");
tb.assert_line(0, "Header");
tb.assert_line_contains(3, "status");
let snapshot = tb.to_string_trimmed();

The most robust pattern is:

  1. render one frame
  2. inspect one or two stable lines
  3. assert a small substring or a focused semantic fact

Avoid asserting giant whole-screen strings unless the UI is intentionally snapshot-tested.

Snapshot testing with insta

TestBackend::to_string_trimmed() returns a deterministic multi-line string suitable for snapshot testing with the insta crate. SLT itself does not require insta, but the in-tree test suite uses it — see tests/snapshots.rs for ~10 live examples covering text, rows, tables, tabs, calendars, lists, and separators.

Add insta to your own project's dev-dependencies:

[dev-dependencies]
superlighttui = "0.19"
insta = "1"

Compare the full buffer against a checked-in snapshot:

use slt::TestBackend;

#[test]
fn dashboard_snapshot() {
    let mut tb = TestBackend::new(40, 10);
    tb.render(|ui| {
        let _ = ui.container().border(slt::Border::Rounded).p(1).col(|ui| {
            ui.text("Dashboard").bold();
            ui.text("42 events");
        });
    });
    insta::assert_snapshot!(tb.to_string_trimmed());
}

Accept or reject candidate snapshots with cargo insta review. Inline snapshots (insta::assert_snapshot!(tb.to_string_trimmed(), @r"...")) are preferred for small fixtures — they keep the expected output next to the test and never drift from external .snap files.

Snapshot tests pair well with EventBuilder for interaction journeys, and with focused assert_contains / assert_line for narrow invariants. Prefer small snapshots — one widget or one panel — over full-screen dumps that churn on every theme or layout tweak.

Visual snapshot regression tests

tests/visual_snapshots.rs renders one frame of each demo example into a TestBackend and stores the buffer output as a plain-text snapshot under tests/snapshots/visual__<demo>.snap. The goal is to catch the kinds of visual regressions that raw assertions miss — top-border title overflow, flexbox grow drift, theme color shifts, CJK width handling at the right edge — by failing CI when the rendered output changes unexpectedly.

How to run

cargo test --test visual_snapshots

The first run on a clean checkout passes against the committed baselines. A failing test prints a side-by-side diff of expected vs actual buffer.

Updating baselines after intentional changes

When you deliberately change visual output (a widget restyling, a layout tweak, a new badge), the snapshots will fail. Review and accept the new baseline:

cargo insta review     # interactive
cargo insta accept     # accept all pending

Commit the updated tests/snapshots/visual__*.snap files alongside the code change so reviewers can see the visual diff in the PR.

What it catches

  • Layout drift (flexbox grow/shrink/gap regressions)
  • Border rendering bugs (wrong corners, missing edges, title overflow)
  • Theme color shifts that flip glyph attributes
  • CJK / wide-char width handling at the right edge
  • Wrap and truncation at small terminal sizes

What it does NOT catch

  • Interactive state transitions (focus, hover, click) — use EventBuilder with assertion-based tests instead
  • Animation and frame timing — use parity / property tests
  • Sixel / kitty image output — not represented in plain-text buffer
  • Multi-frame state changes (only frame 1 is captured)

Implementation

Each example file (examples/demo*.rs) exposes a pub fn render(ui: &mut Context) entry point that builds fresh state and runs one rendering pass. The example's own main keeps using slt::run (or slt::run_with) so the interactive demo still works; the snapshot test imports the example via Rust's #[path = "../examples/demo.rs"] attribute and calls render directly.

Demos with rich internal state (demo.rs, demo_dashboard.rs, demo_infoviz.rs, demo_cjk.rs) use a render_frame(ui, &mut state) helper for runtime, and a thin render(ui) wrapper that builds default state and forwards. Frame-1 snapshots only need that wrapper.

Testing custom widgets

For custom widgets:

  • call register_focusable() if keyboard input matters
  • use interaction() if you need click/hover without a wrapping container
  • verify both rendering and return-value semantics
let changed = ui.widget(&mut rating);
assert!(changed);

Good test targets in SLT

  • clipping and viewport behavior for raw_draw
  • focus order and Tab behavior
  • modal and overlay interaction boundaries
  • Response.clicked, .changed, .hovered, .focused
  • widget state persistence across frames
  • rendering of wrapped text, markdown, rich output, and charts
  • backend contract invariants for frame()

Debugging failing UI tests

When a test fails:

  • print tb.to_string_trimmed() in the failure path
  • compare the expected focus/input state with the actual event sequence
  • verify whether the widget depends on previous-frame data (prev_* behavior)
  • ask whether the failure belongs to widget rendering, runtime contract, or shared-kernel parity

That last question matters. A surprising amount of confusion disappears when the test is moved to the correct layer.

Recommended contributor workflow

  1. Reproduce the issue with TestBackend if possible.
  2. Add EventBuilder if interaction matters.
  3. If the change touches frame() or Backend, add or update a contract test.
  4. If the change touches the frame kernel, consider parity/property coverage too.

This keeps tests small while still protecting the core.

Related docs

  • docs/DEBUGGING.md - one-frame delay, F12 overlay, clipping
  • docs/PATTERNS.md - custom widgets, hooks, overlays, large-app structure
  • docs/BACKENDS.md - backend mental model and low-level contract
  • src/test_utils.rs - canonical rustdoc for test helpers