Search documentation of Plushie

# Testing Reference

Copy Markdown View Source

Complete reference for the Plushie test framework. For a narrative introduction, see the Testing guide.

## Setup

    # test/test_helper.exs
    Plushie.Test.setup!()
    ExUnit.start()

`setup!/0` starts the shared renderer session pool, configures ExUnit exclusions for backend-specific tests, and registers cleanup hooks. The pool multiplexes test sessions over a single renderer process (mock/headless) or spawns per-test renderer processes (windowed).

See `Plushie.Test` for setup options including `:pool_name` and `:max_sessions` (default: `max(schedulers * 8, 128)`).

## Test cases

Module | Purpose
--- | ---
`Plushie.Test.Case` | Full app testing (starts Runtime + Bridge per test)
`Plushie.Test.WidgetCase` | Single widget testing in a harness app

### Plushie.Test.Case

    use Plushie.Test.Case, app: MyApp

Starts a fresh app instance before each test. Imports all helpers. Tests support parallel execution. Add `async: true` to your test module to run concurrently (the session pool handles isolation). On teardown, checks prop validation diagnostics and raises if any are found.

### Plushie.Test.WidgetCase

    use Plushie.Test.WidgetCase, widget: MyWidget

    setup do
      init_widget("widget-id", prop1: "value")
    end

Hosts the widget in a parameterized harness app (window > column > widget). All standard helpers are available. Additional helpers:

Function | Description
--- | ---
`last_event/0` | Most recently emitted `WidgetEvent`, or nil
`events/0` | All emitted events, newest first

Event data from the most recent event is available via `model().last_value`, which contains the atomized map from the event's value field. This is useful for asserting on structured event data emitted by the widget.

These are the only WidgetCase-specific helpers.

## Helpers by category

All imported automatically by `Plushie.Test.Case` and `Plushie.Test.WidgetCase`. See `Plushie.Test.Helpers` for full specs.

### Queries

Function | Description
--- | ---
`find(selector)` | Find element, return nil if not found
`find!(selector)` | Find element, raise if not found
`find_by_role(role)` | Find by accessibility role
`find_by_label(label)` | Find by accessibility label
`find_focused()` | Currently focused element
`text(element)` | Extract display text from an element

### Interactions

Function | Widget types | Event produced
--- | --- | ---
`click(selector, opts)` | button, any clickable | `:click`
`type_text(selector, text, opts)` | text_input, text_editor | `:input`
`submit(selector, opts)` | text_input | `:submit`
`toggle(selector, opts)` | checkbox, toggler | `:toggle`
`toggle(selector, value, opts)` | checkbox, toggler | `:toggle` with specific value
`select(selector, value, opts)` | pick_list, combo_box, radio | `:select`
`slide(selector, value, opts)` | slider, vertical_slider | `:slide`
`scroll(selector, dx, dy, opts)` | scrollable | `:scroll`
`paste(selector, text, opts)` | text_input, text_editor | `:paste`
`canvas_press(selector, x, y, opts)` | canvas | `:press` (unified pointer)
`canvas_release(selector, x, y, opts)` | canvas | `:release` (unified pointer)
`canvas_move(selector, x, y, opts)` | canvas | `:move` (unified pointer)
`press(key)` | n/a | `Plushie.Event.KeyEvent`
`release(key)` | n/a | `Plushie.Event.KeyEvent`
`type_key(key)` | n/a | press + release
`move_to(x, y)` | n/a | cursor position
`pane_focus_cycle(selector, opts)` | pane_grid | `:pane_focus_cycle`

All interactions are synchronous. They wait for the full update cycle to complete before returning. Under the hood they call `sync/1`, which returns `:ok` on success or `{:ok, :view_error}` if the last `view/1` call raised. The interaction helpers propagate this, so a view crash after an interaction won't silently pass.

### Multi-window interactions

Target a specific window using window-qualified selectors or the `window:` option:

    click("settings#save")                         # window qualifier in selector
    click("#save", window: "settings")             # explicit window: option
    type_text("settings#name", "hello")            # qualifier works everywhere
    type_text("#name", "hello", window: "settings") # equivalent

Without either, an ambiguous ID that exists in multiple windows raises an error.

### Key name parsing

Key names are normalized by the renderer: case-insensitive, with whitespace, underscores, and hyphens stripped before lookup. This means all of these are equivalent:

- `"LeftArrow"`, `"left_arrow"`, `"left-arrow"`, `"leftarrow"`, `"Left"`

Common key aliases are recognized:

- Arrow keys: `"left"` / `"arrowleft"` / `"leftarrow"`
- `"enter"` / `"return"`, `"esc"` / `"escape"`
- `"bs"` / `"backspace"`, `"del"` / `"delete"`
- `"pageup"` / `"pgup"`, `"pagedown"` / `"pgdn"`
- Single characters: `"s"`, `"a"`, `"1"` (case preserved)
- Function keys: `"F1"` through `"F12"`
- Modifier combos: `"Ctrl+s"`, `"Shift + Left_Arrow"`, `"Alt+F4"`
- Modifier aliases: `ctrl`/`control`, `alt`/`option`/`opt`, `logo`/`super`/`win`/`meta`/`command`/`cmd`

Key strings are passed through to the renderer for normalization. See the plushie-core Rust docs for the full list of supported key names and aliases.

### Assertions

Macro | Description
--- | ---
`assert_text(selector, expected)` | Widget displays expected text
`assert_exists(selector)` | Widget is in the tree
`assert_not_exists(selector)` | Widget is not in the tree
`assert_model(pattern)` | Model matches pattern
`assert_role(selector, role)` | Accessibility role matches
`assert_a11y(selector, expected)` | Accessibility props match
`assert_no_diagnostics()` | No prop validation warnings

### State inspection

Function | Description
--- | ---
`model()` | Current app model
`tree()` | Normalized UI tree
`tree_hash(name)` | Capture structural tree hash
`screenshot(name, opts)` | Capture pixel screenshot
`save_screenshot(name, opts)` | Save screenshot as PNG

### Async and effects

Function | Description
--- | ---
`await_async(tag, timeout)` | Wait for tagged async task to complete
`register_effect_stub(kind, response)` | Stub a platform effect by kind atom (e.g. `:file_open`)
`unregister_effect_stub(kind)` | Remove an effect stub
`reset()` | Re-initialise the app from scratch (stops and restarts the full supervision tree)

Effect stubs intercept effects at the renderer and return controlled responses. They register by kind (the operation type atom like `:file_open`, `:clipboard_write`), not by tag. A stub applies to all effects of that kind regardless of which tag they use. Stubs are scoped to the test session and auto-cleaned on teardown.

`reset/0` is expensive. It stops the entire Plushie supervisor tree and starts a fresh instance. Use it when you need a guaranteed clean slate mid-test. For most tests, the per-test setup from `Plushie.Test.Case` is sufficient.

## Selector syntax

Form | Matches
--- | ---
`"#widget_id"` | Local widget ID (# prefix required)
`"#scope/path/id"` | Exact scoped path
`"window_id#widget_id"` | Widget in a specific window
`"window_id#scope/path/id"` | Scoped path in a specific window
`{:text, "Save"}` | Widget with matching text (content, label, value, placeholder)
`{:role, :button}` | Widget with accessibility role
`{:label, "Name"}` | Widget with accessibility label
`:focused` | Currently focused widget

The `window_id#path` form scopes the selector to a specific window. `"main#save"` finds widget `"save"` only in window `"main"`. `"main#form/save"` finds the scoped widget `"form/save"` in window `"main"`. The window qualifier works with all ID-based helpers (`find`, `click`, `assert_text`, etc.).

Canvas element selectors use the full scoped path including the canvas ID: `"#my-canvas/handle"`. With a window qualifier: `"main#toolbar/buttons/save-btn"`.

Bare strings without a `#` prefix are not valid selectors and raise `ArgumentError`. Use `{:text, "..."}` for text content matching.

## Backend capabilities

Tests run against one of three backends. Selection: `PLUSHIE_TEST_BACKEND` env var or `config :plushie, :test_backend`.

Backend | Speed | Rendering | Screenshots | Effects
--- | --- | --- | --- | ---
`:mock` | ~ms | Protocol only | Hash only | Stubs only
`:headless` | ~100ms | Software rendering | Pixel-accurate | Stubs only
`:windowed` | ~seconds | GPU rendering | Pixel-accurate | Real

The mock backend uses focus + space for click simulation and synthetic events for canvas/select. All backends use the real renderer binary and real wire protocol.

Tests are backend-agnostic by default. The same assertions work on all three. Use tags to restrict tests to specific backends when they depend on rendering capabilities:

    @tag backend: :headless    # runs in headless + windowed, skipped in mock
    @tag backend: :windowed    # runs only in windowed

Backend capability is hierarchical: `mock < headless < windowed`. A test tagged `:headless` runs in both headless and windowed mode but is excluded from mock mode. Untagged tests run on all backends.

## Screenshots and tree hashes

    assert_tree_hash("initial-state")     # structural tree comparison
    assert_screenshot("styled-view")       # pixel comparison

Golden files are stored in `test/snapshots/` (tree hashes) and `test/screenshots/` (pixel screenshots). Each screenshot produces two files: a `.sha256` hash used for the actual assertion, and a `.png` for visual inspection. The hash is what CI checks; the PNG is a human convenience for reviewing what the UI looks like. First run creates the golden files; subsequent runs compare against them.

Some teams commit both files, others commit only the hashes and gitignore the PNGs - both are valid. To keep PNGs out of version control:

    # .gitignore
    test/screenshots/*.png

Pass `png: false` to skip writing the PNG entirely:

    assert_screenshot("styled-view", png: false)

Update golden files when the UI intentionally changes:

    PLUSHIE_UPDATE_SNAPSHOTS=1 mix test     # tree hashes
    PLUSHIE_UPDATE_SCREENSHOTS=1 mix test   # pixel screenshots

These are separate environment variables.

## Animation testing

The mock backend resolves renderer-side transitions instantly. Props snap to their target values without interpolation. The headless backend runs real interpolation; use `Command.advance_frame/1` to step through frames deterministically. The `skip_transitions` helper fast-forwards all in-flight transitions to completion in a single call.

## .plushie scripting format

Declarative test scripts with a header and instruction list:

    app: MyApp
    viewport: 800x600
    theme: dark
    backend: mock
    -----
    click "#save"
    type_text "#editor" "Hello"
    expect "Hello"
    screenshot "after-hello"
    wait 500
    assert_text "#count" "3"

Header fields: `app:` (required), `viewport:` (default 800x600), `theme:`, `backend:` (default mock).

Instructions:

Instruction | Description
--- | ---
`click SELECTOR` | Click a widget
`type_text SELECTOR TEXT` | Type into a widget
`type_key KEY` | Press and release a key
`press KEY` | Key down
`release KEY` | Key up
`move_to X Y` | Move cursor
`toggle SELECTOR [true|false]` | Toggle checkbox
`select SELECTOR VALUE` | Select from list
`slide SELECTOR VALUE` | Move slider
`expect TEXT` | Assert text appears in tree
`screenshot NAME` | Capture screenshot
`assert_text SELECTOR TEXT` | Assert widget text
`assert_model EXPR` | Assert model matches
`wait MS` | Pause for milliseconds

    mix plushie.script                           # run all in test/scripts/
    mix plushie.script path/to/test.plushie      # run specific script
    mix plushie.replay path/to/test.plushie      # replay with real windows

## See also

- `Plushie.Test.Case` - case template docs
- `Plushie.Test.Helpers` - helper function specs
- `Plushie.Test.WidgetCase` - widget testing harness
- Testing guide - narrative walkthrough
- Commands reference - effect stubs and async mechanics
- Custom Widgets reference - testing widgets with WidgetCase
- Configuration reference - test pool and backend configuration
- Mix Tasks reference - `plushie.script` and `plushie.replay`

← Previous Page Subscriptions
Next Page → Wire Protocol