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:

  1. Widget-level — render a widget to a headless terminal and assert on the buffer string. Good for widget authors, visual regressions, and layout checks.
  2. App-level — start a supervised ExRatatui.App under test_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
end

get_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
end

start_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

  # ...
end

Then:

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
end

This 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 == 2

Useful 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.subscriptions

You 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"
end

Pulling 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

  • DebuggingRuntime.enable_trace/2, buffer inspection during development.
  • Performance — how render?: false affects render counts in tests.
  • ExRatatui.Runtime module docs — full shape of snapshot/1, enable_trace/2, inject_event/2.