JsonUI
← GuidesWriting screen testsJsonUI tests are JSON files — same language as your layouts. One file describes a screen or a flow, and a per-platform driver (XCUITest for iOS, Espresso / UI Automator for Android, Playwright for Web) executes it. The CLI is jsonui-test, shipped inside jsonui-cli — there is no `jui test` command. This guide covers the real shape of tests, the action/assert DSL, the drivers, and the invocation flow.~14 min read
1. Where tests liveUnder tests/ at the project root, split by type. Screen tests live in tests/screens/ (one file per layout, mirroring the layout path). Flow tests live in tests/flows/ (one file per user journey). Optional richer docs go in tests/descriptions/ and are linked from the test via the descriptionFile field.
tests/ layout
tests/
screens/
home.test.json
learn/hello-world.test.json ← screen test
flows/
login-to-profile.test.json ← flow test
descriptions/
home.description.json ← optional richer docs
2. Screen test anatomyA screen test has one `source.layout` pointer, `metadata` (name + tags), an optional `initialState.viewModel`, and a `cases[]` array. Each case has a `name` and a `steps[]` array. Steps alternate action steps (tap, input, scroll, ...) and assert steps (visible, text, count, state, ...). Keep cases small and named for what they prove. Since jsonui-cli 1.7.36 `source` is required rather than conventional, and since 1.7.37 its paths are checked for existence: a `layout` or `document` naming a file that is not there warns, names the directories it looked under — the declared one and the repository root — and says the warning becomes an error once projects have cleared their counts. Those two shipped apart, so between them a file could satisfy `source` with a path to nothing and still validate clean. If you filled `source` in during that window, one run now is worth more than the green you saw then. Since 1.7.48 the summary line says whether that check ran at all: a path kind whose directory is not there is declined rather than reported as hundreds of missing files, and the run now names what it declined — `Files: 1, Errors: 0, Warnings: 0, Path checks skipped: document, layout`. Measured on one test file declaring a missing layout and a missing document, changing nothing but the directories: `Warnings: 2` with both present, `Warnings: 1` with one, `Warnings: 0` with neither, and the suffix naming the declined kinds in the last two. The same file and the same declarations produce a clean zero when the check cannot run, which is what the suffix is for. It follows the convention the other summary fields follow — nothing skipped, no field — so absence of the suffix is the statement that both kinds were checked, and `Warnings: 0` means what you would assume. This site's own run prints no suffix, measured: both directories exist, so its zero covers both kinds. The exit code does not move — all three arms printed `PASSED` — so a zero with two kinds declined still passes a gate reading the result word. And `source.layout` is not bookkeeping: on web the driver derives the screen id from it, so a test that had no `source` was falling back to the `networkidle` readiness gate and starts waiting for a screen marker the moment you add one — a behaviour change on a field that reads like metadata. The screen-identity page covers what the marker gate does and when to decline it. Since 1.8.104 the shape itself is checked: a `source` that is not an object with a non-empty `layout` string is an error, because both drivers decode exactly that object and reject anything else at parse time. Before 1.8.104 a string `source` skipped every source check and validated clean. Measured with the pinned tool: this site's 32 screen tests pass, and a copy of one with `source` collapsed to its path string reports 1 error.
tests/screens/counter.test.json
{
"type": "screen",
"source": { "layout": "layouts/counter.json" },
"metadata": { "name": "Counter — increment flow", "tags": ["smoke"] },
"platform": "web",
"initialState": { "viewModel": { "count": 0 } },
"cases": [
{
"name": "increment once shows 1",
"steps": [
{ "assert": "text", "id": "counter_value", "equals": "0" },
{ "tap": "counter_increment_btn" },
{ "assert": "text", "id": "counter_value", "equals": "1" }
]
}
]
}
3. Flow test anatomyA flow test spans multiple screens. `sources[]` registers each layout with an alias, `initialState.screen` names the starting screen, and steps carry a `screen` discriminator so the runner knows where each interaction happens. `checkpoints[]` names inflection points (optionally with `screenshot: true`) so a reviewer can see where the flow transitioned.
tests/flows/login-to-home.test.json
{
"type": "flow",
"sources": [
{ "alias": "login", "layout": "layouts/login.json" },
{ "alias": "home", "layout": "layouts/home.json" }
],
"metadata": { "name": "Login → Home happy path" },
"platform": "web",
"initialState": { "screen": "login", "viewModels": { "login": {} } },
"cases": [
{
"name": "logs in and lands on home",
"steps": [
{ "screen": "login", "input": "email_field", "value": "demo@example.com" },
{ "screen": "login", "input": "password_field", "value": "hunter2" },
{ "screen": "login", "tap": "submit_btn" },
{ "screen": "home", "waitFor": "home_header" },
{ "checkpoint": "arrived_home", "screenshot": true }
]
}
]
}
4. The action and assert DSLTwo schemas, both defined in jsonui-test-runner/schemas/. Every step is either an action (does something) or an assert (checks something). No 'lookup' verb — identifying the target is part of each step's parameters.Actions• `tap` — id / text, optional retryTapIfNoChange.• `doubleTap` — double-tap the element.• `longPress` — long-press; optional duration.• `input` — set the target field's value.• `clear` — clear the target field.• `typeText` — types value into whatever holds keyboard focus (no id).• `hideKeyboard` — dismiss the keyboard; no-op if none. On iOS the screen needs a dismiss affordance, e.g. ScrollView `keyboardDismissMode: "interactive"` or an accessory Done.• `scroll` — scroll in a direction.• `scrollUntilVisible` — id + container + timeout. `direction` is where the search starts; if the target is not found by that end, the driver sweeps back the opposite way before giving up. That two-leg search order is the shared part on all three platforms, and only it — how each driver scrolls between those ends is per-platform, and nothing here claims those match.• `swipe` — swipe gesture.• `waitFor` / `waitForAny` — wait until the element (any of the elements) appears.• `wait` — fixed wait in ms.• `back` — navigate back.• `screenshot` — capture a named screenshot.• `readText` — store the element's text in @{variable} for later steps.• `repeat` — times or while + steps.• `retry` — maxRetries 0–3 + steps.• `setLocation` — latitude / longitude.• `addMedia` — Android: pushes media into the gallery; iOS: seeds the photo library via PhotoKit (simulator only; pick via normal picker taps, pre-grant with `jsonui-test pregrant`); web: sets a file input. Use basenames; assert existence/app state, never counts (seeding accumulates).• `alertTap` — tap an alert button (also dismisses iOS SpringBoard system alerts).• `selectOption` — pick a SelectBox option.• `tapItem` — tap a collection cell by index.• `selectTab` — switch a TabView / Segment tab by index.• `setViewport` / `setOrientation` — drive responsive conditions.• `setMocks` — switch mock scenarios mid-test.• `emitHook` — web only: invoke a hook the app registered on `window.__jsonuiTestHooks` (arguments via hookArgs); gate with `when: {platform: "web"}`.Asserts• `visible` / `notVisible` — the element is (not) shown.• `enabled` / `disabled` — interactivity state.• `text` — id + equals or contains.• `count` — id + equals.• `state` — dot-path + equals, read through a state provider.• `screenshot` — baseline compare; similarity threshold, default 98%.• `openedUrl` — web only: the URL passed to the most recent `window.open`, equals / contains; gate with `when: {platform: "web"}`.• `screen` — the named screen is displayed; the target key is `name` (not the step-level `screen`, which says where the step runs). Checks a marker code generation emits into every screen, so it needs no knowledge of the screen's contents. See screen identity (/concepts/screen-identity).Every assertion auto-waits: it polls every 100 ms up to `timeout` (default 5000 ms) until the condition holds, so explicit waits are rarely needed.Screen changes can also be verified without writing anything: the driver checks the marker every time a flow's inline step changes `screen`, waiting up to `screenTransitionTimeout` (10000 ms). `verifyScreenTransitions` is on by default from drivers iOS 1.9.0 / Android 1.8.0 / web 1.8.0, with the markers coming from SwiftJsonUI 10.8.1+ (10.8.0 emitted the iOS marker in a corner, where it fails the predicate) / KotlinJsonUI 2.15.1+ — so a project that has not rebuilt yet fails every transition as `marker-absent`. Rebuild with `jui build`, or set `verifyScreenTransitions: false` while you migrate.• Conditions (when): gate any step or assert with "when": { "platform": "ios" | "android" | "web", "responsive": <bucket | constraint> }. Screen-test cases can also carry case-level platform / responsive. A responsive value is one of the seven canonical buckets (compact / medium / regular plus their landscape variants) or a constraint object (minWidth / maxWidth / minHeight / maxHeight / orientation) — each driver resolves it to mirror that platform's own renderer breakpoints.• Skips report, never fail: an unmet condition skips the step with a skipReason (platform or responsive) in the results, and an unknown condition is treated as unmet (skipped, never run-anyway) so older drivers stay safe. Drive the environment from a test with setViewport / setOrientation. Platform + responsive gating and these actions need test-runner driver 1.2.0+ on every platform you target.
5. The driversThe same .test.json runs on three very different runtimes. Each driver is published to its registry (SPM / Maven Central / npm) — always use the latest release; the registry, not this page, is the source of truth for version numbers. A driver maps layout `id` attributes onto the platform's test-automation identifier, then executes the steps. No shared runner binary — each driver is invoked via its native build system. Write the JSON once; the drivers give you the same test on all three.• iOS — XCUITest, via Swift Package `github.com/Tai-Kimura/jsonui-test-runner-ios` (a `from:` pin resolves to the latest release; `swift package update` to catch up). id → accessibilityIdentifier. `JsonUITest.load(from:)` / `loadAll(from:)` → `JsonUITest.createRunner(app:).run(screenTest:)`; check `result.allPassed`. Runs under `xcodebuild test` or the Xcode GUI.• Android — Espresso + UI Automator, via Maven Central `io.github.tai-kimura:jsonui-test-runner-android` (needs `mavenCentral()`; Gradle wants an exact version — pin the latest from Maven Central and bump when new drivers ship). id → resource-id (Compose `testTag` exposed via `testTagsAsResourceId`). `JsonUITest.loadFromAssets(ctx, path)` → `JsonUITest.createRunner().run(test)` → `TestSuiteResult.allPassed`. Runs under `./gradlew connectedAndroidTest` or Firebase Test Lab.• Web — Playwright, via npm `jsonui-test-runner-web@latest` (the `latest` dist-tag always points at the current release). id → the HTML `id` attribute (`#id` selector — not data-testid). `TestLoader.loadFromFile(...)` / `loadFromDirectory(...)` → `new JsonUITestRunner(page).run(loadedTest)`; pass/fail via the `allPassed(result)` function. Runs under `npx playwright test`. Browser matrix, recording and screenshots are plain Playwright config — see section 7.
6. The jsonui-test CLIjsonui-test ships inside jsonui-cli — the same install as `jui` and `jsonui-doc`. Install it once via the bootstrap script. Since jsonui-cli 1.6.21 the synced tree carries a self-contained launcher at `~/.jsonui-cli/test_tools/jsonui-test` (the same pattern as `jui` — it runs without pip): if the `jsonui-test` on your PATH is an older console script from a previous pip or Homebrew install, it shadows the toolchain and rejects newer subcommands, so invoke the launcher directly (or point PATH at it) whenever a documented subcommand comes back as an invalid choice. Since jsonui-cli 1.6.28, `jsonui-test --version` (and `jsonui-doc --version`) returns the toolchain version rather than a frozen internal number, so the version display is a real identity check: if it prints something old, you genuinely are running an old copy — a quick three-way match of `~/.jsonui-cli/VERSION`, the sync-meta stamp, and `--version` settles which copy you have. The CLI handles validation and scaffolding; actually running tests happens via the platform driver above.• Install (standalone): curl -fsSL https://raw.githubusercontent.com/Tai-Kimura/jsonui-cli/main/test_tools/installer/bootstrap.sh | bash — or install the full jsonui-cli (jui / jsonui-doc included): curl -fsSL https://raw.githubusercontent.com/Tai-Kimura/jsonui-cli/main/installer/bootstrap.sh | bash (requires Python 3.10+).• Validate: `jsonui-test validate tests/` (exit 0 = pass, 1 = schema violation; accepts files or directories). When `jui.config.json` carries a `test.install` block, a passing validate also flatten-installs the validated tests into the platform test dirs — success-gated (broken tests are never distributed), full-sync (stale *.test.json removed first), and a screen-name collision aborts with exit 1. Since 1.7.50 that full sync is conditional on the run having seen everything: a partial run — one file, or a subdirectory — installs what it was given, deletes nothing, and says why, `partial run — stale files left in place: this run covered 1 of 3 declared test(s), so a missing one may simply not have been passed to this command`. Before that, passing a subset was enough to remove the rest from the destination, so a gate script that expanded a glob could uninstall the tests it did not name. Measured on a three-test fixture: the full run installed 3 and cleaned 0, then the one-file run left all three in place. `--no-install` skips the install; `--config` picks the config file. No `test.install` block → validate-only (the CI schema gate stays a pure no-op). Since 1.8.20 validate also warns, without failing, when a `selectOption` step carries more than one of `index`, `value`, `label`: `selectOption carries 2 selectors (value, label); precedence is index, then value, then label, so 'value' selects and 'label' is ignored. Write exactly one — on selectOption 'label' is the option text to select, not a step note` (measured: `Result: PASSED`, `Warnings: 1`).• Generate: `jsonui-test generate test screen|flow <name>` scaffolds a test; `generate description screen|flow <name> <case>` scaffolds a description JSON for one case.• Report: `jsonui-test report <results…> --format junit|html -o <out>` merges the results JSON the drivers emit (results.schema.json) into a JUnit XML or HTML report for CI. Since jsonui-cli 1.8.24 each failed result may also carry `failureReason`, a machine-readable counterpart to the prose `error`: one of `element-not-found`, `timeout`, `assertion`, `invalid-test`, `mock`, `setup`, `teardown`, `launch`, `action`, `other`. It names the STAGE that failed, not an exception class, because the three drivers do not share a taxonomy. It is optional and older drivers omit it, so an absent value means unknown, never "no reason" — and a driver that never emits `timeout` is not a driver that never timed out.• Mock: `jsonui-test mock generate` writes API mocks from OpenAPI into `<mockDir>/generated/`, and `--check` compares each scenario body against the schema for its status code rather than just its route. `mock serve` runs the local mock server and control panel, and validates what the app sends as well. Neither is something you have to remember: `jsonui-test validate` runs the contract check and regenerates `generated/` when it is stale. See the API Mock guide for the full workflow.• Artifacts: `jsonui-test artifacts pull` collects a run's evidence in one command — iOS xcresult attachments, Android on-device files, and web Playwright output (videos / traces plus the driver's screenshots) — into `tests/artifacts/<platform>/<stamp>/<test>/<case>/` with a `latest` symlink (`--platform ios|android|web`, default all; `--json` prints absolute paths; `--clean` removes the sources after pulling). `artifacts status` shows the resolved config + what has been collected; `mock serve --artifacts` auto-pulls after each run-target. Configure via `test.artifacts` in jui.config.json: dir / ios.xcresult / android.appId / android.adb (set `adb` when it is not on PATH; otherwise auto-discovered via PATH → ANDROID_HOME → the OS-default SDK location) / web.testResults + web.screenshotDir (default `test-results` / `screenshots`). Per-platform recording setup is covered in the next section.• Media & pregrant: media files for `addMedia` live in `test.mediaDir` (default `tests/media`; reference them by basename) — the install step ships them to the iOS target's `media/` folder. `jsonui-test pregrant` walks your tests and prepares the device before the run. On iOS it pre-grants the simulator photo permission (`photos-add`) for every reachable `addMedia` step, so no permission dialog interrupts the run. From cli 1.8.0 it also has an Android arm: it collects every `deny` under `launch.permissions` (`unset` is not collected) and revokes those permissions on the app before the instrumentation starts. Both arms run by default and each prints its own count; `--platform ios|android` limits the run to one, and `--app-id` / `--serial` name the Android package and device (defaults: `test.install.android.applicationId` and the single connected device). When the app is not installed on the device it refuses rather than guesses.• Screen names: every step's `screen` must resolve to a layout classified as a screen (`jui screens` prints the classification; `--json` adds `derivedScreens`, the complete set to audit) or to an id declared in `jui.config.json` under `test.appOwnedScreens` for hand-written pages. Violations are validator errors, so they stop the install — pass `--no-install` when you only want to inspect.
7. Recording & screenshotsEvery driver can record a run and capture screenshots — with nothing bespoke to learn: each platform uses its native mechanism, the file naming is shared, and `jsonui-test artifacts pull` collects everything into one tree. The only thing that differs per platform is how you switch recording on:• iOS — Xcode's automatic screen recording: set the scheme / test plan's preferred screen capture to screenRecording. By default (attachment lifetime = delete on success) only failed tests keep their recording — switch the lifetime to "keep all" to keep recordings for passing tests too. Recordings land in the xcresult; granularity is the XCTest method.• Android — pass `record=true` to the runner to record per test case. Failures are always kept; passing runs are discarded by default — set `keepRecordingOnSuccess(true)` on the runner builder to keep them too. Files are written on-device and fetched by `artifacts pull`.• Web — plain Playwright config: `use: { video: 'on' }` records every run (`'retain-on-failure'` keeps failures only), and `projects` pick the browsers — chromium / firefox / webkit, or `channel: 'chrome'` / `'msedge'` for branded builds. Granularity is test file × browser project. Point the runner's `screenshotDir` at `testInfo.outputDir` so the PNGs land next to each video:
playwright.config.ts + test
// playwright.config.ts — browser matrix + recording
export default defineConfig({
use: { video: 'on' }, // 'retain-on-failure' keeps failures only
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
 
// in a test — route the driver's PNGs next to each video
test('screen', async ({ page }, testInfo) => {
const runner = new JsonUITestRunner(page, { screenshotDir: testInfo.outputDir });
// runner.run(loadedTest) ...
});
Screenshots share one naming scheme on all three platforms: the `screenshot` action saves `screenshot_<test>_<case>_<name>.png`, and a failed step automatically saves `failure_<test>_<case>.png`.`jsonui-test artifacts pull` (or the MCP tool `test_artifacts_pull`) then collects recordings and screenshots from all three platforms into the artifacts tree:
tests/artifacts after pull
tests/artifacts/
ios/<stamp>/<test>/(recordings|screenshots|other)/ <- xcresult attachments
android/<stamp>/<test>/<case>/ <- pulled from the device
web/<stamp>/<spec>-<title>-<project>/recordings/video.webm
web/<stamp>/<spec>-<title>-<project>/screenshots/screenshot_<test>_<case>_<name>.png
<platform>/latest -> the newest <stamp>
8. In CINo GitHub Actions harness is shipped — you wire the three platforms yourself. The minimum safety net is `jsonui-test validate tests/` as a schema gate before any driver runs. Sharding by platform keeps wall-clock reasonable: Web shards usually finish in under a minute, iOS and Android shards depend on simulator / emulator boot time.• Example (Web): a GitHub Actions step that runs `jsonui-test validate tests/` first, then `npx playwright install --with-deps` + `npx playwright test` inside jsonui-doc-web.
GitHub Actions excerpt
# .github/workflows/test.yml (web shard)
- name: Validate test schemas
run: jsonui-test validate tests/
- name: Run Playwright tests
working-directory: jsonui-doc-web
run: |
npx playwright install --with-deps
npx playwright test
9. Launch, conditions & resultsThree cross-cutting features round out the model. Every step accepts `when` (a condition — the step is skipped unless it holds), `optional` (a failure becomes a warning and the run continues), and `label` (a human-readable name in reports). A root-level `launch` block sets the app's starting state, `teardown` steps always run — even after a failure — and the drivers emit a standardized results JSON that `jsonui-test report` turns into JUnit / HTML.• Conditions & flags: `when` gates a step on `platform` (ios / android / web / all), `responsive` (a size-class bucket like compact / regular, or min/max window-size bounds), a `state` path, or `visible` / `notVisible`; `optional: true` downgrades a failure to a warning; `label` names the step in logs and reports. A web-only step such as `openedUrl` or `emitHook` warns whenever it can still reach a mobile driver, and since 1.7.7 the wording follows what you declared: ungated, it reads `gate it with 'when': {'platform': 'web'} in cross-platform tests`; gated onto iOS, it reads `this step is gated onto ios, where it does not run`, because you have already said where you want it and the point is that it cannot run there. Gated to web, it says nothing.• launch: `clearState` (reset app data), `permissions` (camera / microphone / location / notifications / photos / contacts / calendar / bluetooth → allow / deny / unset), and `arguments` (launch arguments) — applied once before the first step. On Android, `arguments` actually reaches the app from driver 1.8.4 — earlier drivers logged the value without attaching it to the launch. Also on Android, from driver 1.8.5 `deny` is asserted rather than applied: a permission the run inherits as granted fails that file (the driver declares that an in-run revoke would kill the instrumented process, so it never executes one) — run `jsonui-test pregrant --platform android` first to put the denied baseline in place. `unset` leaves the inherited state untouched.• teardown & results: `teardown` steps run even when a case fails, so cleanup is guaranteed. The result is `{ format: 'jsonui-test-results', version: 1, platform, suites[] }` with per-case `status` (passed / failed / skipped), `error`, `warnings`, and `durationMs`.jui.config.json test.install + a test using launch and a conditional step:
jui.config.json + *.test.json
// jui.config.json — install SSoT tests to the platform test dirs
"test": {
"src": "tests",
"install": {
"ios": { "target_dir": "<ios-app>/<UITests>/GeneratedTests" },
"android": { "assets_dir": "<android-app>/app/src/androidTest/assets/tests" }
}
}
 
// a test using launch + a conditional step + teardown
{
"type": "screen",
"source": { "layout": "layouts/home.json" },
"launch": { "clearState": true, "permissions": { "location": "allow" } },
"cases": [{
"name": "shows nearby only on mobile",
"steps": [
{ "when": { "platform": ["ios", "android"] }, "assert": "visible", "id": "nearby_section" },
{ "assert": "state", "path": "user.isLoggedIn", "equals": true }
],
"teardown": [ { "tap": "logout_btn", "optional": true } ]
}]
}
Keep reading
jsonui-test-runnerThe runner this guide drives./tools/test-runner
Navigation between screensSo your tests have somewhere to navigate to./guides/navigation