Testing

Philosophy

Progressive fidelity: test your app’s logic with fast, pure-Gleam mock tests; promote to headless or windowed backends when you need wire-protocol verification or pixel-accurate screenshots.

Unit testing

update is pure, view returns Node values. Plain gleeunit – no framework needed.

Testing update

import gleeunit/should
import plushie/event.{WidgetClick}
import plushie/command

pub fn adding_a_todo_appends_to_list_and_clears_input_test() {
  let model = Model(todos: [], input: "Buy milk")
  let #(model, _cmd) = my_app.update(model, WidgetClick(id: "add_todo", scope: []))

  should.equal(model.todos, [Todo(text: "Buy milk", done: False)])
  should.equal(model.input, "")
}

Testing commands from update

Commands are Command(msg) union values. Pattern-match on the constructor to verify what update asked the runtime to do, without executing anything.

import gleeunit/should
import plushie/command
import plushie/event.{WidgetSubmit, WidgetClick}

pub fn submitting_todo_refocuses_the_input_test() {
  let model = Model(todos: [], input: "Buy milk")
  let #(model, cmd) = my_app.update(model, WidgetSubmit(
    id: "todo_input", scope: [], value: "Buy milk",
  ))

  should.equal(list.length(model.todos), 1)
  let assert command.Focus(widget_id: "todo_input") = cmd
}

pub fn save_triggers_an_async_task_test() {
  let model = Model(data: "unsaved")
  let #(_model, cmd) = my_app.update(model, WidgetClick(id: "save", scope: []))

  let assert command.Async(tag: "save_result", ..) = cmd
}

Testing view

import gleam/option
import plushie/tree
import plushie/node

pub fn view_shows_todo_count_test() {
  let model = Model(
    todos: [Todo(id: 1, text: "Buy milk", done: False)],
    input: "",
    filter: "all",
  )
  let tree = my_app.view(model)

  let assert option.Some(counter) = tree.find(tree, "todo_count")
  let assert option.Some(node.StringVal(content)) =
    dict.get(counter.props, "content") |> option.from_result()
  should.be_true(string.contains(content, "1"))
}

Testing init

pub fn init_returns_valid_initial_state_test() {
  let #(model, _cmd) = my_app.init(dynamic.nil())

  should.be_true(list.is_empty(model.todos))
  should.equal(model.input, "")
}

Tree query helpers

plushie/tree provides helpers for querying view trees directly:

import plushie/tree
import gleam/option

tree.find(tree, "my_button")            // find node by ID
tree.exists(tree, "my_button")          // check existence
tree.ids(tree)                          // all IDs (depth-first)
tree.find_all(tree, fn(node) {          // find by predicate
  node.kind == "button"
})

These work on the raw Node values returned by view. No test session or backend required.

JSON tree snapshots

For complex views, snapshot the entire tree as JSON to catch unintended structural changes. plushie/testing/snapshot.assert_tree_snapshot compares a tree against a stored JSON file at the unit test level – no backend needed.

import plushie/testing/snapshot

pub fn initial_view_snapshot_test() {
  let #(model, _cmd) = my_app.init(dynamic.nil())
  let tree = my_app.view(model)

  snapshot.assert_tree_snapshot(tree, "initial_view", "test/snapshots")
}

First run writes the file. Subsequent runs compare and fail with a diff on mismatch. Update after intentional changes:

PLUSHIE_UPDATE_SNAPSHOTS=1 gleam test

This is a pure JSON comparison – it normalizes key ordering for stable output. It is distinct from the framework’s tree_hash.assert_tree_hash (which uses SHA-256 hashes of the tree) and screenshot.assert_screenshot (which compares pixel data).

The test framework

Unit tests cover logic. But they cannot click a button, verify a widget appears after an interaction, or catch a rendering regression when you bump iced. That is what the test framework is for.

import gleeunit/should
import gleam/option
import plushie/testing as test
import plushie/testing/element

pub fn clicking_increment_updates_counter_test() {
  let session = test.start(counter_app)
  let session = test.click(session, "increment")

  let assert option.Some(el) = test.find(session, "count")
  let assert option.Some(text) = element.text(el)
  should.equal(text, "1")
}

plushie/testing.start creates a session, runs init, and normalizes the initial view. State is threaded explicitly through each operation – no process dictionary, no mutable state.

Selectors, interactions, and assertions

Where do widget IDs come from?

Every widget in plushie gets an ID from the first argument to its builder or constructor. For example, ui.button("save_btn", "Save", []) creates a button with ID "save_btn".

When using the test framework, pass the ID directly (no # prefix):

test.click(session, "save_btn")
test.find(session, "save_btn")

Element handles

test.find returns Option(Element). The Element type wraps a Node with convenient accessors:

import plushie/testing/element

let assert option.Some(el) = test.find(session, "my-button")
element.id(el)        // => "my-button"
element.kind(el)      // => "button"
element.text(el)      // => Some("Click me")
element.children(el)  // => [...]

Use element.text to extract display text from an element:

let assert option.Some(el) = test.find(session, "count")
let assert option.Some(txt) = element.text(el)
should.equal(txt, "42")

element.text checks props in order: content, label, value, placeholder. Returns None if no text prop is found.

Interaction functions

All interaction functions take a session and return an updated session. State threading is explicit.

FunctionWidget typesEvent produced
test.click(session, id)buttonWidgetClick(id:, scope: [])
test.type_text(session, id, text)text_input, text_editorWidgetInput(id:, scope: [], value: text)
test.submit(session, id)text_inputWidgetSubmit(id:, scope: [], value: val)
test.toggle(session, id)checkbox, togglerWidgetToggle(id:, scope: [], value: !current)
test.select(session, id, value)pick_list, combo_box, radioWidgetSelect(id:, scope: [], value: val)
test.slide(session, id, value)slider, vertical_sliderWidgetSlide(id:, scope: [], value: val)

Assertions

import gleeunit/should
import gleam/option
import plushie/testing as test
import plushie/testing/element

// Text content
let assert option.Some(el) = test.find(session, "count")
let assert option.Some(txt) = element.text(el)
should.equal(txt, "42")

// Existence
should.be_true(option.is_some(test.find(session, "my-button")))
should.be_true(option.is_none(test.find(session, "admin-panel")))

// Full model equality
should.equal(test.model(session), expected_model)

// Direct model inspection
should.equal(test.model(session).count, 5)

// Element type
let assert option.Some(el) = test.find(session, "count")
should.equal(element.kind(el), "text")

API reference

All functions are in plushie/testing:

FunctionDescription
test.start(app)Start a test session, run init and render initial view
test.model(session)Returns the current app model
test.tree(session)Returns the current normalized UI tree
test.find(session, id)Find element by ID, returns Option(Element)
test.click(session, id)Click a button widget
test.type_text(session, id, text)Type text into a text_input or text_editor
test.submit(session, id)Submit a text_input (simulates pressing enter)
test.toggle(session, id)Toggle a checkbox or toggler
test.select(session, id, value)Select a value from pick_list, combo_box, or radio
test.slide(session, id, value)Slide a slider to a numeric value
test.send_event(session, event)Dispatch a raw event through the update cycle
test.element_text(element)Extract text content from an Element
test.element_prop(element, key)Get a prop value from an Element
test.element_children(element)Get an element’s children

Additional modules:

ModuleKey functions
plushie/testing/elementfind, text, prop, id, kind, children, find_all
plushie/testing/snapshotassert_tree_snapshot, node_to_json
plushie/testing/tree_hashhash, assert_tree_hash
plushie/testing/screenshotempty, save_png, assert_screenshot
plushie/testing/scriptparse, parse_file

Backends

All tests work on all backends. Write tests once, swap backends without changing assertions.

Three backends

:pooled_mock:headless:windowed
Speed~ms~100ms~seconds
RendererYes (--mock)Yes (--headless)Yes
Display serverNoNoYes (Xvfb in CI)
Protocol round-tripYesYesYes
Structural tree hashesYesYesYes
Pixel screenshotsNoYes (software)Yes
EffectsCancelledCancelledExecuted
SubscriptionsTracked, not firedTracked, not firedActive
Real renderingNoYes (tiny-skia)Yes (GPU)
Real windowsNoNoYes

Backend selection

You never choose a backend in your test code. Backend selection is an infrastructure decision made via environment variable or application config. Tests are portable across all three.

PrioritySourceExample
1Environment variablePLUSHIE_TEST_BACKEND=headless gleam test
2Default:pooled_mock

Snapshots and screenshots

Plushie has three distinct regression testing mechanisms. Understanding the difference is important.

Structural tree hashes (assert_tree_hash)

tree_hash.assert_tree_hash captures a SHA-256 hash of the serialized UI tree and compares it against a golden file. It works on all three backends because every backend can produce a tree.

import plushie/testing as test
import plushie/testing/tree_hash

pub fn counter_initial_state_test() {
  let session = test.start(counter_app)
  tree_hash.assert_tree_hash(test.tree(session), "counter-initial", "test/snapshots")
}

pub fn counter_after_increment_test() {
  let session = test.start(counter_app)
  let session = test.click(session, "increment")
  tree_hash.assert_tree_hash(test.tree(session), "counter-at-1", "test/snapshots")
}

Golden files are stored in test/snapshots/ as .sha256 files. On first run, the golden file is created automatically. On subsequent runs, the hash is compared and the test fails on mismatch.

To update golden files after intentional changes:

PLUSHIE_UPDATE_SNAPSHOTS=1 gleam test

Pixel screenshots (assert_screenshot)

screenshot.assert_screenshot captures real RGBA pixel data and compares it against a golden file. It produces meaningful data on both the :windowed backend (GPU rendering via wgpu) and the :headless backend (software rendering via tiny-skia). On :pooled_mock, it silently succeeds as a no-op (returns an empty hash, which is accepted without creating or checking a golden file).

Note that headless screenshots use software rendering, so pixels will not match GPU output exactly. Maintain separate golden files per backend, or use headless screenshots for layout regression testing only.

import plushie/testing as test
import plushie/testing/screenshot

pub fn counter_renders_correctly_test() {
  let session = test.start(counter_app)
  let session = test.click(session, "increment")
  // screenshot capture would come from a backend-specific session
  screenshot.assert_screenshot(
    screenshot.empty("counter-at-1"),
    "counter-at-1",
    "test/screenshots",
  )
}

Golden files are stored in test/screenshots/ as .sha256 files. The workflow is the same as structural snapshots but uses a separate env var:

PLUSHIE_UPDATE_SCREENSHOTS=1 gleam test

Because screenshots silently no-op on pooled_mock, you can include assert_screenshot calls in any test without conditional logic. They will produce assertions when run on the headless or windowed backends.

JSON tree snapshots (assert_tree_snapshot)

snapshot.assert_tree_snapshot is a unit-test-level tool that compares a raw tree Node against a stored JSON file. No backend or session needed. See the Unit testing section above.

When to use each

Script-based testing

.plushie scripts provide a declarative format for describing interaction sequences. The format is a superset of iced’s .ice test scripts – the core instructions (click, type, expect, snapshot) use the same syntax. Plushie adds assert_text, assert_model, screenshot, wait, and a header section for app configuration.

The .plushie format

A .plushie file has a header and an instruction section separated by -----:

app: my_counter_app
viewport: 800x600
theme: dark
backend: pooled_mock
-----
click "#increment"
click "#increment"
expect "Count: 2"
tree_hash "counter-at-2"
screenshot "counter-pixels"
assert_text "#count" "2"
wait 500

Header fields

FieldRequiredDefaultDescription
appYesApp module name
viewportNo800x600Viewport size as WxH
themeNodarkTheme name
backendNopooled_mockBackend: pooled_mock, headless, or windowed

Lines starting with # are comments (in both header and body sections).

Instructions

InstructionSyntaxMock supportDescription
clickclick "selector"YesClick a widget
typetype "selector" "text"YesType text into a widget
type (key)type enterYesSend a special key (press + release). Supports modifiers: type ctrl+s
expectexpect "text"YesAssert text appears somewhere in the tree
tree_hashtree_hash "name"YesCapture and assert a structural tree hash
screenshotscreenshot "name"No-op on pooled_mockCapture and assert a pixel screenshot
assert_textassert_text "selector" "text"YesAssert widget has specific text
assert_modelassert_model "expression"YesAssert expression appears in inspected model (substring match)
presspress keyYesPress a key down. Supports modifiers: press ctrl+s
releaserelease keyYesRelease a key. Supports modifiers: release ctrl+s
movemove "selector"No-opMove mouse to a widget (requires widget bounds)
move (coords)move "x,y"YesMove mouse to pixel coordinates
waitwait 500Ignored (except replay)Pause N milliseconds

Running scripts

# Run all scripts in test/scripts/
gleam run -m plushie/testing/script_runner

# Run specific scripts
gleam run -m plushie/testing/script_runner -- test/scripts/counter.plushie test/scripts/todo.plushie

Replaying scripts

gleam run -m plushie/testing/script_runner -- --replay test/scripts/counter.plushie

Replay mode forces the :windowed backend and respects wait timings, so you see interactions happen in real time with real windows. Useful for debugging visual issues, demos, and onboarding.

Testing async workflows

On the pooled_mock backend

The pooled_mock backend executes Async, Stream, and Done commands synchronously. When update returns a command like command.Async(work: fn() { fetch_data() }, tag: "data_loaded"), the backend immediately calls the function, gets the result, and dispatches an AsyncResult event through update – all within the same call.

pub fn fetching_data_loads_results_test() {
  let session = test.start(my_app)
  let session = test.click(session, "fetch")
  // On pooled_mock, the async command already executed synchronously.
  // The model is already updated.
  let model = test.model(session)
  should.be_true(list.length(model.results) > 0)
}

Widget ops (focus, scroll), window ops, and timers are silently skipped on pooled_mock because they require a renderer. Test the command shape at the unit test level instead:

pub fn clicking_fetch_starts_async_load_test() {
  let model = Model(loading: False, data: option.None)
  let #(model, cmd) = my_app.update(model, WidgetClick(id: "fetch", scope: []))

  should.equal(model.loading, True)
  let assert command.Async(tag: "data_loaded", ..) = cmd
}

On headless and windowed backends

All three backends now use the shared CommandProcessor to execute async commands synchronously. Async results are available immediately on all backends because the commands have already completed.

Debugging and error messages

Element not found

test.find(session, "nonexistent")
// => None

Use test.tree to inspect the current tree and verify the widget’s ID:

let tree = test.tree(session)
io.debug(tree)

Inspecting state when a test fails

test.model and test.tree are your best debugging tools:

pub fn debugging_a_failing_test_test() {
  let session = test.start(my_app)
  let session = test.click(session, "increment")

  io.debug(test.model(session))
  io.debug(test.tree(session))

  let assert option.Some(el) = test.find(session, "count")
  let assert option.Some(txt) = element.text(el)
  should.equal(txt, "1")
}

CI configuration

Pooled mock CI (simplest)

No special setup. Works anywhere Gleam runs.

- run: gleam test

Headless CI

Requires the plushie binary (download or build from source).

- run: PLUSHIE_TEST_BACKEND=headless gleam test

Windowed CI

Requires a display server and GPU/software rendering. Two options:

Option A: Xvfb (X11)

- run: sudo apt-get install -y xvfb mesa-vulkan-drivers
- run: |
    Xvfb :99 -screen 0 1024x768x24 &
    export DISPLAY=:99
    export WINIT_UNIX_BACKEND=x11
    PLUSHIE_TEST_BACKEND=windowed gleam test

Option B: Weston (Wayland)

Weston’s headless backend provides a Wayland compositor without a physical display. Combined with vulkan-swrast (Mesa software rasterizer), this runs the full rendering pipeline on CPU.

- run: sudo apt-get install -y weston mesa-vulkan-drivers
- run: |
    export XDG_RUNTIME_DIR=/tmp/plushie-xdg-runtime
    mkdir -p "$XDG_RUNTIME_DIR" && chmod 0700 "$XDG_RUNTIME_DIR"
    weston --backend=headless --width=1024 --height=768 --socket=plushie-test &
    sleep 1
    export WAYLAND_DISPLAY=plushie-test
    PLUSHIE_TEST_BACKEND=windowed gleam test

On Arch Linux, weston and vulkan-swrast are available via pacman.

Progressive CI

Run pooled_mock tests fast, then promote to higher-fidelity backends for subsets:

# All tests on pooled_mock (fast, catches logic bugs)
- run: gleam test

# Full suite on headless for protocol verification
- run: PLUSHIE_TEST_BACKEND=headless gleam test

# Windowed for pixel regression (tagged subset)
- run: |
    Xvfb :99 -screen 0 1024x768x24 &
    export DISPLAY=:99
    PLUSHIE_TEST_BACKEND=windowed gleam test

Wire format in test backends

The headless and windowed backends communicate with the renderer using the same wire protocol as the production Bridge. By default, both use MessagePack ({packet, 4} framing). JSON is available for debugging via environment variable or session options.

The pooled_mock backend does not use a wire protocol (pure Gleam, no renderer process), so the format option has no effect on it.

Testing extensions

Extension widgets have two testing layers: Gleam-side logic (struct building, command generation, demo app behavior) and Rust-side rendering (the widget actually renders, handles events, etc.).

Gleam-side: unit tests (no renderer)

Extension modules generate types, setters, and protocol implementations. Test these directly:

import gleeunit/should

pub fn new_creates_struct_with_defaults_test() {
  let gauge = my_gauge.new("g1", value: 50)
  should.equal(gauge.id, "g1")
  should.equal(gauge.value, 50)
}

pub fn build_produces_correct_node_test() {
  let node = my_gauge.new("g1", value: 75) |> my_gauge.build()
  should.equal(node.kind, "gauge")
}

Demo apps test the extension in context:

import plushie/tree
import gleam/option

pub fn view_produces_a_gauge_widget_test() {
  let #(model, _cmd) = my_gauge_demo.init(dynamic.nil())
  let tree = my_gauge_demo.view(model) |> tree.normalize()
  let assert option.Some(gauge) = tree.find(tree, "my-gauge")
  should.equal(gauge.kind, "gauge")
}

Rust-side: unit tests (no Gleam)

The plushie_ext::testing module provides TestEnv and node factories for testing WidgetExtension::render() in isolation:

use plushie_ext::testing::*;
use plushie_ext::prelude::*;

#[test]
fn gauge_renders_without_panic() {
    let ext = MyGaugeExtension::new();
    let test = TestEnv::default();
    let node = node_with_props("g1", "gauge", json!({"value": 75}));
    let env = test.env();
    let _element = ext.render(&node, &env);
}

End-to-end: through the renderer

To verify extension widgets survive the wire protocol round-trip and render correctly, build a custom renderer binary that includes the extension’s Rust crate, then run tests through it with the headless backend:

# Run tests through the real renderer (headless, no display server)
PLUSHIE_TEST_BACKEND=headless gleam test

Write end-to-end tests with the test framework:

import plushie/testing as test
import gleam/option

pub fn gauge_appears_in_rendered_tree_test() {
  let session = test.start(my_gauge_demo_app)
  should.be_true(option.is_some(test.find(session, "my-gauge")))
}

pub fn gauge_responds_to_push_command_test() {
  let session = test.start(my_gauge_demo_app)
  let session = test.click(session, "push-value")
  let assert option.Some(el) = test.find(session, "value-display")
  let assert option.Some(txt) = element.text(el)
  should.equal(txt, "42")
}

These tests run on :pooled_mock by default (fast, logic-only). Set PLUSHIE_TEST_BACKEND=headless to exercise the full Rust rendering path with the extension compiled in.

Known limitations

Workarounds and details for each limitation are noted inline below.

Search Document