This guide covers the test paths that contributors and AI-assisted editors should reach for first.
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.
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
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.
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).
| 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.
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.
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 propagateui.quit()returnsOk(false)AppStatepersists across frames- resize changes are respected by the next frame
This is the right place to test the runtime boundary, not widget rendering details.
When you touch core lifecycle code, keep one more layer in mind:
- parity tests compare
TestBackendwith a minimal customBackend - 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.
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:
- render one frame
- inspect one or two stable lines
- assert a small substring or a focused semantic fact
Avoid asserting giant whole-screen strings unless the UI is intentionally snapshot-tested.
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.
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.
cargo test --test visual_snapshotsThe 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.
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 pendingCommit the updated tests/snapshots/visual__*.snap files alongside the
code change so reviewers can see the visual diff in the PR.
- 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
- Interactive state transitions (focus, hover, click) — use
EventBuilderwith 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)
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.
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);- 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()
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.
- Reproduce the issue with
TestBackendif possible. - Add
EventBuilderif interaction matters. - If the change touches
frame()orBackend, add or update a contract test. - If the change touches the frame kernel, consider parity/property coverage too.
This keeps tests small while still protecting the core.
docs/DEBUGGING.md- one-frame delay, F12 overlay, clippingdocs/PATTERNS.md- custom widgets, hooks, overlays, large-app structuredocs/BACKENDS.md- backend mental model and low-level contractsrc/test_utils.rs- canonical rustdoc for test helpers