GuidesBranch testsOne branchContracts declaration, three platforms of real-stack unit tests: `jsonui-test generate branch-tests` emits vitest for web, JUnit4 (Robolectric) for Android, and XCTest for iOS from the same branch tables — mocking nothing but the HTTP boundary. This page covers the command, the generated files and who owns them, the harness contract on each platform, the errors that stop generation, and how to trust the result.12 min read
1. One declaration, three platforms`jsonui-test generate branch-tests <screen>` resolves `<spec_directory>/<screen>.spec.json` (from jui.config.json; `--spec` overrides) and emits three files per platform: an @generated test file — one test per declared branch (arrange = baseline, then the condition's witness, then when's data entries, later wins — a baseline that does not actually set up the pre-state is how a contract ends up asserting nothing, see section 12; act = the method plus settle(); assert = the then entries — and, since jsonui-cli 1.7.54, one inferred assert ahead of them when `when` carries `api.<op>: "<scenario>"`: that the route was hit at least once, failing as `route '<op>' declared in when was never hit (0 requests)`. Before that, a branch could arrange an error scenario and still go green off a local failure path that never issued the request. Measured on one fixture across the two generators: 1.7.53 emits no such assert, 1.7.54 emits exactly one, placed before the then entries so an unreached route is reported as the cause rather than as a data mismatch. It is inferred, not sovereign: write anything about that surface in `then` — `called`, `not-called`, `.request`, or `api: "none"` — and it is not emitted, measured on both `called` and `api: "none"`; an inferred assertion never contradicts or duplicates a written one), with note-only branches listed as numbered comments so the coverage boundary stays visible — an @generated shared runtime, and a consumer-owned harness skeleton emitted only when the file does not exist. Common prerequisites: `endpoint` declarations on the dataFlow methods the contract references, and mock files under the mocks directory (*.mock.json with source.method/path and named scenarios) — resolved as `--mocks-dir` first, then `mock.mockDir` from jui.config.json (honored since jsonui-cli 1.6.32), then `tests/mocks`; when nothing binds, the error names the directory it searched and how many mock files it found there. Each platform then needs its own runner (sections 3–5). Agents reach the same generator through the `test_generate_branch_tests` MCP tool (jsonui-mcp-server 2.9.0), which maps every flag above — restart Claude Code after updating the server so the new tool is visible.Ownership at a glance: the test file and the runtime are @generated — regenerate after every spec edit, they are rewritten in place; the harness (one per screen) is yours, and re-generation never overwrites it.Since jsonui-cli 1.6.24, validate also holds these endpoint declarations against the project's API canon — structurally opt-in: it fires only when the project has an api_directory with OpenAPI documents. Three warning classes: a path no document declares, a path declared but not for that HTTP method (the declared methods are listed), and parameter-spelling differences (the canonical spelling is quoted). Non-HTTP declarations (`RTDB onValue(...)` and the like) are always legal and never checked, and validation stays PASSED — these are warnings:The check is opt-in on the canon, not on the declarations — and since jsonui-cli 1.7.15 it says so. If a spec declares endpoints and no OpenAPI document is found under api_directory, validate warns on that file and names both the directory it searched and the config that answered. Since 1.7.18 the count carries its unit, because there are two: `N endpoint(s), declared in M place(s)`. Routes are unique on verb plus normalized path, so two spellings of one path parameter are one route; places count every site that declares one, and a spec listing a route in `apiEndpoints` as well as on a method declares it twice. Read routes for how much contract is unchecked and places for how much work the check skipped — measured on one fixture, dropping the `apiEndpoints` block took it from `2 endpoint(s), declared in 4 place(s)` to `2 endpoint(s), declared in 2 place(s)`. A run's total is the sum of what the batch prints; the notice is per file. Only one of the two columns survives that sum, though: places add up, because a declaring site belongs to exactly one file, while routes are made unique within a file and a route two specs both declare is reported by both. Measured — two specs sharing a single route each print `1 endpoint(s), declared in 1 place(s)` over a corpus of one route and two places. A parent spec contributes nothing of its own here: measured on a parent with two subs, only the subs' files carry the notice. It is not 'every route matches' — nothing was compared. Before 1.7.15 the case was silent, and the silence was indistinguishable from a project whose routes all match: measured on one fixture, v1.7.14 returned PASSED with zero warnings while four declarations went unchecked. Since 1.7.17 a declared api_directory answers even when it holds nothing. That matters because a path that resolves to nothing used to read as permission to keep searching: the walk fell through to a shallower config, borrowed its canon, and the warnings you got blamed your routes for not being in a document that was never yours to compare against. Measured on one fixture with a broken api_directory — v1.7.16 raised two warnings naming the spec's own endpoints, v1.7.17 raises one naming the broken path. What did not change: a config that says nothing about the API still falls through to an ancestor's, which is what keeps a monorepo layout working — same fixture, one key removed, ancestor canon in force again. So on a face that declares api_directory, losing the canon now produces either the notice or an unreachable-declaration error; the remaining silence is the case where no spec declares an endpoint at all, where there is nothing to compare and the zero is an honest zero. This site is that case: 103 specs, no endpoint declarations, no warnings.The runtime is shared per output directory, so the unit of regeneration is the project, not the screen. After a release that changes the runtime's shape, regenerating one screen swaps that shared file for everyone while the other screens' tests still expect the old one — they stop compiling. Since jsonui-cli 1.6.34 the command names the siblings it did not touch, so the situation is visible at the moment it is created; it still will not regenerate them for you, because rewriting generated files nobody asked about is the kind of help that costs more than it saves. Since jsonui-cli 1.7.32 you can ask instead of remembering: `generate branch-tests --check` writes nothing, reports drift against the @generated files on disk, and exits non-zero. Naming no screen covers every spec that declares branchContracts, for the same reason the unit of regeneration is the project. The drift it catches is one no other gate does — the generated test embeds copies of the mock scenario bodies, so editing a mock alone leaves the test asserting the old payload. Measured: after generating, changing only a mock body and regenerating nothing takes the run from `1 up to date, 0 stale` and exit 0 to a `[DRIFT]` line naming the test file, `0 up to date, 1 stale`, and exit 1.Since jsonui-cli 1.8.71 the shared runtime refuses on the READ side too: `countFor` and `lastBodyFor` reject an op no route declares, and name the ones that are declared. They used to answer 0 and nothing — both plausible values, which is the whole defect: an assert written against a misspelled op checked nothing and stayed green for as long as it existed. Generated asserts cannot reach the refusal, because every op they name comes from the same route table; it is there for the hand-written tests this runtime is exported for. Four edges are worth knowing before reading it as a guarantee — three shared, one that differs by platform. It does not reach a test that filters the public `calls` list by op directly, comparing the op field instead of calling either accessor: no name is passed to anything that could refuse it. It only hides a defect where the assert expects zero or nothing — one expecting a value, a length, or a non-empty result goes red on its own. And ‘expects zero’ has a second form that never spells a zero: a count taken before the act and compared with the count after, which under a misspelled op is zero on both sides and agrees. The fourth is the per-platform one, and it was measured on the generated runtimes rather than taken from a note. On Android and iOS the route table is optional — `Recorder(routeOps: Set<String>? = null)` and `init(routeOps: Set<String>? = nil)` — and a recorder built without one refuses nothing, so a hand-written harness opts in by passing `routeOps`. On web there is no such recorder: `installFetchMock(routes, …)` takes the routes as a required argument and derives the declared set from them, so that face is always armed. Requiring the argument everywhere would have been a compile error in every hand-written harness that builds its own recorder, correctly spelled ones included, which is why it was not done.
endpoint vs OpenAPI canon — one seeded drift each (validate stays PASSED)
[WARNING] ...methods[2].endpoint: Endpoint 'POST /api/ghosts' is not declared in any OpenAPI document under api_directory — update the spec to the canonical route, or document the route[WARNING] ...methods[3].endpoint: Endpoint path '/api/items/{itemId}' is declared in the API document but not for DELETE (declared: GET, PUT)[WARNING] ...methods[1].endpoint: Endpoint path parameters differ from the API document: spec '/api/items/{item_id}' vs canonical '/api/items/{itemId}'two units, and what a broken api_directory looked like before it was named
docs/screens/json/Items.spec.json [WARNING] dataFlow: 2 endpoint(s), declared in 4 place(s), were not checked: no OpenAPI document was found under api_directory ('…/sub/docs/api', from '…/sub/jui.config.json'). This is not 'every route matches' — nothing was compared. # drop the apiEndpoints block — the same two routes, half the places: [WARNING] dataFlow: 2 endpoint(s), declared in 2 place(s), were not checked: ... # the same fixture, same broken api_directory, on v1.7.16 — your routes blamed: [WARNING] ...methods[0].endpoint: Endpoint 'GET /api/items' is not declared in any OpenAPI document under api_directory — update the spec to the canonical route, or document the route [WARNING] ...methods[1].endpoint: Endpoint 'POST /api/items' is not declared ...the command names the siblings it did not touch
tests/unit/generated/jsonui-branch-runtime.ts (shared runtime) note: 1 other generated test file(s) share this runtime — regenerate them too if it changed shape (checkout_screen.branches.test.ts)2. Mock only the HTTP boundaryThe real ViewModel, UseCase, and Repository run in every test, and so does response decoding — the web runtime stubs fetch, Android points real Retrofit (and kotlinx-serialization decoding) at MockWebServer, iOS intercepts at URLProtocol under real Codable decoding. That is what lets "the call returned 2xx but the payload was wrong" failures land in the net instead of being mocked away. Every declared endpoint that has a mock answers with its default scenario, so incidental calls during the method do not fail the test.`api: "none"` asserts zero calls on the declared surface (the matched routes). Unrelated traffic — an analytics SDK posting telemetry, say — is recorded for diagnosis and answered with an error status, but does not fail the contract. This matters most on iOS, where interception is process-wide.Declare every operation the method reaches, not just the one the branch is about. A method that also calls another screen's API still makes that call under test; without a declaration it has no route, the request goes unmatched, and the run stops partway with a failure that reads like the branch itself is broken.File-backed scenarios — a CSV or PDF export, declared with `contentType` and `bodyFile` instead of an inline `body` — are honored as far as the generated test can be self-contained: the status and the declared content type come back faithfully, the body comes back empty, and the file's contents are never embedded. So a branch can pin that the call happened, what status it carried, and that an implementation branching on content type takes the right turn; it cannot pin anything about the bytes. Reaching into them with `@response.<path>` is a generation error — the same mock boundary that section 5 of the contracts guide draws around server-chosen values.
3. Web (vitest)Web is the default platform — no flags needed. Outputs land under tests/unit/; the project needs vitest (scope its config to the unit directory so an existing Playwright `npm test` stays untouched). Two conventions hold for every transcript on this page: the tool prints paths absolute and they are shown here relative to the project root, and `<version>` on the closing line stands in for the number the tool prints there — that line names the toolchain that generated the files, and it is whichever one you ran rather than one this page can name for your install.If `jsonui-test` rejects branch-tests as an invalid choice, an older console script on your PATH is shadowing the toolchain — use the launcher at `~/.jsonui-cli/test_tools/jsonui-test`.
web (default) — output
$ jsonui-test generate branch-tests profile_screenGenerated branch tests for 'profile_screen': tests/unit/generated/profile_screen.branches.test.ts (3 declared branch(es), 1 note-only listed as comments) tests/unit/generated/jsonui-branch-runtime.ts (shared runtime) tests/unit/branch-harness/profile_screen.ts (NEW harness skeleton — implement createHarness()) routes: updateProfile (from dataFlow.repositories[].methods[].endpoint, not from the contract's api references) 1 screen(s) generated by jsonui-test <version>.profile_screen.branches.test.ts — branch 2 (@generated)
it("branch 2: cond=\"formValid\" & api.updateProfile=\"success\"", async () => { const h = createHarness(); h.setState({"isSaving": false, "form": {"nickname": "a"}}); const rec = installFetchMock(ROUTES, {"updateProfile": "success"}); const ref_form = h.readField("form"); try { await (h.vm as any).onTapSave(); await settle(); h.expectTransition("HomeScreen"); expect(rec.countFor("updateProfile")).toBeGreaterThan(0); expect(partialMismatches(rec.lastBodyFor("updateProfile"), { "nickname": ref_form })).toEqual([]); } finally { rec.restore(); }});4. Android (Robolectric)Add `--platform android --package <pkg>` (since jsonui-cli 1.6.20). Here --out-dir and --harness-dir point at the Kotlin test source root and the package path is appended automatically. The runtime brings MockWebServer plus a reflection-based BaseBranchHarness that reads and sets fields, StateFlows, and data-class state generically; scenario bodies are embedded, so the generated file is self-contained.Prerequisites: test dependencies (Robolectric, MockWebServer, kotlinx-coroutines-test, kotlin-reflect), `testOptions.unitTests.isIncludeAndroidResources = true` in the module's Gradle file, and a robolectric.properties that pins the sdk and names a plain Application class — a real DI-heavy Application will try to boot on the JVM and fail.
android — output
$ jsonui-test generate branch-tests profile_screen \ --platform android --package com.example.profile \ --out-dir app/src/test/java --harness-dir app/src/test/javaGenerated branch tests for 'profile_screen': app/src/test/java/com/example/profile/ProfileScreenBranchesTest.kt (3 declared branch(es), 1 note-only listed as comments) app/src/test/java/com/example/profile/JsonuiBranchRuntime.kt (shared runtime) app/src/test/java/com/example/profile/ProfileScreenBranchHarness.kt (NEW harness skeleton — implement createHarness()) routes: updateProfile (from dataFlow.repositories[].methods[].endpoint, not from the contract's api references) 1 screen(s) generated by jsonui-test <version>.ProfileScreenBranchesTest.kt — branch 2 (@generated)
// branch 2: cond="formValid" & api.updateProfile="success"@Test fun `onTapSave branch 2`() { runBranchTest(routes, mapOf<String, Any?>("updateProfile" to "success"), ::createProfileScreenBranchHarness) { h, rec -> h.setState(mapOf<String, Any?>("isSaving" to false, "form" to mapOf<String, Any?>("nickname" to "a"))) val ref_form = h.readField("form") h.invoke("onTapSave") h.settle() h.expectTransition("HomeScreen") assertTrue(rec.countFor("updateProfile") > 0) assertEquals(emptyList<String>(), partialMismatches(rec.lastBodyFor("updateProfile"), mapOf<String, Any?>("nickname" to Ref(ref_form)))) }}5. iOS (XCTest)Add `--platform ios --module <app module>` (since jsonui-cli 1.6.22) — --module names the module the tests `@testable import`. Point --out-dir and --harness-dir at the test target's folder; with Xcode 16's synchronized folders, having the files on disk is enough — no project-file editing.Interception is process-wide: the runtime registers a URLProtocol and also swizzles the URLSessionConfiguration.default / .ephemeral getters, so network layers that build their own sessions from those configurations are captured too — nothing reaches the real network.Reads are generic, writes are typed: the runtime reads any field via Mirror traversal (unwrapping @Published); Swift has no reflective writes, so setState goes through the generated Data.update(dictionary:) and declared event handlers — that typed side lives in your harness.A `null` expectation compares equal to both Swift nil and NSNull() (since jsonui-cli 1.6.27): assertFieldEquals normalizes both sides on entry, so a harness readField can return an Optional property as-is — nothing to remember. Before 1.6.27 only the request matcher (partialMismatches) knew this equivalence, so a branch asserting a field back to null always failed on iOS — a reminder that a convention only one face of a runtime knows is a hole, not a convention.Harnesses are deliberately retained for the process lifetime: deallocating @MainActor types on teardown goes through the isolated-deinit back-deploy shim on current simulators and crashes with an invalid free, so the runtime keeps the few test VMs alive instead — the full reason is a comment in the generated runtime.With Xcode 16's synchronized folders (FileSystemSynchronizedRootGroup), the generated files are picked up by the test target as soon as they exist on disk.
ios — output
$ jsonui-test generate branch-tests profile_screen \ --platform ios --module ProfileApp \ --out-dir AppTests --harness-dir AppTestsGenerated branch tests for 'profile_screen': AppTests/ProfileScreenBranchesTest.swift (3 declared branch(es), 1 note-only listed as comments) AppTests/JsonuiBranchRuntime.swift (shared runtime) AppTests/ProfileScreenBranchHarness.swift (NEW harness skeleton — implement createHarness()) routes: updateProfile (from dataFlow.repositories[].methods[].endpoint, not from the contract's api references) 1 screen(s) generated by jsonui-test <version>.ProfileScreenBranchesTest.swift — branch 2 (@generated)
// branch 2: cond="formValid" & api.updateProfile="success"func test_onTapSave_branch_2() { runBranchTest(routes: routes, overrides: ["updateProfile": "success"], harnessFactory: createProfileScreenBranchHarness) { h, rec in h.setState(["isSaving": false, "form": ["nickname": "a"]]) let ref_form = h.readField("form") h.invoke("onTapSave", args: []) h.settle() h.expectTransition("HomeScreen") XCTAssertGreaterThan(rec.countFor("updateProfile"), 0) XCTAssertEqual(partialMismatches(rec.lastBodyFor("updateProfile"), ["nickname": Ref(value: ref_form)]), []) }}6. The harness — your side of the contractOne hand-written file per screen: it constructs the real ViewModel against the mock boundary and implements setState, invoke, expectTransition, and resolveString. The shared rule across all three platforms: unknown names must fail loudly — a harness that silently ignores a name it does not know would quietly weaken every branch that uses it. Since jsonui-cli 1.7.39 the shared runtime checks the other half of that contract, which is what the harness gives back. `resolveString` is called through a wrapper that rejects a result equal to the key it was handed, or a full key ending in `_<key>`, with a message naming both the key and what came back. The reason is the render path rather than the field: `@{...}` bindings are not resolved when a component renders, so a data field holds resolved text, and an expectation compared against it has to be resolved text too. That reason is worth reading past the easier one — a field also carrying a server message has no front-end key for that value, which is true and sufficient on its own, but taken as the reason it invites the conclusion that a field servers never touch may return a key, and that is wrong for every field. The check fires on the call rather than at setup, because a harness whose string table is empty never calls `resolveString` at all: it is broken and green until the screen's first `@key` contract arrives. Measured at its edges, and one of them moved. Text that happens to be a single identifier-shaped word ending in the key is rejected too — `Some_title` for `title` — which is a false positive kept on purpose, because it errs toward refusing and names what it refused. An empty string was accepted until jsonui-cli 1.7.42, so a harness returning `""` for every name passed a check written to catch exactly that harness; this page said so, and 1.7.42 closed it. Returning nothing now fails like returning a key, and the message says both — a key, or nothing, means the table did not resolve. Since 1.7.44 that sentence comes from one source rather than three copies, so a Swift or Kotlin harness fails with the same words a web one does — which is what makes it a shared rule rather than three rules that happen to agree today. The words are the shared part, and only the words: 1.7.44 merged the three copies onto the weakest of them and spliced the key and the returned value into the sentence unescaped, which 1.7.42 had not done on web. Rendering all three and finding them identical is what this page did to check that merge, and identical was the symptom — three copies agree once you flatten them onto the one that quotes nothing. 1.7.45 splits the values back out per language while keeping the prose single-sourced, so the renderings now differ where they should and agree where the claim is. One edge is still open: the test is emptiness, not blankness, so a harness returning a single space passes. The three flavors:web — plain closed maps: screenRoutes from transition destinations to URLs, and a closed string-key map (keep it closed so string-usage lint gates stay computable).On web, write the ViewModel through the runtime's `applyDeclaredKeys(vm, state)` (since jsonui-cli 1.6.31) rather than assigning every key in a loop, then hand the same object to the data store. A plain loop invents ViewModel properties for data-only fields — values the screen updates through the store and the ViewModel never declares — and because readField consults the ViewModel first, those invented properties shadow the store for the rest of the test: the branch then fails against a correct implementation. Android skips writes to undeclared fields in its base harness and iOS goes through a typed switch, so this one is web-only; 1.6.31 lines the three faces up. A related repair went the other way in 1.6.33: for a scenario with no inline body, none of the three runtimes returned what the mock declared — one broke its own types, two answered with the string "null" — and the fix was not invented but copied from the mock server, which had always treated a missing body as an empty response. A convention only one face knows is a hole; so is one that every face gets wrong while the canonical answer sits outside the runtime. The generated types also carry an index signature on purpose: output the project cannot hand-edit must not break its type-check when a mock later grows a key nobody anticipated.android — extend the generated reflection-based BaseBranchHarness (fields, StateFlows, and `_data` data-class copies are handled generically) and add closed maps for strings and transition predicates.ios — everything typed: setState through Data.update(dictionary:) plus event-handler routing for VM-internal state; invoke / expectTransition / resolveString as closed switches. The skeleton's own comment states the contract:
ProfileScreenBranchHarness.swift — skeleton contract comment
// Swift has no reflective writes, so this harness is the typed side of the// contract: setState routes through the generated Data.update(dictionary:)// (plus event-handler calls for VM-internal state), while invoke /// expectTransition / resolveString are closed switches — an unknown name// must fail loudly, never soften.7. Formatted strings: pseudo-key + harness formatting`@key` resolves one whole entry of the string table — there is no vocabulary for format arguments, and hard-coding the formatted result ("Resuming from step 0/10") as a literal breaks on every locale but one. The locale-independent pattern: the spec declares a pseudo-key that names the expectation — `@resume_info_step_0_of_10` — and the harness's closed map resolves it by formatting the app's own string table with the arguments the name spells out. The expectation then flows through exactly the string the user sees, and the closed-map contract (unknown names fail loudly) already covers it. Since jsonui-cli 1.6.25, `jui lint-strings --usage` also checks every `@key` a branchContracts section references for existence — the division of labor: validate checks only the reference's shape, because it does not know where the strings table lives; lint-strings does, so it resolves each reference against the table and against your harness's declarations. A pseudo-key is correct precisely because it is NOT in the table — what makes it legal is the harness declaration: `*_STRING_KEYS` maps count, and so does any key-shaped literal in a `*BranchHarness*` file (the Swift and Kotlin skeletons spell the closure as a closed switch rather than a map, and that counts the same). A `@key` found in neither place is reported missing — declared nowhere, in other words a typo:
one pseudo-key, three harness flavors (consumer-owned)
// spec — the expectation names a pseudo-key:"then": { "data.statusMessage": "@resume_info_step_0_of_10" } // web harness — closed map entry:"resume_info_step_0_of_10": tpl("resume_info_step", { current: 0, total: 10 }), // android harness — closed when-branch:"resume_info_step_0_of_10" -> context.getString(R.string.resume_info_step, 0, 10) // ios harness — closed switch case:case "resume_info_step_0_of_10": return String(format: StringManager.resumeInfoStep, 0, 10)8. What stops generationThe binding chain is the same on every platform: api.<op> resolves through the dataFlow method's endpoint declaration to a mock file matched on source.method + path, then to the named scenario — a reference that cannot be bound is a hard error, because a test that cannot bind its vocabulary must not silently weaken. The platform flags have their own requirements. All six messages below are real output, one seeded mistake each:
hard generation errors — one seeded mistake each
Error: branches[1].when.api.updateProfile: api operation 'updateProfile' has no `endpoint` declaration in dataFlow.repositories/useCases — declare the method with its endpoint (e.g. "endpoint": "POST /api/...")Error: branches[1].when.api.updateProfile: no mock file found for PUT /api/items (op 'updateProfile') under the mocks directoryError: branches[1].when.api.updateProfile: scenario 'timeout' not found in ['failure', 'success'] (mock for PUT /api/items)Error: condition 'formValid' has no witness_false — test generation needs a witness to arrange the stateError: --package is required for --platform android (Kotlin package of the generated test sources)Error: --module is required for --platform ios (the app module name for @testable import)9. Platform-scoped branchesWhen implementations genuinely diverge — one platform surfaces an alert field of its own where the others share one — a branch can carry `platforms` (a non-empty subset of ios / android / web, since jsonui-cli 1.6.22; anything else fails validate). Each generator skips out-of-scope branches with an explicit comment and excludes them from its declared count — never silently:
generator output — scoped branches are skipped loudly
// in ProfileScreenBranchesTest.swift (--platform ios):// branch 3 is platform-scoped (['web', 'android']) — not generated for ios // in profile_screen.branches.test.ts (web):// branch 4 is platform-scoped (['ios']) — not generated for web10. Time-dependent state: a note, not a platform splitState that changes by itself after a delay — a banner that auto-dismisses after five seconds — can observe differently per platform even when the implementation is identical, because the test time models differ: the Android runtime drives a StandardTestDispatcher whose virtual clock advances inside settle(), so the dismissal has already fired; the iOS runtime drains the real-time main run loop in short slices, so it has not. That split is a property of the harnesses, not of the implementations — scoping such a branch with `platforms` would leak the test's time model into the spec. The same family includes state that is released only when an external SDK answers — billing, say — where settle() cannot know when that latency ends: the split is timing, not implementation, so it too belongs in a note. Declare the delayed outcome as a note with the reason, and keep the machine-checked branch to the immediately observable part. Auto-dismissing UI is common — check for this before puzzling over a one-platform red:
declare the delayed outcome as a note, with the reason
{ "note": "banner auto-dismisses after 5 s — time-model dependent: virtual time (Android tests) has already fired the dismissal inside settle(), the real-time run loop (iOS tests) has not. Harness property, not an implementation difference — do not platforms-split this." }11. Scope: claims that pin an unrelated axisThe three classes above are things the machine cannot judge. There is a fourth reason to keep something out of the contract, and it is different in kind: the claim is perfectly writable — but writing it pins an axis the contract has no business fixing. The canonical shape: a section's display variants (empty / populated / an upgrade prompt) depend on the user's plan tier. Declare all three and the contract silently fixes the tier axis too — the day a tier is added, branches go red for a reason that has nothing to do with the screen being broken. What belongs in the contract instead is the one fact that holds on every tier: the section is visible. Weakening the claim is not a compromise — one axis-independent fact outlives three axis-dependent variants.The test: does this claim survive a change on an axis it is not about? If not, weaken it to the part that does. And the inverse reading is the useful alarm — if you can imagine a branch going red for any reason other than "the implementation broke", the contract's scope is too wide.
12. Trust it: red-check, then regenerateA green suite proves the tests can pass; a red-check proves they can fail. After the first green run, break one implementation line and confirm that exactly one branch goes red — then restore it. Vary the depth of the mutation rather than hunting for the one perfect line: a mutation at the decode/mapping stage — where the response becomes your domain value — behaves like a real failure, because a real payload drop empties every reader downstream of it; a shallow mutation of one stored field can stay green when the method reads the response directly. Shoot coarse to fine, and treat a probe that stays green as information, not failure — it is telling you which surface the contract actually watches.The regeneration loop afterwards: edit the spec → `jsonui-doc validate spec` → generate again (the @generated pair is rewritten, the harness is kept) → run. The decision table and the tests stay two views of the same declaration.Before blaming the probe, check the arrange. A contract of the form "…is cleared / collapses / becomes empty" asserts a transition away from some state — and if `baseline` leaves that field at its default, and the default already equals the post-clear value, the assertion was vacuous from the moment it was written: nothing changes across the act, so removing the implementation changes nothing either. That failure is decided at authoring time, not at probe time, which is what makes it different from picking the wrong line to mutate. Whenever a branch asserts that something goes away, make the baseline put it there first — visible, selected, populated — and the same red-check that stayed green will turn red.Make landing the mutation a procedure, not a good intention — the same two lines of error handling often appear verbatim in several methods of one file, so a string-targeted edit lands in a neighbour and the contracted method stays intact. Two steps are enough: locate the function first and mutate relative to it ("the first match below `func confirmDelete()`"), never by string alone; and when the run comes back green, suspect a missed probe before you conclude the implementation is right — grep for the marker and check its line number before reading anything into the result. A green run only means something if the thing you meant to break is really broken.The mirror image is worth knowing because the symptom points the wrong way: a branch that goes red against an implementation you just verified by hand. A vacuous arrange stays green and looks like a bad probe; a harness that lies about the subject goes red and looks like a bug in the code — on web, the classic cause is a setState that invents ViewModel properties for data-only fields (see section 6). Both failures wait for the same trigger: they appear only once a baseline actually arranges the pre-state, which is to say they wait for exactly the contracts most worth writing. So when a red arrives, ask whether the test is reading the same field the implementation writes before you start editing the implementation.There is a third variant, and it is the quietest: the branch passes, but through a path you did not mean. A baseline has to agree with the data the scenario actually returns — above all the values the implementation matches on, ids, timestamps, versions. Put an id in the baseline that no record in the mock's list carries, and a branch meant to pin "the conflict is detected and the item is refetched" can instead exercise "the refetch found nothing": the assertion still holds, so the run is green, and what is pinned is not what you wrote it for. So the question a red-check answers is not only "can this fail?" but "did it travel the route I described?" — turning a branch red proves it can move; reading which assertion moved proves it moved along your path.A fourth way a green run lies: the expectation itself does not identify the branch. If the value you assert can arrive by more than one route — a `@key` whose text happens to read exactly like the message the server sends — then breaking the mapping changes nothing observable and the branch stays green. The fix is in the fixture, not the contract: give the mock wording that differs from the string table's, and the assertion can finally tell the two routes apart. The same reasoning explains another stubborn green: when the implementation guards the same outcome in more than one place, breaking one guard leaves the result intact, so a probe that lands correctly still produces no red. So when nothing turns red, work through two suspects in order: first, the probe missed — grep the marker's line number and confirm it landed where you meant; only then, something else already ensures this — look for a second guard covering the same outcome. Same symptom, different place to look. The opposite result has its own first suspect: when more goes red than the branch you broke, check that the working tree is in the state you think it is before concluding the branches are entangled. Restoring a mutation with a wholesale VCS checkout takes uncommitted work with it, and the next probe then runs against a tree missing changes you never meant to drop. Keep a copy of the file before mutating and restore from that, so undoing a probe touches exactly what the probe touched.One line holds the first three together: the baseline is an arrange, not decoration. It is not a restatement of the declaration — it is the operation that puts the subject into the state where the observation can happen at all. Read the three failures that way and they stop being separate problems: vacuous means no pre-state was built, a lying harness means it was built in the wrong place, and the wrong path means it was built with the wrong contents. When a new shape of arrange appears in a contract, suspect the harness before the implementation — a harness only has to handle the states someone has asked it for, so a new kind of arrange is exactly when its shortcuts surface. The fourth variant extends the same idea to the other end of the test: an arrange has to build the state that makes the observation possible, and an expectation has to be one only this branch can satisfy. Both halves have to distinguish — a green run is worth something only when nothing else could have produced it.
Keep going
Branch contractsThe declaration side: vocabulary, validate, and the rendered decision tables./guides/branch-contracts
Writing screen testsScreen and flow tests — the executable side of behavior documentation./guides/testing
Verifying implementation against docsThe wider check suite that keeps spec and implementation from drifting./guides/verifying-implementation-against-docs