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.
534 lines
14 KiB
Markdown
534 lines
14 KiB
Markdown
# 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`:
|
|
|
|
```toml
|
|
[dev-dependencies]
|
|
makepad-test = { path = "../../libs/makepad_test", version = "0.1.0" }
|
|
```
|
|
|
|
### 2. Create Integration Test
|
|
|
|
Create `tests/ui.rs` in your package:
|
|
|
|
```rust
|
|
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
|
|
|
|
```bash
|
|
# 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:**
|
|
|
|
```rust
|
|
#[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
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
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
|
|
|
|
```bash
|
|
cargo test -p makepad-example-text-input --test ui -- --test-threads=1
|
|
```
|
|
|
|
### Curated Repo Suite (macOS)
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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:**
|
|
```bash
|
|
cat target/makepad_test/<package>/<test>/logs.txt
|
|
```
|
|
|
|
2. **Inspect widget snapshot:**
|
|
```bash
|
|
cat target/makepad_test/<package>/<test>/widget-snapshot.json
|
|
```
|
|
Look for `text`, `value`, `checked`, `selected` state.
|
|
|
|
3. **Inspect widget tree:**
|
|
```bash
|
|
cat target/makepad_test/<package>/<test>/widget-tree.txt
|
|
```
|
|
|
|
4. **Verify selector is scoped tightly enough**
|
|
|
|
### Hub-Level Transport Diagnostics
|
|
|
|
```bash
|
|
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
|
|
|
|
```toml
|
|
# crates/apps/map/Cargo.toml
|
|
[dev-dependencies]
|
|
makepad-test = { path = "../../../libs/makepad_test", version = "0.1.0" }
|
|
```
|
|
|
|
### 2. Create Test File
|
|
|
|
```rust
|
|
// 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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```rust
|
|
// Good - specific
|
|
app.locator(Selector::id("submit_button"))
|
|
|
|
// Bad - too broad
|
|
app.locator(Selector::widget_type("Button"))
|
|
```
|
|
|
|
### 2. Wait Before Interacting
|
|
|
|
```rust
|
|
// 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
|
|
|
|
```rust
|
|
// 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:
|
|
```bash
|
|
cat target/makepad_test/<package>/<test>/logs.txt
|
|
cat target/makepad_test/<package>/<test>/widget-snapshot.json
|
|
```
|
|
|
|
### 5. Use Visible Mode for Debugging
|
|
|
|
```bash
|
|
MAKEPAD_TEST_VISIBLE=1 \
|
|
MAKEPAD_TEST_ACTION_DELAY_MS=750 \
|
|
cargo test -p my-package --test ui -- --test-threads=1
|
|
```
|
|
|
|
## Resources
|
|
|
|
- [makepad_test README](https://github.com/makepad/makepad/blob/dev/libs/makepad_test/README.md)
|
|
- [makepad_test GUIDE](https://github.com/makepad/makepad/blob/dev/libs/makepad_test/GUIDE.md)
|
|
- [Makepad Examples](https://github.com/makepad/makepad/tree/dev/examples)
|
|
- [Studio Remote Protocol](https://github.com/makepad/makepad/blob/dev/AGENTS.md)
|
|
|
|
## 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.
|