.md

Testing

Three runtimes, one test story: fast unit tests in bun test, host tests in flutter test, and Maestro flows tapping the real app on a real device. Here's what to test where — with working examples.

What to test where

LayerRunnerSpeedReach for it when…
Unitbun testmslogic, signals, store selectors — anything that isn't a widget
Host (Dart)flutter testsecondsyou wrote a custom widget adapter or touched wire decoding
E2EMaestro~30 s / flowuser journeys: launch, navigate, tap, type, assert — the whole stack

Rule of thumb: every bug gets a unit test; every user journey gets a flow. The host layer is the framework's job — you only add Dart tests when you ship your own adapters.

Unit tests (bun test)

Your app logic is plain JavaScript running on Solid primitives, so it tests as plain JavaScript: *.test.js files anywhere in the package run with bun test — signals, memos, derived state, reducers, formatting, store selectors. No device, no emulator, milliseconds per run:

// src/cart.test.js
import { test, expect } from 'bun:test';
import { createRoot } from 'solid-js';
import { createCart } from './cart';

test('total reacts to quantity', () => {
  createRoot(() => {
    const cart = createCart();
    cart.add({ id: 1, price: 10 }, 3);
    expect(cart.total()).toBe(30);
  });
});
A headless component-render harness (mount JSX on an in-memory bridge, query by testID, assert emitted ops) is on the roadmap. Today, widget-level behavior is covered by Maestro flows below — they query the same testIDs on the real app.

Host tests (flutter test)

Only needed when you write custom Dart adapters. Put widget tests next to your adapter in flutter-host/test/; the framework's own decoder, wire format, and builders are covered upstream. bun run test runs them.

E2E with Maestro

Flows are declarative YAML driving the real app through the platform accessibility tree — you write user journeys in neither of the app's two languages. The scaffold ships .maestro/smoke.yaml; add one file per journey:

# .maestro/checkout.yaml
appId: com.example.myapp
---
- launchApp
- extendedWaitUntil:                 # covers async JS boot — always start with this
    visible: { id: "home-title" }
    timeout: 15000
- tapOn: { id: "product-3" }
- inputText: "2"
- tapOn: { id: "add-to-cart" }
- swipe: { direction: UP }
- tapOn: { id: "checkout" }
- assertVisible: { id: "order-confirmed" }
- takeScreenshot: checkout-done
$ bun run test:e2e                 # all flows in .maestro/, attached device/emulator
$ maestro test .maestro/checkout.yaml   # one flow by file
[Passed] smoke (37s) · checkout (52s) — 2/2 flows
Match by id:, not visible text. Give every widget you assert or tap a testID — it becomes the platform accessibility identifier, survives copy changes, and is the same query your unit tests use. Skal widget labels live in the accessibility description, which Maestro's plain-string matcher doesn't read.

Debugging a failing flow

  • maestro hierarchy — dump exactly what Maestro sees on the current screen. If your testID isn't in there, the widget isn't in the semantics tree (or you're on the wrong screen).
  • maestro studio — point-and-click flow recorder in the browser; great for finding selectors.
  • On failure, Maestro saves screenshots + logs under ~/.maestro/tests/ and prints the path.
  • Very long screens: extremely tall single <ScrollView>s can defer semantics for their top content — prefer <ListView> (virtualized) for long pages, or assert on a shorter screen.

E2E in CI

Flows run anywhere Maestro and an emulator do. A minimal GitHub Actions job — an Android emulator on a runner, the app installed from the workflow's own build:

# .github/workflows/e2e.yml (yours to add)
- uses: reactivecircus/android-emulator-runner@v2
  with:
    api-level: 34
    script: |
      bun run build
      (cd flutter-host && flutter build apk --debug)
      adb install flutter-host/build/app/outputs/flutter-apk/app-debug.apk
      maestro test .maestro/

Flows are the release gate: if a journey breaks, the merge waits — not your users.