Recipes
Previews and tests
A store without a server: a fixture committed as a response, recorded transports, a held mutation, a rule tested with a value.
Baton's store is filled from a payload as well as from the network, and a
transport is a protocol with one verb, so a preview, a test and a benchmark
each get a store the way an app does and never a mock of Baton's types.
This page is the catalogue, each piece in Swift and then in Kotlin; every
piece lives in BatonTesting, a product of the package nothing in the app
links, or in Kotlin in the module baton-testing, a test dependency on
the JVM and on Android, except commitPayload, which is the environment's
own.
A Kotlin test outside a composition gives the environment a test
dispatcher as both its main dispatcher and its ingest dispatcher and runs
under runTest, as the runtime's own tests do:
val dispatcher = StandardTestDispatcher(testScheduler)
val environment = Environment(transport, store = Store(), mainDispatcher = dispatcher, ingestDispatcher = dispatcher)A Compose test provides it through LocalBaton on the rule's thread and
waits with rule.waitUntil, as the GitHub sample's ScreenTests does.
A preview: a fixture committed as a response#
let environment = Environment(transport: SilentTransport())
try await environment.commitPayload(CharacterQuery(id: "1"), Payload(fixture))commitPayload runs the operation's plan over a payload, bytes in a
response's shape, and commits them as a fetch's response is committed, so the preview draws
from the fixture through the same lenses, and SilentTransport never
answers, so nothing behind the preview waits on a network. The payload may
carry part of what the operation selects; what it leaves out reads as
missing, as a partial response would.
In Kotlin, commitPayload is a suspend function, run on the environment's
main dispatcher whatever thread calls it, and Payload wraps a
ByteArray or a JSON String:
val environment = Environment(SilentTransport())
environment.commitPayload(CharacterQuery(id = "1"), Payload(fixture))A test over recorded responses#
let transport = RecordedTransport([CharacterQuery.name: fixture("character-1")])
let environment = Environment(transport: transport)RecordedTransport answers each operation by name, or through a responder
that sees the whole request, and keeps the requests it was sent
(requests, requestCount), so a test asserts what the app asked for. An
operation with nothing recorded fails with a TransportError of status 0
that says so.
In Kotlin, by name through a map, or through a responder given as the
constructor's lambda; an operation's name is its companion's name:
val transport = RecordedTransport(mapOf(CharacterQuery.name to fixture("character-1")))
val environment = Environment(transport)A test that holds a mutation or drives a subscription#
let transport = ScriptedTransport([CharacterQuery.name: fixture("character-1")])
let environment = Environment(transport: transport, subscriptions: transport)
let task = Task { try await environment.mutate(SetFavorite(id: "1", favorite: true), optimistic: optimistic.payload) }
await wait(until: { transport.held.count == 1 })
// The optimistic layer is applied; the server has not answered.
transport.held[0].respond(fixture("set-favorite-1"))ScriptedTransport holds a mutation, or any operation the test names, until
the test replies or refuses it, so the window between an optimistic apply
and the server's answer is observable; it drives a subscription's events by
hand (driven[i].send, complete, fail); and it lists its requests by
kind. wait(until:) waits on the main actor for a handle or a store to
settle, with a timeout, yielding between checks.
In Kotlin, the mutation runs in a coroutine the test launches, held is a
list whose entries respond or refuse, driven a list whose entries
send, complete or fail, requests(kind) lists one kind, and wait
is a suspend function that yields between checks:
val transport = ScriptedTransport(mapOf(CharacterQuery.name to fixture("character-1")))
val environment = Environment(transport, subscriptions = transport)
val job = launch { environment.mutate(SetFavorite(id = "1", favorite = true), optimistic.payload) }
wait(until = { transport.held.size == 1 })
// The optimistic layer is applied; the server has not answered.
transport.held.single().respond(fixture("set-favorite-1"))A rule tested with a value#
func isSeriesRegular(_ character: CharacterValue_character) -> Bool {
character.status == "Alive" && character.episode.count > 10
}
#expect(!isSeriesRegular(CharacterValue_character(id: "1", name: "Rick", status: "Dead", origin: nil, episode: [])))A rule outside a view takes the value an @inline fragment compiles to,
so its test builds one with the value's initializer, field by field, and
needs no store at all; the app hands it the value the spread's accessor
read. The same rule over a lens would need a store to read from.
In Kotlin the value is a data class whose primary constructor takes the
fields:
fun isSeriesRegular(character: CharacterValue_character): Boolean =
character.status == "Alive" && character.episode.size > 10
assertFalse(isSeriesRegular(CharacterValue_character(id = "1", name = "Rick", status = "Dead", origin = null, episode = emptyList())))The log in tests#
A debug build prints a missing field, a value a reader's type cannot hold,
an ambiguous id and a null @required(action: LOG) field through the
environment's log. A test silences them with environment.log = nil, or
collects them:
let seen = Events()
environment.log = { event in seen.append(event) }The events are value-free, names and counts, so a test asserts
.missing(type: "Character", field: "name") and never a record.
In Kotlin the environment prints the missing-data events only when it is
made with debug = true, since common Kotlin has no build configuration
of its own to read, so a test leaves it unset and collects the events
instead:
val seen = mutableListOf<LogEvent>()
environment.log = { event -> seen.add(event) }and asserts LogEvent.Missing("Character", "name") in seen.
A bug report that becomes a fixture#
StoreExport.text(of: environment.store), from BatonInspector, writes the
store in the dump format the fixtures under spec/ use: every record by
key, sorted, one a line. A dump from a debug menu or the inspector's share
button is a fixture a test can start from, and a diff of two dumps is a
reviewable change to identity or layout. In Kotlin the same text is
StoreExport.text(environment.store), from baton-inspector.
What these are not#
Not mocks. Each is a transport like any other, and nothing behind it can tell: the environment, the store, the handles and the lenses run the code the app runs. A test of the app's own layer over Baton therefore tests that layer, not a stand-in for the library.
Baton