---
title: Testing
url: "https://native-sdk.dev/docs/testing"
docs_index: /llms.txt
lastUpdated: 2026-10-08
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

Native SDK apps test headlessly by default: the view is a function of the model, dispatch is typed, and the layout engine runs without a window server. Unit tests drive the real loop, `TestHarness` covers runtime integration, and the [automation](/docs/automation) harness drives the live app. For running these tiers on GitHub Actions — including the workflow `native init --full` scaffolds — see [Testing in CI](/docs/testing/ci).

## TypeScript tests against compiled apps

For an app with `src/core.ts`, add `tests/*.test.ts` and run `native test`. The build compiles the app with scriptc and links a headless host around the existing `TestHarness`. Node.js 24 runs the test code; the app's model, updates, markup, layout, effects, and automation execute in the native process.

```ts
// tests/counter.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import { NativeApp, findWidget } from "@native-sdk/core/testing";

test("increment and replay", async () => {
  const app = await NativeApp.start({ wallMs: 77000 });
  try {
    let snapshot = await app.snapshot();
    snapshot = await app.click(findWidget(snapshot, {
      role: "button", name: "+",
    }));
    assert.equal(snapshot.model.count, 1);
    assert.ok(snapshot.widgets.some(widget => widget.name.includes("total: 1")));

    const replay = await app.verifyReplay();
    assert.deepEqual(replay.snapshot.model, snapshot.model);
  } finally {
    await app.close();
  }
});
```

`findWidget` requires exactly one matching accessibility role and name. Add `view` to distinguish identical controls in different views. Use a fresh snapshot after an interaction. Widget identities and effect keys are decimal strings so JavaScript preserves all 64 bits. Scroll regions also expose `widget.scroll`, the applied two-axis `ScrollState` with offsets, velocities, and viewport/content extents widened exactly from native f32. Other widgets have `scroll: null`.

Each `NativeApp.start()` creates an independent native process. `click`, `action`, `menu`, and `frame` return the resulting snapshot. Actions support `focus`, `press`, `toggle`, `increment`, `decrement`, and `dismiss`. Requests are serialized, time out after 10 seconds by default (`timeoutMs` overrides this), and reject on native errors or process exits. Always close the app in `finally`, or use `await using`.

For text-entry widgets, `setText(widget, text)` replaces the content through native select-all and text input, preserving leading and trailing whitespace. `selectText(widget, anchor, focus)` uses UTF-8 byte offsets. `composeText`, `commitComposition`, and `cancelComposition` drive native IME events. Each method returns a snapshot and records the interaction for replay. `key(view, "cmd+a")`, `key(view, "backspace")`, and `key(view, "enter")` use the native keyboard path.

For drag and scroll tests, `drag(widget, { x, y })` sends a physical pointer gesture through native hit testing. Coordinates are in the receiving canvas. `beginDrag` leaves the gesture active so you can inspect its preview; finish it with `pointer(widget, "up", point)` or cancel with `key(widget.view, "escape")`. `pointer` also accepts `"down"`, `"drag"`, and `"cancel"`, with an optional `{ x, y }` movement delta. `wheel(widget, deltaY, deltaX)` scrolls at the widget's center. `dropFiles(view, paths, window)` sends a platform file-drop event; an empty view addresses the window, and the default window is `1`. These inputs use the normal runtime and replay journal.

For secondary windows, `snapshot.windows` lists live window identities, labels, titles, bounds, focus, and hidden state. `frame()` presents all live canvases. Select controls with `findWidget(snapshot, { role, name, view })`; pointer, text, and keyboard operations route to that canvas. `closeWindow(window)` sends a native user-close notification, including the window's close command. `menu(command, windowId)` addresses a specific window and defaults to the primary window.

`contextPress(widget)` presents the declared native menu. `snapshot.contextMenu` contains the exact target, correlation token, pointer position, and all item records; labels remain lossless byte arrays. Pass that record to `contextMenuAction(menu, itemId)` to select an item, or use item `0` to dismiss. A superseded record exercises the native stale-token path and cannot select a successor menu. These events replay through the normal runtime.

`snapshot.webViews` lists every open WebView's URL, bounds, layer, transparency, bridge setting, and zoom. The test host enforces the app manifest's permissions and navigation origins during startup and replay, so undeclared navigation fails as it does in a shipping app.

### Models, effects, and replay

`snapshot.model` is the committed native model's JSON projection: byte values become number arrays, enums become strings, and tagged unions use the generated mirror's `{arm: payload}` shape. Its TypeScript type is a JSON record. `snapshot.widgets` contains accessibility state, bounds, and available actions. Each widget's `value` reports its applied numeric accessibility value, or `null` when it has none; use it to assert slider and split-divider positions, including retained values outside the model. The `fingerprint` covers the native accessibility tree.

The host drives the primary canvas and every live secondary canvas on the current desktop build target. It uses fake effects and a fixed clock (`wallMs`, default zero). It does not start production services, load persistence, read launch environment values, or open app-data directories.

Inspect `snapshot.effects.requests`, then answer a request with `app.respond(key, bytes, ok)`. For typed services, successful responses use the service contract's canonical binary encoding; the generated result decoder routes them to the typed Msg arm. Failed responses carry error bytes. See `examples/service-feed-reader/tests/view.test.ts` for a record response fixture. Inspect `snapshot.effects.timers`, then call `app.fireTimer(key)` to send a journaled platform timer event. `snapshot.effects.recorded` counts journaled effect results, including clock reads.

Rendering uses `NullPlatform`; OS windows, GPU presentation, and platform integrations still need live [automation tests](/docs/automation).

`verifyReplay()` finishes the in-memory recording, creates a fresh native runtime, and verifies recorded checkpoints, the final accessibility fingerprint, and the final model. Its report includes event, effect, and checkpoint counts plus the replayed snapshot. It is terminal: afterward, use only `snapshot()` and `close()`. The journal and each response are capped at 8 MiB; each request is capped at 64 KiB. Exceeding a bound fails the test.

Tests are discovered directly under `tests/` (no recursive discovery), run in a shared Node process, and resolve the SDK selected by the build. The core remains compiled even when an app has no local `node_modules`. For editor checking of Node test files, install `@types/node` and use a separate test tsconfig with `types: ["node"]` and `include: ["tests/**/*.ts"]`; keep the deterministic core's tsconfig separate.

## Zig full-loop UI tests

For apps created with `--template zig-core`, the generated `src/tests.zig` shows the core pattern: build the real view from the markup, find a widget, dispatch its press exactly like the runtime would, and assert on the model and the rebuilt view:

```zig
var view = try canvas.MarkupView(Model, Msg).init(arena, main.app_markup);
var ui = canvas.Ui(Msg).init(arena);
const tree = try ui.finalize(try view.build(&ui, &model));

var fx = main.Effects.init(testing.allocator);           // fake-executor effects channel
defer fx.deinit();
fx.executor = .fake;

const plus = findByText(tree.root, .button, "+").?;           // walk tree.root
main.update(&model, tree.msgForPointer(plus.id, .up).?, &fx); // dispatch like the runtime
try testing.expectEqual(@as(i64, 1), model.count);
```

Two `msgForPointer` traps: a disabled control yields `null` (assert `== null` rather than unwrapping when testing disabled states), and the tree is a snapshot — after each dispatch, rebuild the view before pressing anything again. Widget ids are stable across rebuilds, so asserting an id stayed constant while its text changed is the standard way to prove keyed identity. Sliders have their own split: `msgForValue(id, value)` fires only a Zig builder's `on_value` constructor and returns `null` for a markup slider — a markup slider binds a plain `on-change`, so assert its dispatch with `msgFor(id, .change)`.

The same tests can push the tree through the layout engine (`canvas.layoutWidgetTree`) to assert real frames — see the generated `src/tests.zig` for both patterns. Effects-using apps swap in the fake executor (`app_state.effects.executor = .fake`) to assert on spawn/fetch/file requests and feed results back deterministically — see [Native UI: Effects](/docs/native-ui#effects).

## TestHarness

`TestHarness` provides a headless test driver using `NullPlatform` and a `BufferSink` for capturing trace records:

```zig
const harness = try native_sdk.TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
```

The harness embeds the multi-megabyte `Runtime`, so create it on the heap (`create`/`destroy`) — stack instances overflow test threads, and the same rule applies to `UiApp` instances in tests (`App.create`/`destroy`). The harness provides a pre-configured runtime with `NullPlatform` and a trace sink that captures records in memory; handler and update errors propagate (fail the test) instead of degrading. Use it to test runtime integration: lifecycle events, command dispatch, and full `UiApp` frames.

`TestHarness` is the same mechanism used by the framework's own test suite to verify bridge policy enforcement, window management, and lifecycle correctness.

## Headless tests

The default test suite does not require a window server:

```bash
zig build test
zig build test-desktop
zig build test-automation-protocol
zig build test-platform-info
zig build test-examples
zig build test-examples-native
zig build test-examples-mobile
```

Bridge and IPC coverage lives in the headless desktop tests: they inject platform bridge events, exercise command policy and handlers, and assert the platform response without launching a WebView. Automation command parsing is covered by `test-automation-protocol`.

`test-examples` runs all repository example checks. Use the narrower example groups when you want a faster pass over one slice.

`test-examples-native` runs every native-first example's headless suite with `-Dplatform=null` from the repository root. Zero-config examples (app.zon + src, no build files) are driven through the `native` CLI's generated build graph — the same path `native test` takes in any app directory — while examples that own a `build.zig` run their in-directory `zig build test`.

`test-examples-mobile` verifies the iOS, Android, and shared `mobile-shell` example layouts so the embedded host scaffolds stay present. It also checks the shared mobile-shell platform, capability, and command metadata.

## WebView smoke tests

WebView smoke coverage is a separate macOS integration step using [Automation](/docs/automation):

```bash
zig build test-webview-smoke -Dplatform=macos
zig build test-native-shell-smoke -Dplatform=macos
zig build test-webview-cef-smoke -Dplatform=macos -Dweb-engine=chromium
```

This step:

1. Starts the system WebView example with automation and the JS bridge enabled
2. Waits for a published automation snapshot (`native automate wait`)
3. Verifies main window, source, and native/WebView metadata (`native automate snapshot`)
4. Sends a `native.ping` request through `native automate bridge`
5. Verifies child WebView create, resize, navigate, and close commands
6. Verifies the responses

The CEF smoke step additionally requires a local CEF layout or `-Dcef-auto-install=true`; it exercises `native.ping` and child WebView create/resize/navigate/close through the automation bridge. These steps are intentionally opt-in because they need a GUI-capable macOS session.

The native-shell smoke step launches `examples/native-shell`, verifies toolbar/sidebar/statusbar/WebView rows in the automation snapshot, drives native focus traversal, drives a main-window resize and checks relayout bounds, dispatches `app.refresh` through bridge, menu, toolbar, and shortcut command paths, creates and closes a child preview WebView, and checks that the native status label reflects each command source.

## NullPlatform

`NullPlatform` is a headless platform stub that records loaded sources and dispatched events without creating real windows. Use it in tests and with `EmbeddedApp`:

```zig
var null_platform = native_sdk.NullPlatform.init(.{});
var runtime = native_sdk.Runtime.init(.{
    .platform = null_platform.platform(),
});
```

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)