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.
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:
- Starts
StudioHub::start_in_process - Mounts the current package directory
- Runs the current package headlessly
- Waits for
BuildStartedandAppStarted - Passes a
TestAppinto your test body - 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 widgettype_text("text")- Type text (appends)fill("text")- Clear and typeclear()- Clear the widgetpress_key(KeyCode::Enter)- Press a keypress_key_with_modifiers(KeyCode::A, KeyModifiers::CTRL)- Key with modifiersscroll(x, y)- Scrolldrag_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 textvalue- Input valuechecked- Checkbox/toggle stateselected- 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 messagelogs.txt- Application logswidget-snapshot.json- Structured widget statewidget-tree.txtorwidget-tree-error.txt- Raw widget treefailure-screenshot.pngorfailure-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-inputmakepad-example-countermakepad-example-todomakepad-example-floating-panelmakepad-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
TestAppandLocatorAPIs - 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 modeMAKEPAD_TEST_STUDIO=127.0.0.1:8001- Override Studio addressMAKEPAD_TEST_STUDIO_MOUNT=makepad- Override mount nameMAKEPAD_TEST_STARTUP_DELAY_MS=1000- Wait after app appearsMAKEPAD_TEST_ACTION_DELAY_MS=750- Wait after each interactionMAKEPAD_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
-
Inspect logs:
cat target/makepad_test/<package>/<test>/logs.txt -
Inspect widget snapshot:
cat target/makepad_test/<package>/<test>/widget-snapshot.jsonLook for
text,value,checked,selectedstate. -
Inspect widget tree:
cat target/makepad_test/<package>/<test>/widget-tree.txt -
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-testas 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.