JsonUI
← GuidesMocking APIs in testsTest a screen without its backend. jsonui-test generates mock responses from your OpenAPI / Swagger, serves them from a local mock server, and lets each test pick a scenario (success / error / empty) — so the same `.test.json` can drive the happy path and the failure path against deterministic data.~9 min read
1. What API mocks areThree moving parts: mock definition files generated from your API contract, a local mock server that serves them, and a per-test scenario switch. Nothing about your app changes — a test points the driver's MockClient at the mock server, and requests that would hit the real backend are answered from the mock files instead.
2. Mock definition filesOne `*.mock.json` file per OpenAPI operation, holding several named scenarios — typically `default`, an empty result and an error — with one named active. `activeScenario` is the fallback; tests override it per case. Since 2026-07-30 the tree has two halves: `<mockDir>/generated/<tag>/*.mock.json` is regenerated wholesale on every run and can be gitignored, while `<mockDir>/<tag>/*.mock.json` is yours and the tools never touch it. They overlay per scenario, not per file, so a hand-written file only needs the scenarios its tests actually drive — routine ones like `error_403` fill in from the generated side, and a hand-written `activeScenario` wins. Startup prints which hand-written file covered what, because a file silently shadowing another is the failure mode worth avoiding. That shape only became true of the tools in jsonui-cli 1.7.22; before it the description was the intent. Measured on a fixture with one thin overlay: v1.7.21's `mock generate` reported `Generated 0 mock file(s) …; 1 route(s) already served by a hand-written mock` and left no generated copy for that route, so there was nothing for the routine scenarios to fill in from — and `jsonui-test validate` rejected the overlay itself with `activeScenario 'default' is not among scenarios: ['two_items']`. From 1.7.22 the generated copy is written for overlaid routes too, the line reads `1 route(s) overlaid by hand-written mock(s)`, and activeScenario is checked against the merged view, so omitting it means inheritance. Same tree, one tool version apart: that error is present on 1.7.21 and gone on 1.7.23. A test's scenario references read the same merged view, and the error message shows which index answered. A screen test's `mocks` block sits at the file root and is keyed by the mock's `source.operationId` — not `api.<op>`, not the route — and the run needs `mock.swagger` declared, or the mocks are counted but never checked. Measured with a generated half declaring `default`/`empty`/`error_403` and a hand-written overlay declaring only `two_items`: on 1.7.24 both a generated-only and a hand-written-only scenario resolve, while a name in neither errors with `available: ['default', 'empty', 'error_403', 'two_items']` — the union, printed. On 1.7.23 the same hand-written-only name errors with `available: ['default', 'empty', 'error_403']`, the generated half alone. Since 1.7.26 both halves of an overlaid route are checked, and the weight differs by half rather than by luck. A drifted hand-written body gates: `[BODY] … Drift detected` and exit 1. A stale generated body does not: `[WARN] … stale generated body — regenerating fixes it` and exit 0, because regenerating fixes it. Break both and one run prints both, `[BODY]` and `[WARN]` together, exiting 1 on the gating one. Measured with the hand-written file's tag directory named to sort before `generated` and again to sort after it — same results both ways, which is the point: on 1.7.25 and earlier that same rename flipped a drifted hand-written body between `[BODY]`/exit 1 and `No drift`/exit 0, so a green `--check` was worth as much as the naming happened to be lucky. If you are moving up from 1.7.25 or earlier, run the check once rather than trusting the last green you saw. The two commands showed this differently until 1.7.50: `--check` printed a `[WARN]` line per stale generated body, while `jsonui-test validate` — the command most projects actually gate on — carried a standing denominator instead. From 1.7.50 the denominator is on both, so that contrast is gone: `--check` now opens with the same `mock contract:` line. Its closing line is not one sentence but three, chosen by what the run found — measured on three fixtures. With nothing uncompared and nothing stale it is still the old unconditional `No drift: mocks are in sync with swagger.`; with an uncompared scenario it becomes `No drift in the 5 scenario(s) compared; 1 scenario(s) were not compared — the contract line above says why`; with a stale generated body, `No drift in hand-written mocks; 1 generated body(ies) are stale — refresh with 'jsonui-test mock generate'`. So if you have the old sentence pinned somewhere it is still current text, but it is not what every clean run prints. One trap in reading the pair: the closing line's `not compared` counts only the scenarios listed as findings, while the denominator's own `not compared` bucket is wider — a run can print `1 not compared (no response body declared)` on the contract line and the bare all-clear underneath it. What still differs between the commands is which findings each lists, and the older sentences below describe the outcome of a run rather than its full transcript. Since 1.7.27 that line leads with the total and puts every scenario in exactly one bucket, and since 1.7.28 the uncompared bucket says which kind — `mock contract: 7 scenario(s) — 6 compared, 1 not compared (no response body declared), 1 generated body(ies) stale` — and it prints the compared count even when nothing is stale. Since 1.7.47 that line carries a note saying what it covers — `(this line reports gating findings and uncompared scenarios; misnamed files and optional-field omissions appear only in `jsonui-test mock generate --check`)` — on every run that reaches the gate, clean ones included; and when a stale generated body is actually present, a second line states that not failing is the convention rather than an oversight: `(generated bodies are refreshed by `jsonui-test mock generate`, so a stale one is reported and does NOT fail this check — the same convention orphans follow)`. Measured on a fixture with one stale body: both lines print, the denominator's `1 generated body(ies) stale` clause is unchanged, and the run still exits 0. The closure is the point. On 1.7.26 the same tree printed `6 scenario(s) compared`, which reads as though six were all there were; the seventh was a scenario the contract cannot compare, because the swagger declares that status with no response schema, and it belonged to neither published bucket. A line that names buckets and does not close on the total invites the reader to treat the buckets it prints as the whole, which is how a silent third bucket survives. The kinds are worth separating for the same reason the total is worth printing: Read the unit before the number: it counts scenarios across the whole tree, overlay included, so a route carrying both halves contributes both. The seven above are two operations across three files — one operation with three generated scenarios and two hand-written ones, another with two — which is why a total that looks large next to your endpoint count is not necessarily wrong. `no response body declared` is a debt you can pay — the status is declared and the payload contract is missing, which is the case above — while `declared non-JSON response` is a silence that is correct, a file or a stream that cannot be compared field by field. Read that second label as what the contract says, not as what the endpoint returns: a framework that emits a file while defaulting its declared content type to JSON lands in the debt bucket, and the label is honest about which of the two it read. A third label arrived in jsonui-cli 1.7.33: `not compared (declared but absent)`, for a scenario the swagger declares that the tree no longer has. Before it the denominator was built by walking the files, so deleting such a scenario shrank the total instead of filling a bucket, and the run stayed green. This page then published a limit on that closure which 1.7.36 removed, and the limit is worth keeping in view because it says what the closure is against. On 1.7.33 the denominator closed on declared statuses rather than on what the generator produces, so deleting a second scenario on a status another scenario still covered — the empty-list variant beside the default 200, the kind you add to give a test a branch — took the total from seven to six with every bucket at zero and both commands exiting 0, even though `mock generate` put that scenario straight back. Since 1.7.36 the set compared against is what generation produces, and it closes in both directions. Delete that same variant and `mock generate --check` prints `empty: generation produces this scenario (status 200), and this file does not have it` and exits 1. Add a scenario generation does not produce, on a status the swagger does declare, and the same command prints `handmade: generation does not produce this scenario (status 200), and the next `mock generate` will drop it` and exits 1 — which is the half that used to be silent, and the one whose loss is silent too, since the next regeneration simply discards it. Both sentences name the scenario rather than the status, which matters in the first case: the status there is still served by another scenario, so a status-shaped sentence would have been false against a file the reader can open. The summary line names every bucket it counts, eight of them as of 1.7.36, so a run that exits 1 says which kind of finding did it. Per-mock findings would bury validate's own output, which is why it counts rather than lists. On 1.7.25 that line did not exist and validate said nothing at all here; it also does not repair anything, then or now — regeneration follows the swagger's timestamp, not the drift, so with the swagger untouched a stale generated body survives any number of runs, hash unchanged. One thing the server's own startup lines will tell you, and worth reading once: a hand-written file overlays by route, and two spellings of the same path variable are the same route. Since jsonui-cli 1.7.26 a file declaring `/api/items/{id}` against a swagger route `/api/items/{item_id}` is recognised, and the server prints `loaded 1 endpoint(s)` plus `1 hand-written mock(s) overlay the generated tree:` naming the file. Measured on 1.7.25 the same tree prints `loaded 2 endpoint(s)` and no overlay line at all — the two spellings registered as two routes. So the count and the overlay line are the check: the count should be your number of distinct routes, and every hand-written file you expect to matter should be named. Which of the two registrations answered a request, before 1.7.26, came down to the spelling: the server sorts its routes by path, so the alphabetically smaller spelling won. Measured on 1.7.25 against a swagger route `/api/items/{item_id}`: a hand-written `/api/items/{id}` was served, and a hand-written `/api/items/{uid}` was not — the generated stub answered instead, while the startup lines named neither as an overlay. So on those versions a hand-written mock could be present, loaded, and never reach a single request, and which way it went depended on a variable name nobody chose for that reason. On 1.7.28 the `{uid}` spelling is served like any other. Worth knowing that this page first reported the failure as not reproducible, having only tried the spelling that happens to win. (`mock.schema.json` is an editor aid only — it is not read at runtime.) Being an aid rather than the authority has a consequence worth knowing: it is a COPY, distributed next to your mocks, and it goes stale on its own schedule. It declares `additionalProperties: false`, so your editor marks any key it does not list as invalid — while the CLI, whose vocabulary is the real one, only warns. The two disagree in both directions. A key the CLI has dropped still passes the editor: `headers` was removed from the vocabulary in 1.7.51, and setting it now earns `[WARN] … Unknown scenario key: headers` with `Result: PASSED` and exit 0 — measured, so it is a warning and not a gate. Worse in practice is the other direction: a key the CLI has ADDED is marked invalid by an old copy, which gives the author a red squiggle on a correct declaration and a reason to delete it. `skipRequestValidation` above is exactly that case — it arrived in 1.7.51. If your editor calls a documented key invalid, check the copy's date before believing it, and re-sync rather than removing the key.One mock file, three scenarios:
listProducts.mock.json
// tests/mocks/listProducts.mock.json (one file per operationId)
{
"operationId": "listProducts",
"method": "GET",
"activeScenario": "default",
"scenarios": {
"default": { "status": 200, "body": { "items": [{ "id": 1, "name": "Sample" }] } },
"empty": { "status": 200, "body": { "items": [] } },
"error": { "status": 500, "body": { "message": "boom" }, "delayMs": 200 }
}
}
3. Generating mocks`jsonui-test mock generate --swagger <openapi> --out <dir>` writes one mock file per operation into `generated/`, clearing that directory first so it is always exactly what the current contract says. Your own files live outside it and are never read for this, so there is nothing to clobber and nothing to merge by hand. Run it whenever the contract changes — or let `jsonui-test validate` do it for you, which it does when `generated/` is missing or older than the swagger, so a fresh clone and a first CI run both start from a complete set.• `--update-default` rewrites only the `default` body and the `source` route of existing files, leaving every other scenario byte-identical — the ones your tests drive keep their data. It preserves `source.swagger` too, so running it from a different directory does not churn every file. Over MCP the same generation is `test_mock_generate` (Group F), with `check: true` for the read-only form.
4. Contract checksA mock is a claim about what the server returns, and `mock generate --check` is what keeps that claim honest. Since 2026-07-30 it actually reads the bodies: every scenario is matched to the schema for its status code and compared recursively — declared types, `required` fields including nested ones and array elements, and `enum` values, with `nullable` respected. Before that it compared only the method and path, so a body describing a contract the server no longer had still reported `No drift`, which is the worst thing a drift check can do: stay quiet and reassure you. Scenarios are now identified by route rather than filename, so a project that names files after paths is no longer reported as one hundred percent orphaned; a mismatch with the naming convention is called out separately as `[NAME]` instead of being buried in a pile of ORPHAN and MISSING lines.• Findings under `generated/` are warnings — regenerating fixes them. Findings outside it are errors, because a human has to decide what the right body is.• Mocks are paired with operations by position-normalized route since jsonui-cli 1.6.46: `/api/items/{id}`, `/api/items/{item_id}` and a trailing slash are one key. A path variable's name is not part of the URL space, and OpenAPI forbids two paths that differ only in that name, so normalizing cannot merge two real routes. Before it, matching compared `source.path` as a string — renaming a variable in the contract detached every mock on that route, each was reported as an orphan, and the body comparison for those operations quietly stopped while the summary still read PASSED. A mock that matches nothing is still an orphan; only the spelling stopped mattering. Findings print the spelling each side actually wrote, not the normalized form, so the path you read is the path in the file.• The summary line states what the run actually checked, and its two mock fields follow one rule: each appears only when it has something to say, so the absence of a field is itself information. `Orphan mocks: K` appears when the gate ran. An orphan is a mock whose body is compared to nothing — a check quietly shrinking its own scope is the one failure a pass/fail line cannot otherwise show. `Unchecked mocks: N` appears when mock files were validated but nothing was declared to check them against. A warning names them too, but a summary line without the field is byte-identical to one from a project that has no mocks at all, and a reader who greps the last line would see no difference — silence is not information. Its value is a count or the word `unknown`: since 1.6.50, a `mock.mockDir` that is declared but does not resolve prints `Unchecked mocks: unknown`, because the mocks are wherever they were moved to and the config no longer points at them — the number cannot be known from here, and leaving the field out would let the line read clean again. If `.mock.json` files did turn up under the paths you handed to `validate`, since 1.6.51 the warning says how many — as a second fact, not as the field's value, because whether those are the files the declaration meant is not knowable from here either. Two facts side by side, neither borrowing the other's authority. Since 1.6.52 a project that declares nothing at all is partly covered: the run looks for a conventional `mocks/` or `tests/mocks/` directory, bounded by the project — the directory holding the config `validate` actually read, or the `.git` root when there is none. Since 1.6.54 it looks from two starting points, your test definitions and that config's own directory, so a project whose tests live in one tree and whose mocks sit beside its config is covered too. The bound is the point. Unbounded, one stray `mocks/` above a workspace resolves for every project beneath it, and the projects it would then talk about are the ones with no mocks at all. Two limits stay, and both are silent: a mock directory under a name the convention does not know, and mocks that sit above the boundary — a monorepo keeping them beside the parent, say, while the config that was read belongs to a child. Both close the same way, with one line: the boundary and the name convention constrain discovery only, never a declaration. A declared `mockDir` and `swagger` can point above the config that declares them — measured, a child project pointing at `../shared/mocks` and `../shared/openapi.json` runs the gate normally — because a declaration carries the authority a guess does not. The same order decides where the reference check builds its index, the one that resolves the `mocks` map in a test to real operation ids: since 1.6.55 a declared directory wins, and the convention is consulted only when nothing is declared. Before that a decoy `tests/mocks/` beside a project that declares `api-mocks/` was enough to make every declared operation id read as unknown. Finding nothing is still not evidence of nothing — there is no `Unchecked mocks: 0` — so read the absence of both fields as this run had nothing declared and found nothing, not as proof that the project has no mocks. The count stays out of `Warnings:`, which counts per-file findings, so a project gating on `Warnings: 0` does not fail on a finding deliberately left off the gate. Since 1.7.47 the run says so itself: under the summary line, `N finding(s) above are not counted in Warnings:` and then one sentence per reason — a status no operation declares, for which no remedy exists, and a scenario declaring its own `undeclaredStatus`, where clearing it would undo a decision somebody made on purpose. Both measured firing, and the block is absent when the count is zero, so it is not a standing line. A third reason is in the code and cannot reach you from this command: a scenario with no status at all. That scenario is itself a validation error (`'status' must be an HTTP status int, got: None`), the mock gate runs only on a run with zero errors, and every mock under the configured mockDir is validated — measured on both halves, hand-written and generated. It surfaces in `jsonui-test mock generate --check` as `[WARN] … : no status — not compared`. Until 1.7.50 that command printed no footnote, so the one class the footnote called clearable was the one you could not see explained anywhere. 1.7.50 puts the footnote on the command each finding actually appears on, with a first line matching that command's denominator — `N finding(s) above are not counted in Warnings:` on `validate`, `N finding(s) above do not fail this check:` on `--check`, which has no warnings counter to speak of. Measured: the no-status class now prints its sentence, on `--check` and only there. Note what did not change — `validate` still prints its own footnote for the two classes it can reach, so this is an addition rather than a move, and `validate`'s note that misnamed files and optional-field omissions appear only in `--check` is still printed and still true. One spelling trap sits on the same path: `undeclaredStatus` silences the gate only as an object with a non-empty `reason`, and a bare `true` is ignored by design — measured, the same scenario gated as `[STATUS]` with `true` and moved into the uncounted footnote once given a reason. Being ignored was also silent until 1.7.52, which is the part that cost people time; from that version the bare form warns and names the shape it wants — ``'undeclaredStatus' is honoured only as {"reason": "<why>"} with a non-empty reason — as written it does not suppress anything``. Measured across the two versions on one scenario: `Warnings: 0` on 1.7.51, `Warnings: 2` on 1.7.52. It is a warning, not an error, and the run still exits 0. The exit code is unchanged: keeping mocks only to feed a dev server, with no contract to check them against, is a legitimate setup — this asks, it does not decide. Neither field is ever printed as a zero, because `Orphan mocks: 0` from a run that never looked is the same sentence as a clean result. A failing mock gate flips the headline to `FAILED`; before 1.6.46 only the exit code dropped, so a run could print `PASSED` and exit 1, and a reader who trusted the word never saw it. Regeneration is counted before the summary rather than after, and its trigger watches your hand-written mocks as well as the swagger — adding one retires the generated copy of that operation immediately instead of at the next schema change — so `Files:` reads the same across a rename and its follow-up instead of alternating between two values for the same healthy state.• The server checks what your app sends, too. `mock serve` validates request bodies and query parameters against the contract, merging the path-level `parameters` an operation inherits. It does not reject the request: a violation means the implementation does not satisfy the contract, not that the screen should render an error, and answering 422 would rewrite what every test touching that endpoint is testing. Violations are deduplicated, summarized at the end of the run and set a non-zero exit code; you can also read them live at `/__jsonui__/contract-violations`.• Both run without being asked. `jsonui-test validate` reads the swagger and mock directory from the config and runs the contract check as part of validation — the old `--check` existed for a long time while nothing called it, which is precisely why stale mocks survived. Request validation is on by default for the same reason. The escape hatches are `--no-mock-check`, and for requests `mock.validateRequests=false`, `--no-validate-requests`, or `skipRequestValidation` on a single scenario. That last one only became usable as written in jsonui-cli 1.7.51: the mock server had honoured the key at serve time all along, but it was not in the validator's list of scenario keys, so setting it correctly earned `[WARN] … Unknown scenario key: skipRequestValidation` and a red marker from the editor schema — and the only way to clear the warning was to stop using the feature. Measured on the same fixture across the two versions: `Warnings: 1` on 1.7.50, `Warnings: 0` on 1.7.51, with a near-miss key (`skipRequestValidationn`) still warning on 1.7.51, so the check was narrowed rather than switched off. If you are below 1.7.51 the hatch works but is not quiet; upgrading is what makes the line above true as advice.• Two deliberate simplifications. `oneOf` / `anyOf` are checked against the first branch only — the goal is to report real violations, not to prove full conformance. An empty array is accepted without demanding the shape of its elements, because an empty array is a legitimate example of an array schema and the generator itself emits one for the `empty` scenario. Scenario names are never used to infer meaning: a response is an error shape because its status code says so, not because someone called it `not_found`.
5. Serving mocks + control panel`jsonui-test mock serve --mock-dir <dir> [--port N]` starts a local mock server plus a browser control panel at `/__jsonui__/panel` where you can flip scenarios live. The panel HTML ships inside the package (loaded via `importlib.resources`, never a repo-relative path).• Hardened by default: binds to 127.0.0.1, mints a fresh token per launch, verifies the Host header (DNS-rebind protection), and blocks path traversal.• CLI-only: because it is a long-running HTTP server that executes run-targets, `mock serve` is intentionally NOT exposed over MCP. Only `mock generate` is (Group F `test_mock_generate`).
6. Selecting scenarios from testsA root-level `mocks` map (`{ operationId: scenario }`) sets the initial scenario for a test — screen tests and flow tests alike (in a flow it is applied before launch, so even the app's very first fetch runs under the scenario; drivers 1.4.0+). A `setMocks` step switches scenarios mid-test — flow tests can switch per transition step, so a single journey can walk from a populated list to an empty one to a 500. `setMocks` cannot cross a relaunch; anything that must be in effect at startup belongs in the file-level map.Initial scenario + a mid-test switch:
scenario-switch.test.json
// switch scenarios from a screen test
{
"type": "screen",
"source": { "layout": "layouts/home.json" },
"mocks": { "listProducts": "empty" },
"cases": [{
"name": "reload surfaces the error state",
"steps": [
{ "setMocks": { "listProducts": "error" } },
{ "tap": "reload_btn" },
{ "assert": "visible", "id": "error_banner" }
]
}]
}
7. Driver integrationEach driver has a `MockClient`. Set `mockServerURL` + `token` on the runner config and the driver points the app at the mock server for the run, then resets scenarios afterward. iOS / Android / Web all speak the same admin API.• Admin API (all three drivers): POST `/__jsonui__/scenario-set` (body `{ mocks }`) and POST `/__jsonui__/reset`, authenticated with the `X-JsonUI-Token` header. An unknown operationId is rejected, so a typo fails loudly instead of silently serving the wrong data.
Keep reading
Writing screen testsThe test DSL these mocks plug into./guides/testing
CLI command referenceEvery jsonui-test subcommand, including mock./reference/cli-commands