Testing Framework

View Source

This guide covers TermUI's testing framework for component and widget testing.

Overview

TermUI provides a comprehensive testing framework in TermUI.Test.* with four key modules:

ModulePurpose
ComponentHarnessMount and test components in isolation
TestRendererCapture rendered output for inspection
EventSimulatorCreate synthetic events for testing
AssertionsTUI-specific test assertions

Quick Start

defmodule MyWidgetTest do
  use ExUnit.Case, async: true
  use TermUI.Test.Assertions

  alias TermUI.Test.{ComponentHarness, EventSimulator, TestRenderer}

  test "widget renders and responds to events" do
    # Mount component
    {:ok, harness} = ComponentHarness.mount_test(MyWidget, initial_value: 0)

    # Render and check output
    harness = ComponentHarness.render(harness)
    renderer = ComponentHarness.get_renderer(harness)
    assert_text_exists(renderer, "Value: 0")

    # Send event and verify state change
    harness = ComponentHarness.send_event(harness, EventSimulator.simulate_key(:up))
    harness = ComponentHarness.render(harness)
    assert_text_exists(renderer, "Value: 1")

    # Cleanup
    ComponentHarness.unmount(harness)
  end
end

Component Harness

The ComponentHarness mounts components in isolation for testing without the full runtime.

Mounting Components

# Basic mount
{:ok, harness} = ComponentHarness.mount_test(MyComponent)

# With props
{:ok, harness} = ComponentHarness.mount_test(MyButton, label: "Click me")

# With custom dimensions
{:ok, harness} = ComponentHarness.mount_test(MyWidget, width: 40, height: 10)

Rendering

# Render component
harness = ComponentHarness.render(harness)

# Get render result (the render tree)
render_tree = ComponentHarness.get_render(harness)

# Get all renders (most recent first)
all_renders = ComponentHarness.get_renders(harness)

Sending Events

# Single event
harness = ComponentHarness.send_event(harness, event)

# Multiple events
harness = ComponentHarness.send_events(harness, [event1, event2, event3])

# Event + render cycle (common pattern)
harness = ComponentHarness.event_cycle(harness, event)

Inspecting State

# Get full state
state = ComponentHarness.get_state(harness)

# Get state at path
value = ComponentHarness.get_state_at(harness, [:counter, :value])

# Direct state manipulation (use sparingly)
harness = ComponentHarness.set_state(harness, %{count: 10})
harness = ComponentHarness.update_state(harness, fn s -> %{s | count: s.count + 1} end)

Cleanup

# Always unmount when done
ComponentHarness.unmount(harness)

# Or reset to initial state
{:ok, harness} = ComponentHarness.reset(harness)

Test Renderer

The TestRenderer captures rendered output to a buffer for inspection.

Creating a Renderer

{:ok, renderer} = TestRenderer.new(24, 80)  # 24 rows, 80 columns

Writing Content

# Write a string
TestRenderer.write_string(renderer, 1, 1, "Hello, World!")

# Set individual cell
TestRenderer.set_cell(renderer, 1, 1, Cell.new("X", fg: :red))

# Clear buffer
TestRenderer.clear(renderer)

Reading Content

# Get text at position
text = TestRenderer.get_text_at(renderer, 1, 1, 5)  # "Hello"

# Get entire row
row_text = TestRenderer.get_row_text(renderer, 1)

# Get cell
cell = TestRenderer.get_cell(renderer, 1, 1)

# Get style at position
style = TestRenderer.get_style_at(renderer, 1, 1)
# => %{fg: :red, bg: :default, attrs: MapSet.new([:bold])}

Searching Content

# Check if text exists at position
TestRenderer.text_at?(renderer, 1, 1, "Hello")  # true/false

# Check if region contains text
TestRenderer.text_contains?(renderer, 1, 1, 80, "Error")

# Find all occurrences
positions = TestRenderer.find_text(renderer, "Error")
# => [{5, 10}, {12, 3}]

Snapshots

Snapshots capture buffer state for comparison:

# Take snapshot
snapshot = TestRenderer.snapshot(renderer)

# Compare to snapshot
TestRenderer.matches_snapshot?(renderer, snapshot)  # true/false

# Get differences
diffs = TestRenderer.diff_snapshot(renderer, snapshot)
# => [{row, col, expected_cell, actual_cell}, ...]

# Convert to string for debugging
TestRenderer.to_string(renderer)
TestRenderer.snapshot_to_string(snapshot)

Cleanup

TestRenderer.destroy(renderer)

Event Simulator

The EventSimulator creates synthetic events without terminal input.

Keyboard Events

# Basic key press
event = EventSimulator.simulate_key(:enter)
event = EventSimulator.simulate_key(:up)
event = EventSimulator.simulate_key(:escape)

# Key with character
event = EventSimulator.simulate_key(:a, char: "a")

# Key with modifiers
event = EventSimulator.simulate_key(:c, modifiers: [:ctrl])
event = EventSimulator.simulate_key(:s, modifiers: [:ctrl, :shift])

# Function keys
event = EventSimulator.simulate_function_key(1)   # F1
event = EventSimulator.simulate_function_key(12)  # F12

# Navigation keys
event = EventSimulator.simulate_navigation(:up)
event = EventSimulator.simulate_navigation(:page_down)
event = EventSimulator.simulate_navigation(:home)

Common Shortcuts

EventSimulator.simulate_shortcut(:copy)       # Ctrl+C
EventSimulator.simulate_shortcut(:paste)      # Ctrl+V
EventSimulator.simulate_shortcut(:cut)        # Ctrl+X
EventSimulator.simulate_shortcut(:save)       # Ctrl+S
EventSimulator.simulate_shortcut(:quit)       # Ctrl+Q
EventSimulator.simulate_shortcut(:undo)       # Ctrl+Z
EventSimulator.simulate_shortcut(:redo)       # Ctrl+Shift+Z
EventSimulator.simulate_shortcut(:select_all) # Ctrl+A

Typing Text

# Simulate typing a string (returns list of events)
events = EventSimulator.simulate_type("Hello")
# => [%Key{key: :h, char: "H"}, %Key{key: :e, char: "e"}, ...]

# Send all events
harness = ComponentHarness.send_events(harness, events)

Key Sequences

# Simulate sequence of keys
events = EventSimulator.simulate_sequence([:tab, :tab, :enter])

# With options
events = EventSimulator.simulate_sequence([
  {:a, char: "a"},
  :tab,
  :enter
])

Mouse Events

# Click
event = EventSimulator.simulate_click(10, 20)              # left click
event = EventSimulator.simulate_click(10, 20, :right)      # right click
event = EventSimulator.simulate_click(10, 20, :left, modifiers: [:ctrl])

# Double click
event = EventSimulator.simulate_double_click(10, 20)

# Mouse movement
event = EventSimulator.simulate_move(15, 25)

# Drag
event = EventSimulator.simulate_drag(10, 20, :left)

# Scroll
event = EventSimulator.simulate_scroll_up(10, 20)
event = EventSimulator.simulate_scroll_down(10, 20)

Other Events

# Focus events
event = EventSimulator.simulate_focus_gained()
event = EventSimulator.simulate_focus_lost()

# Resize
event = EventSimulator.simulate_resize(120, 40)

# Paste
event = EventSimulator.simulate_paste("Pasted content")

Assertions

Import assertions with use TermUI.Test.Assertions.

Text Assertions

# Assert exact text at position
assert_text(renderer, 1, 1, "Hello")

# Assert text does NOT appear
refute_text(renderer, 1, 1, "Goodbye")

# Assert region contains text
assert_text_contains(renderer, 1, 1, 80, "Error")
refute_text_contains(renderer, 1, 1, 80, "Success")

# Assert text exists anywhere in buffer
assert_text_exists(renderer, "Error")
refute_text_exists(renderer, "Secret")

# Assert entire row matches
assert_row(renderer, 1, "Hello, World!")

Style Assertions

# Assert foreground color
assert_style(renderer, 1, 1, fg: :red)

# Assert background color
assert_style(renderer, 1, 1, bg: :white)

# Assert multiple style properties
assert_style(renderer, 1, 1, fg: :red, bg: :white, attrs: [:bold])

# Assert single attribute
assert_attr(renderer, 1, 1, :bold)
refute_attr(renderer, 1, 1, :underline)

State Assertions

# Assert state at path
assert_state(state, [:counter, :value], 42)
refute_state(state, [:counter, :value], 0)

# Assert state exists (not nil)
assert_state_exists(state, [:user, :name])

Snapshot Assertions

# Take snapshot
snapshot = TestRenderer.snapshot(renderer)

# ... perform operations ...

# Assert matches snapshot
assert_snapshot(renderer, snapshot)

Buffer Assertions

# Assert buffer is empty
assert_empty(renderer)

Testing Patterns

Testing State Transitions

test "counter increments on up arrow" do
  {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 0)

  # Initial state
  assert ComponentHarness.get_state(harness).count == 0

  # Send event
  harness = ComponentHarness.send_event(harness, EventSimulator.simulate_key(:up))

  # Verify state changed
  assert ComponentHarness.get_state(harness).count == 1
end

Testing Rendered Output

test "displays current count" do
  {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 42)
  harness = ComponentHarness.render(harness)

  renderer = ComponentHarness.get_renderer(harness)
  assert_text_exists(renderer, "Count: 42")
end

Testing Event Sequences

test "navigation through menu" do
  {:ok, harness} = ComponentHarness.mount_test(Menu, items: ["A", "B", "C"])

  # Navigate down twice
  harness =
    harness
    |> ComponentHarness.event_cycle(EventSimulator.simulate_key(:down))
    |> ComponentHarness.event_cycle(EventSimulator.simulate_key(:down))

  # Should be on third item
  assert ComponentHarness.get_state(harness).selected == 2
end

Testing with Snapshots

test "render output matches expected" do
  {:ok, harness} = ComponentHarness.mount_test(MyWidget)
  harness = ComponentHarness.render(harness)

  renderer = ComponentHarness.get_renderer(harness)
  snapshot = TestRenderer.snapshot(renderer)

  # Store snapshot for regression testing
  # In real tests, you'd load this from a file
  expected = %{
    rows: 24,
    cols: 80,
    cells: %{...}
  }

  assert_snapshot(renderer, expected)
end

Testing Edge Cases

test "handles empty list" do
  {:ok, harness} = ComponentHarness.mount_test(List, items: [])
  harness = ComponentHarness.render(harness)

  renderer = ComponentHarness.get_renderer(harness)
  assert_text_exists(renderer, "No items")
end

test "handles boundary navigation" do
  {:ok, harness} = ComponentHarness.mount_test(List, items: ["Only item"])

  # Try to go down when already at bottom
  harness = ComponentHarness.send_event(harness, EventSimulator.simulate_key(:down))

  # Should stay at 0
  assert ComponentHarness.get_state(harness).selected == 0
end

Best Practices

  1. Use async: true for isolated tests
  2. Always call unmount/1 to clean up resources
  3. Test state and render separately for clarity
  4. Use event_cycle/2 for common send-event-then-render pattern
  5. Prefer event simulation over direct state manipulation
  6. Use assertions for clear failure messages
  7. Test edge cases: empty data, boundaries, invalid input

Next Steps