nigig-org/MAKEPAD_TEST_FRAMEWORK.md
andodeki 35e26937cb docs: add official makepad_test framework guide (2026)
This guide documents the official makepad_test framework discovered in
the Makepad dev branch (libs/makepad_test/).

Key discoveries:
- makepad_test IS the official Makepad testing framework
- Located in libs/makepad_test/ with GUIDE.md and README.md
- Uses #[makepad_test] macro for Rust-native UI tests
- Provides TestApp, Selector, Locator APIs
- Supports headless and visible Studio modes
- Runs through normal cargo test

Includes:
- Complete framework reference
- Selector and locator APIs
- Waits and assertions
- Visible mode debugging
- Complete map widget test examples
- Best practices and troubleshooting

This supersedes both previous testing guides and provides the
correct approach for testing Makepad applications in 2026.
2026-07-27 17:22:16 +00:00

14 KiB

Makepad Test Framework - Official Guide (2026)

Executive Summary

makepad_test is Makepad's official Rust-native UI regression testing framework. It allows you to write UI tests that:

  • Live next to the package they exercise (in tests/ directory)
  • Run through normal cargo test
  • Drive the app through the Studio protocol in headless mode
  • Support both headless and visible Studio modes
  • Provide structured selectors, locators, waits, and assertions

Key Discovery: Makepad DOES have a testing framework - it's called makepad_test and it's located in libs/makepad_test/.

Quick Start

1. Add Dependency

Add to your package's Cargo.toml:

[dev-dependencies]
makepad-test = { path = "../../libs/makepad_test", version = "0.1.0" }

2. Create Integration Test

Create tests/ui.rs in your package:

use makepad_test::{makepad_test, Selector, TestApp};

#[makepad_test]
fn smoke_test(app: TestApp) {
    // Wait for a widget to be visible
    app.locator(Selector::id("my_button"))
        .wait_visible()
        .click();
    
    // Wait for text to appear
    app.locator(Selector::id("status_label"))
        .wait_text("Button clicked!");
}

3. Run Tests

# Headless mode (default)
cargo test -p my-package --test ui -- --test-threads=1

# Visible mode (for debugging)
MAKEPAD_TEST_VISIBLE=1 cargo test -p my-package --test ui -- --test-threads=1

Core Concepts

Authoring Model

Tests live beside the package they exercise:

examples/my_app/
├── Cargo.toml
├── src/main.rs
└── tests/
    └── ui.rs          # UI tests here

The #[makepad_test] macro:

  • Uses env!("CARGO_MANIFEST_DIR") for mount root
  • Uses env!("CARGO_PKG_NAME") for package to run
  • Keeps normal Rust workflow intact

Macro Behavior

#[makepad_test] expands to a #[test] wrapper that:

  1. Starts StudioHub::start_in_process
  2. Mounts the current package directory
  3. Runs the current package headlessly
  4. Waits for BuildStarted and AppStarted
  5. Passes a TestApp into your test body
  6. Captures failure artifacts on errors or panics

Supported signatures:

#[makepad_test]
fn smoke(app: TestApp) {
    // ...
}

#[makepad_test]
fn smoke(app: TestApp) -> Result<(), TestError> {
    // ...
    Ok(())
}

Unsupported:

  • async tests
  • methods with self
  • generic test functions
  • macro arguments

Runtime Defaults

The runtime is synchronous and serial-first:

  • Action timeout: 10s
  • Poll interval: 50ms
  • Artifacts: target/makepad_test/<package>/<test>/

The in-process runner serializes app sessions, so UI suites should be invoked with --test-threads=1.

Selectors

Selectors are snapshot-based and match structured widget state.

Constructors

Selector::all()                    // Match all widgets
Selector::id("widget_id")          // Match by widget ID
Selector::widget_type("TextInput") // Match by widget type
Selector::raw("text:hello")        // Raw query string

Builder Filters

Selector::id("button")
    .text_exact("Click Me")        // Exact text match
    .text_contains("Click")        // Partial text match
    .nth(0)                        // Nth match (0-indexed)
    .window("panel_window")        // Specific window
    .window_index(1)               // Window by index
    .any_window()                  // Any window (not just primary)

Note: Selectors default to the primary window for single-window tests.

Locators

Locator methods require exactly one visible match for interaction. This strictness prevents tests from silently clicking the wrong widget.

Common Actions

app.locator(Selector::id("input_field"))
    .wait_visible()
    .fill("hello")              // Clear and type
    .wait_value("hello")
    .press_key(KeyCode::Enter);

Available interaction helpers:

  • click() - Click the widget
  • type_text("text") - Type text (appends)
  • fill("text") - Clear and type
  • clear() - Clear the widget
  • press_key(KeyCode::Enter) - Press a key
  • press_key_with_modifiers(KeyCode::A, KeyModifiers::CTRL) - Key with modifiers
  • scroll(x, y) - Scroll
  • drag_by(x, y) - Drag

Waits and Assertions

app.locator(Selector::id("label"))
    .wait_visible()             // Wait until visible
    .wait_hidden()              // Wait until hidden
    .wait_count(5)              // Wait for count
    .wait_text("Hello")         // Wait for text
    .assert_text("Hello")       // Assert text (immediate)
    .wait_value("value")        // Wait for value
    .assert_value("value")      // Assert value (immediate)
    .wait_checked(true)         // Wait for checked state
    .assert_checked(true)       // Assert checked (immediate)
    .wait_enabled(true)         // Wait for enabled state
    .assert_enabled(true);      // Assert enabled (immediate)

Inspection Helpers

let snapshot = app.locator(Selector::id("widget")).snapshot();
let count = app.locator(Selector::all()).count();
let widget_state = app.locator(Selector::id("widget")).widget_snapshot();
let tree = app.widget_dump();
let screenshot = app.screenshot();
app.wait_for_log_contains("App started");

Lower-Level Escape Hatch

app.forward(vec![/* StudioToApp messages */]);

Structured Widget State

Each snapshot record exposes:

  • widget id - Unique identifier
  • widget type - Widget class name
  • bounds - Position and size
  • window id - Window identifier
  • window index - Window index
  • visible/enabled state - Boolean flags
  • widget-specific state (when available):
    • text - Label text
    • value - Input value
    • checked - Checkbox/toggle state
    • selected - Selection state

This covers common widgets (labels, buttons, text inputs, checkboxes, dock tabs, multi-window widgets) without scraping raw dumps.

Failure Artifacts

Failed tests write to:

target/makepad_test/<package>/<test>/

Typical contents:

  • failure.txt - Failure message
  • logs.txt - Application logs
  • widget-snapshot.json - Structured widget state
  • widget-tree.txt or widget-tree-error.txt - Raw widget tree
  • failure-screenshot.png or failure-screenshot-error.txt - Screenshot

If a capture step fails, the runtime writes a *-error.txt file instead of silently dropping the artifact.

Running Tests

Package-Local

cargo test -p makepad-example-text-input --test ui -- --test-threads=1

Curated Repo Suite (macOS)

tools/run_ui_tests.sh

This runner executes:

  • makepad-example-text-input
  • makepad-example-counter
  • makepad-example-todo
  • makepad-example-floating-panel
  • makepad-example-splash

and prints the artifact directory for each package.

Visible Studio Mode

By default, makepad_test launches the app headlessly through an in-process hub. For local debugging, you can switch to a visible Studio-backed run:

MAKEPAD_TEST_VISIBLE=1 cargo test -p makepad-example-counter --test ui -- --test-threads=1

Visible Mode Behavior

  • Reuses the same TestApp and Locator APIs
  • Connects to an already running Makepad Studio instance
  • Clears older builds for the same package before launching
  • Launches through Studio Run, so the app is visible in Studio's runview

Environment Variables

  • MAKEPAD_TEST_VISIBLE=1 - Enable visible mode
  • MAKEPAD_TEST_STUDIO=127.0.0.1:8001 - Override Studio address
  • MAKEPAD_TEST_STUDIO_MOUNT=makepad - Override mount name
  • MAKEPAD_TEST_STARTUP_DELAY_MS=1000 - Wait after app appears
  • MAKEPAD_TEST_ACTION_DELAY_MS=750 - Wait after each interaction
  • MAKEPAD_TEST_KEEP_OPEN_MS=3000 - Keep app open before shutdown

Example with Pacing

MAKEPAD_TEST_VISIBLE=1 \
MAKEPAD_TEST_STARTUP_DELAY_MS=1000 \
MAKEPAD_TEST_ACTION_DELAY_MS=750 \
MAKEPAD_TEST_KEEP_OPEN_MS=3000 \
cargo test -p makepad-example-counter --test ui -- --test-threads=1

Headless Transport

The runtime reuses the Studio protocol rather than inventing a separate automation channel:

  • The hub runs in-process
  • The app runs headless
  • Widget snapshots, screenshots, and logs move through the Studio protocol
  • Direct stdio is used for headless control where supported

This keeps the test surface aligned with how Studio itself talks to Makepad apps.

Troubleshooting

Test Times Out or Fails to Resolve Widget

  1. Inspect logs:

    cat target/makepad_test/<package>/<test>/logs.txt
    
  2. Inspect widget snapshot:

    cat target/makepad_test/<package>/<test>/widget-snapshot.json
    

    Look for text, value, checked, selected state.

  3. Inspect widget tree:

    cat target/makepad_test/<package>/<test>/widget-tree.txt
    
  4. Verify selector is scoped tightly enough

Hub-Level Transport Diagnostics

MAKEPAD_STUDIO_HUB_DEBUG=1 cargo test -p makepad-example-text-input --test ui -- --test-threads=1

Note: Screenshot capture has a longer timeout than normal widget-state queries because PNG encoding and transport cost more than structured snapshot requests.

Complete Example: Testing a Map Widget

1. Add Dependency

# crates/apps/map/Cargo.toml
[dev-dependencies]
makepad-test = { path = "../../../libs/makepad_test", version = "0.1.0" }

2. Create Test File

// crates/apps/map/tests/ui.rs
use makepad_test::{makepad_test, Selector, TestApp};

#[makepad_test]
fn map_renders(app: TestApp) {
    // Wait for map widget to be visible
    app.locator(Selector::id("map_view"))
        .wait_visible();
    
    // Take a screenshot
    let screenshot = app.screenshot();
    assert!(!screenshot.is_empty(), "Screenshot should not be empty");
}

#[makepad_test]
fn map_zoom_in(app: TestApp) {
    // Wait for map to be visible
    app.locator(Selector::id("map_view"))
        .wait_visible();
    
    // Click zoom in button
    app.locator(Selector::id("zoom_in_button"))
        .wait_visible()
        .click();
    
    // Wait for zoom level to update
    app.locator(Selector::id("zoom_label"))
        .wait_text("Zoom: 15");
}

#[makepad_test]
fn map_pan(app: TestApp) {
    // Wait for map to be visible
    app.locator(Selector::id("map_view"))
        .wait_visible();
    
    // Drag to pan
    app.locator(Selector::id("map_view"))
        .drag_by(100.0, 50.0);
    
    // Wait for coordinates to update
    app.locator(Selector::id("coords_label"))
        .wait_text("Center: 36.82, -1.29");
}

#[makepad_test]
fn map_shows_pois(app: TestApp) {
    // Wait for map to be visible
    app.locator(Selector::id("map_view"))
        .wait_visible();
    
    // Zoom in to show POIs
    app.locator(Selector::id("zoom_in_button"))
        .click();
    
    // Wait for POI markers to appear
    app.locator(Selector::widget_type("PoiMarker"))
        .wait_count(5);
}

#[makepad_test]
fn map_search(app: TestApp) {
    // Wait for search input
    app.locator(Selector::id("search_input"))
        .wait_visible()
        .fill("Nairobi");
    
    // Press Enter to search
    app.locator(Selector::id("search_input"))
        .press_key(KeyCode::Enter);
    
    // Wait for search results
    app.locator(Selector::id("search_results"))
        .wait_visible()
        .wait_count(10);
}

3. Run Tests

# Headless mode
cargo test -p nigig-map --test ui -- --test-threads=1

# Visible mode (for debugging)
MAKEPAD_TEST_VISIBLE=1 cargo test -p nigig-map --test ui -- --test-threads=1

# With pacing (for watching)
MAKEPAD_TEST_VISIBLE=1 \
MAKEPAD_TEST_STARTUP_DELAY_MS=1000 \
MAKEPAD_TEST_ACTION_DELAY_MS=750 \
MAKEPAD_TEST_KEEP_OPEN_MS=3000 \
cargo test -p nigig-map --test ui -- --test-threads=1

Current Limitations

  • Current-package execution only - Cannot test external packages
  • Synchronous API only - No async test support
  • No visual diffing - No built-in image comparison
  • No trace viewer - No timeline visualization
  • Some complex widgets - Need more structured state over time

Milestone 1 is intentionally scoped around reliable Rust-local UI regression coverage first, with cross-platform expansion and richer tooling following after the harness stabilizes.

Best Practices

1. Use Specific Selectors

// Good - specific
app.locator(Selector::id("submit_button"))

// Bad - too broad
app.locator(Selector::widget_type("Button"))

2. Wait Before Interacting

// Good - wait for visibility
app.locator(Selector::id("input"))
    .wait_visible()
    .fill("text");

// Bad - might not be ready
app.locator(Selector::id("input"))
    .fill("text");

3. Use Assertions for Immediate Checks

// Good - assert immediately
app.locator(Selector::id("label"))
    .assert_text("Hello");

// Bad - wait when you don't need to
app.locator(Selector::id("label"))
    .wait_text("Hello");

4. Inspect Failures

Always check failure artifacts when tests fail:

cat target/makepad_test/<package>/<test>/logs.txt
cat target/makepad_test/<package>/<test>/widget-snapshot.json

5. Use Visible Mode for Debugging

MAKEPAD_TEST_VISIBLE=1 \
MAKEPAD_TEST_ACTION_DELAY_MS=750 \
cargo test -p my-package --test ui -- --test-threads=1

Resources

Conclusion

makepad_test is Makepad's official UI testing framework that provides:

  • Rust-native tests (no external tools)
  • Headless and visible modes
  • Structured selectors and locators
  • Waits and assertions
  • Failure artifacts (logs, screenshots, widget dumps)
  • Studio protocol integration
  • Cross-platform support (macOS first, others coming)

This is the correct and current approach for testing Makepad applications in 2026.

Key Points:

  • Add makepad-test as a dev-dependency
  • Use #[makepad_test] macro
  • Write tests in tests/ui.rs
  • Run with cargo test --test ui -- --test-threads=1
  • Use visible mode for debugging
  • Inspect failure artifacts when tests fail

The framework is actively developed and will gain more features (visual diffing, trace viewer, cross-platform support) in future milestones.