Guides

Testing your app

The kit runs your real core on fakes and a manual clock, plays a recorded session as a screen state, and replays what a session asked of the platform.

Pick one#

You wantUse
a screen that reacts, or a cache that goes stalePreviewCore: the real core, its ports faked, time in your hands
a state that is slow to reachRecordedCore: a recorded session under the generated stores, no core
a test that fails when the core makes another requestReplayer: a recording as the script for the ports

The same three exist in Swift (UndraTestKit, a product of the UndraRuntime package), Kotlin (com.github.shreypdev.undra:testkit, at the runtime's version), TypeScript (@undra/testkit, the release's undra-testkit-<version>.tgz, no runtime dependencies) and, for tests of the core itself, Rust (undra::testing). They sit beside the runtimes: no generated file, wire frame or C entry point changes.

Preview a screen#

PreviewCore.load starts your app’s own core in the process with a deterministic fake behind every standard port. A seed fills them; advance moves the clock one deadline at a time and lets the core settle between.

Show Swift, Kotlin and TypeScript 3 snippets
SwiftUI preview
#Preview("Todos, three items") {
    let preview = try! PreviewCore.load(UndraPlaygroundCore.load, seed: seed)
    let todos = try! Todos(ctx: preview.core)
    return TodosScreen(todos: todos)
        .task { for title in ["Buy milk", "Walk the dog"] { _ = try? await todos.add(title: title) } }
}
Kotlin test
val preview = PreviewCore.load(UndraPlaygroundCore::load, Seed.fromJson(seedJson))
val todos = Todos(preview.core)
preview.fakes.http.respond("https://api.test/todos", httpResponse(200, "[]"))
preview.advance(31_000)       // the cached list goes stale; the core refetches
Story or Vitest test
const preview = await PreviewCore.load({ wasm: "/undra_core.wasm", expectedSchemaHash: UndraIds.schemaHash, seed });
const inbox = await RemoteTodosQueryHandle.create("inbox", preview.core);
await preview.advance(31_000);
Show a seed one JSON document for every kit
seed.json
{
  "version": 1,
  "now_ms": 1700000000000,
  "rng_seed": 42,
  "kv": { "greeting": "hello" },
  "http": [
    { "url": "https://api.test/lists/inbox/todos", "status": 200,
      "headers": [["content-type", "application/json"]], "body": "[]" }
  ]
}

Sleeps in a native core run in real time. The manual clock moves what the core reads (Clock, staleness, timestamps) and timers armed through the Timer port. A native core’s ctx.sleep is waited for, as the C ABI never hands sleeps to the host: advance(5_000) does not finish a 5-second sleep, it returns at once and the task still wakes 5 real seconds later. In a native preview, make that delay a parameter the preview sets to a few milliseconds, wait the real duration and then settle(), preview the state after the wait with RecordedCore, or run the story on the web, where sleeps follow the clock. advance stops after maxTimers firings with a typed TimerStormError (a timer that re-arms itself without time passing never ends).

Play a recording#

RecordedCore loads a recording and releases its change-sets with a playhead you move. Calls are answered by the next recorded reply for the same target; a call the recording has no reply for is a typed refusal that names it.

Show Swift 3 lines
swift
let recorded = try RecordedCore.load(Recording(json: text), expectedSchemaHash: UndraIds.schemaHash)
let todos = try Todos(ctx: recorded.core)      // the recorded constructor reply
recorded.advance(ms: 300)                      // the change-sets up to 300 ms into the session

Record a session#

The dev server sees every call, reply, change-set and port call, on iOS, Android and the web alike, so one flag captures a session. A reload starts a new core, so a new file (session-2.json).

bash
undra dev --record session.json

A recording holds what the app asked the platform and was answered, so it can hold secrets. SecureStore payloads are left out by default (the calls are there, their arguments and replies are empty; --record-secrets keeps them). HTTP headers and bodies, Kv values and files are recorded as they are: do not share a recording of a real account’s session. If the file cannot be written the runner warns once, stops recording and keeps serving.

A recording is one canonical JSON document, undra.recording version 1: payloads in hex, handles as strings, times in milliseconds. A recording made on one platform is read by all of them.

Show the format an excerpt
session.json
{
  "format": "undra.recording",
  "version": 1,
  "schema_hash": "0xb5b7b1dc29182a9d",
  "source": "dev-server",
  "platform": "ios",
  "events": [
    {"t":100,"kind":"call","target":"constructor","type":4125302698,"method":898850282,"call":1,"args":""},
    {"t":100,"kind":"reply","call":1,"status":"ok","body":"0000000001000000"},
    {"t":100,"kind":"change_set","txn":1,"entries":[{"handle":"0x0000000100000000","signal":0,"op":"full","value":"00000000"}]},
    {"t":120,"kind":"port_call","port":515815688,"method":1796529958,"call":3,"args":"..","name":"Http.request"},
    {"t":160,"kind":"port_reply","call":3,"status":"ok","body":".."}
  ]
}

Replay port traffic#

A Replayer answers a core’s port calls from a recording, in order per port. A call that differs is a typed mismatch, an extra one is exhausted, a recorded call with no reply in the file is unanswered, and a recorded call never made is unconsumed. A deviation consumes nothing, so one wrong call does not shift every later answer.

Show Swift and Rust 2 snippets
Swift
let replayer = Replayer(try Recording(json: text))
let core = try UndraPlaygroundCore.load(.inproc(adapters: replayer.adapters()))
// ... run the flow ...
try replayer.finish()    // throws each mismatch, and each recorded call the core never made
Rust
let replayer = Arc::new(Replayer::new(&Recording::from_json(&text)?));
replayer.install(t.host());
// ... run the core ...
replayer.finish().expect("the core made the calls it made when it was recorded");

Compare platforms#

Record the same flow on each platform, then undra drift ios.json android.json web.json prints where the hosts diverged: a call missing, extra or made with other arguments; a port answered differently by the platform’s adapters; an event pushed on one side only; a signal observed on one side only; a signal whose final value differs. The core is deterministic, so the difference is in what the hosts put in. Clock and random readings, timer deadlines and the Idempotency-Key header are ignored; --ignore Todos.add.title adds your own. --exit-code makes it a CI gate; the CLI page has every flag.

bash
undra dev --record ios.json                              # the iOS app runs the flow against this server
undra dev --addr 127.0.0.1:7444 --record android.json    # a second server for the Android app (a server serves one client at a time)
undra drift ios.json android.json                        # the first file is the reference
undra drift ios.json android.json --exit-code            # CI

In the playground#

The kit uses only public APIs, and CI builds all three. docs/TESTING.md has the full reference: the seed and recording formats, the fakes’ conformance file, and the limits.

Edit this page on GitHub