JsonUI
GuidesUnit contractsThe spec declares which unit test cases exist and on which platforms; `jsonui-test generate unit-stubs` writes the missing ones as failing stubs, and `--check` fails when the declared set and the implemented set disagree. The bodies stay hand-written — this mechanism is about the SET, not the code. This page covers the declaration, what the config must supply, the generation contract (append-only, and why that had to be fixed), the three kinds of drift, and the iOS naming rule that decides whether a test runs at all.10 min read
1. What the spec carries, and what it does notA unit contract declares the SET of test cases a screen's unit tests must contain — their names, their intent, and which platforms they belong to. It does not generate a body. That was tried and measured upstream before this shape was chosen: generating tests from `branchContracts` moved the covered line count on the contracted methods by 4.3 points, while the untested declared-method bodies across 32 uncontracted screens came to 331 lines, 3.3% of one face's untouched total. Reaching 80% needed the ViewModel and Model bodies written by hand — roughly 6,000 lines on one face and 8,500 on another. So the bodies are yours; what the spec pins is the inventory.The reason to pin an inventory at all is that two faces implementing the same screen from the same spec drift apart silently. A case gets written on one side, renamed on the other, and nothing compares them — because the only thing that could is a person reading both trees. A declared set is the thing a machine can compare.
2. The declaration`unitContracts` is a top-level array on a screen spec. Each entry names a `target` — the class under test — and a list of `cases`, each with a `name`, an `intent`, and an optional `platforms`. The name is an explicit string rather than a convention, so the scanner matches the name itself instead of guessing at a naming scheme. The intent is what lands in the stub's failure message, which is the only place a reader meets it before the body exists.An unstated `platforms` means every platform the project builds — not 'none' and not 'web only'. Measured on one spec declaring four cases with one of them scoped to `["ios"]`, against a config declaring all three faces: the run reports `ios: declared 4` and `android: declared 3` / `web: declared 3`. The scoped case is counted for iOS and is absent from the other two denominators entirely, rather than being counted and then excused.
ProfileScreen.spec.json — unitContracts
"unitContracts": [
{
"target": "ProfileViewModel",
"cases": [
{ "name": "save_whenOffline_setsError",
"intent": "offline save surfaces an error" },
{ "name": "save_whenValid_clearsError",
"intent": "a valid save clears the error" },
{ "name": "nickname_trimsWhitespace",
"intent": "leading and trailing spaces are trimmed" },
{ "name": "save_whenBackgrounded_defers",
"intent": "a save started in the background is deferred",
"platforms": ["ios"] }
]
}
]
3. What the config must supplyThe spec says which cases exist; it does not say where a face keeps its test files, because that would put the same fact in two places. So the directories come from `jui.config.json`: `platforms.<p>.unitTestsDir`, resolved under that platform's `root`. Two more are needed only for generation — `platforms.ios.testModule` for `@testable import`, and `platforms.android.testPackage` for the file's `package` line. Neither is derivable from `target`, which is a class name, and generation refuses without them rather than emitting a file that does not compile.An undeclared `unitTestsDir` is reported, not skipped. Measured by removing `platforms.web.unitTestsDir` from a config whose spec declares three web cases: the run prints `web: NOT CHECKED — platforms.web.unitTestsDir is not declared in jui.config.json, so the 3 case(s) declared for it cannot be compared against anything`, and exits 1. That is the right direction: scanning the wrong directory finds nothing and reports every declared case as unimplemented, which reads like drift and is a configuration error — so the tool says which one it is instead of producing a number.
4. Generation is append-only`jsonui-test generate unit-stubs` writes a stub file per target per platform, in that platform's convention — `<Target>ContractTests.swift`, `<Target>ContractTest.kt`, `<Target>.contract.test.ts` — with the generated cases inside a marker region delimited by `// >>> GENERATED_STUBS_START` and `// <<< GENERATED_STUBS_END`. The stub body is a deliberate failure (`XCTFail` / `fail` / `throw new Error`), carrying the declared intent. A stub that passed would be a case reporting success without a body, which is worse than a missing one, because it is counted. `--dry-run` prints the same report with `[would write]` instead of `[wrote]` and leaves nothing on disk — measured at 0 files after a dry run on a fresh project.The region is only ever added to. A case already inside it — stub or hand-written body — is left exactly as it is, and nothing is ever removed. That is a fix, not a founding property: until jsonui-cli 1.8.48 the run REPLACED the region with what it had generated, and it generates only the cases that are missing. Measured here as an A/B of the two shipped trees over one probe, in the order that reaches it — generate a four-case file, hand-write one body inside the markers, then declare a fifth case. On 1.8.47 the first run left one case in the file and the hand-written body was gone; the file then oscillated with period two, md5 alternating between exactly two values across five runs, and `--check` exited 1 on every one of them. On 1.8.49 the same probe adds the fifth case on run 1, keeps the hand-written body on all three faces, and runs 2 through 5 are byte-identical no-ops with `--check` at 0.Nothing is removed includes a case the spec no longer declares. `check` reports those as `undeclared`, and deleting a test because a spec edit stopped naming it is a different act from filling in a stub — it is the author's. Measured: with a case removed from the spec, its test is still on disk after a generate run. The same restraint covers a file whose markers the author deleted — it is returned untouched, because overwriting on that basis would destroy work. Measured with a case removed from such a file so the tool had a stub it wanted to add: the run reports the file as `unchanged` and its md5 does not move.
ios/Tests/ProfileViewModelContractTests.swift — as generated
import XCTest
@testable import MyApp
 
final class ProfileViewModelContractTests: XCTestCase {
// >>> GENERATED_STUBS_START
func test_nickname_trimsWhitespace() throws {
XCTFail("not implemented: leading and trailing spaces are trimmed")
}
 
func test_save_whenBackgrounded_defers() throws {
XCTFail("not implemented: a save started in the background is deferred")
}
// <<< GENERATED_STUBS_END
}
5. The three kinds of drift`--check` compares the declared set against the implemented one, writes nothing, and exits non-zero on drift. Three findings: `MISSING` (declared, no implementation), `UNDECLARED` (implemented, declared nowhere), and `NEVER RUNS` (the method exists but the platform's runner will not execute it). Measured both ways on one fixture — a converged project exits 0, and the same project with one case dropped from the spec exits 1 with an `UNDECLARED` line naming it.Every line names a denominator, and that is worth reading rather than skipping: `android: declared 3, implemented 0, missing 3, undeclared 0 (0 file(s) read)`. `0 drifted` over an empty scan and `0 drifted` over forty cases are the same sentence and opposite facts. The `file(s) read` count is what tells them apart — a zero there with a non-zero `declared` means the scan found no files, not that the face agrees.
jsonui-test generate unit-stubs --check — one seeded drift of each kind
unit contracts: 5 case(s) declared across 1 spec file(s) scanned (1 spec file(s) carrying a unitContracts block)
android: declared 4, implemented 4, missing 1, undeclared 1 (1 file(s) read)
MISSING save_whenRenamed_keepsDraft (declared, no implementation)
UNDECLARED nickname_trimsWhitespace (implemented, declared nowhere)
ios: declared 5, implemented 5, missing 1, undeclared 1 (1 file(s) read)
MISSING save_whenRenamed_keepsDraft (declared, no implementation)
UNDECLARED nickname_trimsWhitespace (implemented, declared nowhere)
NEVER RUNS save_whenRenamed_keepsDraft (method exists but the runner will not
discover it — XCTest needs a 'test' prefix; it compiles, reads as
present, and executes zero times)
web: declared 4, implemented 4, missing 1, undeclared 1 (1 file(s) read)
MISSING save_whenRenamed_keepsDraft (declared, no implementation)
UNDECLARED nickname_trimsWhitespace (implemented, declared nowhere)
6. The iOS prefix, and the test that never runsDeclared names stay platform-neutral. On iOS the generated method gets a `test_` prefix, because XCTest discovers only `test`-prefixed methods: a method named exactly as declared compiles, is counted by any name-matching scan, and executes zero times — the mechanism manufacturing the very failure the contract exists to prevent. The scanner understands the prefix, so `save_whenOffline_setsError` in the spec and `test_save_whenOffline_setsError` in the file are the same case.A hand-written method that forgets the prefix is what `NEVER RUNS` is for. Measured by planting `func save_whenRenamed_keepsDraft()` in the test class: the check reports `NEVER RUNS save_whenRenamed_keepsDraft (method exists but the runner will not discover it — XCTest needs a 'test' prefix; it compiles, reads as present, and executes zero times)`. Two limits of that scan are worth knowing, both of them fixes. Only functions inside an `XCTestCase` subclass are scanned, because a file-scope helper is not a mis-named test — an earlier cut flagged one, and a line that is always wrong teaches the reader to skip it, taking the next real finding with it. And since jsonui-cli 1.8.48 a local function inside a test method is no longer counted either: measured on one file carrying `func poll()` inside a `test_`-prefixed method, 1.8.47 reports one NEVER RUNS for it and 1.8.49 reports none.
7. A misspelled key does not drop the declarationThe one failure this mechanism must not have is silently declaring nothing. Measured by renaming `cases` to `caes` in one block: the run prints three PROBLEM lines — the unknown key with the expected set, `'cases' is missing — this block declares nothing`, and `1 spec(s) carry a 'unitContracts' block but no case could be read from any of them — a misspelled key drops the declaration silently, which is the one failure this mechanism must not have` — and exits 1. The third line is the one that matters: it fires on the aggregate, so a typo cannot leave a project reporting a clean zero over a contract nobody is reading.
a misspelled block key — three PROBLEM lines, exit 1
PROBLEM profile: unitContracts[0]: unknown key 'caes' (expected one of ['cases',
'target']) — a misspelling here drops the declaration
PROBLEM profile: unitContracts[0]: 'cases' is missing — this block declares nothing
PROBLEM 1 spec(s) carry a 'unitContracts' block but no case could be read from any
of them — a misspelled key drops the declaration silently, which is the one
failure this mechanism must not have
8. What this does not give youThe contract compares names, so it is silent about everything a name does not carry. Two cases with the same name on two faces satisfy it while asserting different things; a case whose body was emptied to a bare `XCTAssertTrue(true)` satisfies it too, because it is present and it runs. `--check` is a set comparison, not a review. It also says nothing about a case that exists on neither face and was never declared — the inventory pins what the spec knows, so a branch nobody thought of is not a gap this can see. Where a case's correctness needs pinning rather than its existence, that is `branchContracts` and the generated branch tests, not this.
Keep going
Branch contractsThe declaration side: vocabulary, validate, and the rendered decision tables./guides/branch-contracts
Branch testsThe other generator: real-stack unit tests on three platforms from one branch table./guides/branch-tests
TestingThe whole test story: screen tests, flow tests, and where unit tests sit in it./guides/testing