ExRatatui ships with a headless test backend so you can test TUIs in CI without a TTY. Tests run async: true, start in milliseconds, and assert against either the rendered character buffer or observable state transitions.
There are two testing levels:
- Widget-level — render a widget to a headless terminal and assert on the buffer string. Good for widget authors, visual regressions, and layout checks.
- App-level — start a supervised
ExRatatui.Appundertest_mode, drive it with synthetic events, and assert on runtime state or app-emitted messages. Good for end-to-end interaction tests.
Both work together. Most apps need a handful of each.
Widget-level: the headless backend
The core APIs are ExRatatui.init_test_terminal/2, ExRatatui.draw/2, and ExRatatui.get_buffer_content/1. A round-trip fits in one test:
defmodule MyApp.WidgetTest do
use ExUnit.Case, async: true
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Paragraph}
test "renders a styled paragraph inside a block" do
terminal = ExRatatui.init_test_terminal(40, 10)
widget = %Paragraph{
text: "Hello!",
style: %Style{fg: :green, modifiers: [:bold]},
alignment: :center,
block: %Block{title: " Greet ", borders: [:all]}
}
:ok = ExRatatui.draw(terminal, [{widget, %Rect{x: 0, y: 0, width: 40, height: 10}}])
content = ExRatatui.get_buffer_content(terminal)
assert content =~ "Hello!"
assert content =~ "Greet"
end
endget_buffer_content/1 returns the visible characters as a multi-line string, stripped of styling. That's usually what you want for assertions — but if you need exact column placement, the string is newline-delimited at the backend's width, so String.split(content, "\n") |> Enum.at(row) gets you one row.
Each test terminal is independent — no global state, nothing to clean up. async: true tests sharing a schema are safe.
App-level: supervised apps under test_mode
When you start any ExRatatui.App with test_mode: {width, height}:
- The server boots against the headless backend instead of the real terminal
- Live terminal input polling is disabled (so ambient TTY events don't leak into your test)
- Everything else —
mount/1,render/2,handle_event/2, commands, subscriptions — runs normally
You drive input with ExRatatui.Runtime.inject_event/2 and assert against either the runtime snapshot or messages your app sends to a passed-in test_pid.
Pattern 1: assert via runtime snapshot
defmodule MyApp.CounterTest do
use ExUnit.Case, async: true
alias ExRatatui.Event
alias ExRatatui.Runtime
test "up arrow increments the counter" do
pid = start_supervised!({MyApp.Counter, name: nil, test_mode: {40, 10}})
:ok = Runtime.inject_event(pid, %Event.Key{code: "up", kind: "press"})
:ok = Runtime.inject_event(pid, %Event.Key{code: "up", kind: "press"})
assert %{render_count: n} = Runtime.snapshot(pid)
assert n >= 2
end
endstart_supervised!/1 (from ExUnit) guarantees the server is cleaned up at the end of the test — always use it over bare start_link/1.
Runtime.snapshot/1 returns a map with render_count, dimensions, subscriptions, active_async_commands, and more. See ExRatatui.Runtime for the full shape.
Pattern 2: assert via a test_pid
For richer assertions, your app can send messages to a pid passed through mount opts:
defmodule MyApp.Counter do
use ExRatatui.App
@impl true
def mount(opts) do
{:ok, %{count: 0, test_pid: Keyword.get(opts, :test_pid)}}
end
@impl true
def handle_event(%ExRatatui.Event.Key{code: "up"}, state) do
state = %{state | count: state.count + 1}
if state.test_pid, do: send(state.test_pid, {:count_changed, state.count})
{:noreply, state}
end
# ...
endThen:
test "tracks count over multiple presses" do
pid =
start_supervised!(
{MyApp.Counter, name: nil, test_pid: self(), test_mode: {40, 10}}
)
:ok = Runtime.inject_event(pid, %Event.Key{code: "up", kind: "press"})
assert_receive {:count_changed, 1}, 500
:ok = Runtime.inject_event(pid, %Event.Key{code: "up", kind: "press"})
assert_receive {:count_changed, 2}, 500
endThis hook is entirely in your app's code — no framework plumbing. The test_pid is just another mount option; production runs don't pass one, so state.test_pid is nil and the send is a no-op.
Pattern 3: assert via :sys.get_state
For quick state inspection in tests, :sys.get_state/1 works on any GenServer, including the ExRatatui server. The returned struct's :user_state field holds your app's state:
server_state = :sys.get_state(pid)
assert server_state.user_state.count == 2Useful in a pinch, but prefer Runtime.snapshot/1 or a test_pid when the test should survive refactors.
Testing event transitions
Key, mouse, and resize events all work the same way:
# Key press
%ExRatatui.Event.Key{code: "enter", kind: "press"}
%ExRatatui.Event.Key{code: "c", modifiers: [:ctrl], kind: "press"}
# Mouse
%ExRatatui.Event.Mouse{kind: :down, button: :left, column: 10, row: 5}
# Resize
%ExRatatui.Event.Resize{columns: 120, rows: 40}Any of these can be passed to inject_event/2. A resize event triggers a fresh render at the new size — good for asserting responsive layout code.
Testing handle_info and subscriptions
Callback-runtime handle_info/2 callbacks receive regular messages — just send(pid, message) in your test. Tracing with enable_trace (see Debugging) captures :info sources if you need to see them.
For reducer-runtime subscriptions:
pid = start_supervised!({MyApp.TUI, name: nil, test_mode: {40, 10}})
snapshot = Runtime.snapshot(pid)
assert length(snapshot.subscriptions) == 1
assert [%{id: :tick, kind: :interval, interval_ms: 1_000}] = snapshot.subscriptionsYou can let a real interval fire (fine for short intervals in fast tests), or prefer driving the transition manually via inject_event/2 and asserting the effect — tests are more deterministic without timing dependencies.
Patterns
Asserting text is visible
Widget-level: assert on get_buffer_content/1. App-level: the server renders into its internal test terminal, but the buffer isn't exposed for supervised apps today — assert via test_pid / snapshot instead, or extract the render logic into a pure function and test it separately:
# In lib/my_app/tui.ex
def render(state, frame), do: scene(state, frame)
def scene(state, frame), do: [{paragraph_for(state), rect_for(frame)}]
# In test/my_app/tui_test.exs
test "renders count with positive styling" do
terminal = ExRatatui.init_test_terminal(40, 10)
scene = MyApp.TUI.scene(%{count: 42}, %ExRatatui.Frame{width: 40, height: 10})
:ok = ExRatatui.draw(terminal, scene)
assert ExRatatui.get_buffer_content(terminal) =~ "42"
endPulling scene/2 out of render/2 is a light refactor that pays for itself the first time you want a visual assertion.
Asserting text in a specific region
content = ExRatatui.get_buffer_content(terminal)
rows = String.split(content, "\n")
assert Enum.at(rows, 5) =~ "status: ok"For column ranges use binary_part/3 or String.slice/2 on the row.
Asserting absence
refute content =~ "error"Asserting focus moved
If your app renders the focused region's border in a distinct color, that'll show up in the buffer. If you track focus explicitly in state, assert against :sys.get_state(pid).user_state.focus.
Property-based invariants
Use stream_data to check invariants that should hold for any input — decoder round-trips, layout constraints, style coercion. The ExRatatui test suite uses this approach for event decoding, layout, style, and text coercion. See test/property/ in the repo for patterns: generators → assertions over StreamData.check_all/3 blocks → deterministic seeds on failure.
Cross-transport parity
A single app module runs over local, SSH, or distribution transports. To verify a change doesn't regress one of them, the suite runs the same scenario against all three transports using a shared driver module. For your own apps, one focused local-transport test suite is usually enough — only add cross-transport tests when you've seen a transport-specific bug or are shipping a library meant to be transport-agnostic.
Gotchas
Avoid Process.sleep/1. If you're waiting for an event to be processed, use _ = :sys.get_state(pid) — by the time that call returns, the server has handled every prior message. If you're waiting for a message, use assert_receive {msg, ...}, timeout.
Avoid Process.alive?/1 for synchronization. Prefer Process.monitor/1 + assert_receive {:DOWN, ^ref, :process, ^pid, _reason}.
async: true + test_mode. test_mode disables terminal polling specifically so async tests don't race ambient TTY events. If you see flaky tests touching real stdin, make sure every start_link / start_supervised! passes test_mode: {w, h}.
Don't forget name: nil. Tests that start a named app will collide with any other test running in parallel under the same name. Pass name: nil to skip registration — you can still address the server by its pid.
Rust NIF rebuilds. If you're editing the Rust side too, prepend EX_RATATUI_BUILD=1 and clean _build/ first — stale precompiled binaries mask your changes. See Debugging.
Where to go next
- Debugging —
Runtime.enable_trace/2, buffer inspection during development. - Performance — how
render?: falseaffects render counts in tests. ExRatatui.Runtimemodule docs — full shape ofsnapshot/1,enable_trace/2,inject_event/2.