Testing.
The Aktion Testing Library (aktion-runtime/test)
brings React-Testing-Library ergonomics to Aktion programs. You
render(...) a program into a real
<aktion-app>, query the rendered shadow DOM the way
a user sees it — by text, role, or label — drive real
clicks and keystrokes, and assert on three things an Aktion program
exposes: the rendered output, the reactive
$state, and the events
it emits.
How it works
The library is a thin, runner-agnostic toolkit over the genuine runtime. It deliberately mirrors how a user — or an LLM — actually runs your program, so a passing test means the real pipeline works:
-
You test the program string, not internals. The unit
of authorship in Aktion is a
$app(...)program, sorender(source)mounts a real<aktion-app>and exercises the genuine parse → plan → render → morph pipeline — real reactivity, real effects, real two-way binding, real router. No mocking of the framework. -
You query the rendered shadow DOM, not private state.
Queries (
getByRole,getByText, …) search the element’s shadow root for what the user can see. Prefer accessible queries (role / label / text) over implementation details, exactly as in Testing Library. -
Assertions target output,
$state, and events. Read reactive atoms withscreen.state.get("count")(backed byserializeState()), and captureassistant-message,route-change,error, and custom$emit(...)events withscreen.emitted(...). -
Async is first-class. Aktion renders on the microtask
queue, so interactions auto-flush and
findBy*/waitForretry until the next paint settles — no manualawait Promise.resolve()plumbing.
Setup
The library works with any runner that provides a DOM (Vitest, Jest, Web Test Runner) — it has zero runner dependencies. The examples here use Vitest with happy-dom (jsdom works too). A custom-element + shadow-DOM-capable DOM is the only requirement.
npm i -D vitest happy-dom
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { environment: "happy-dom" }, // or "jsdom"
});
Then import the helpers from aktion-runtime/test:
import { render, renderComponent, cleanup, flush, json } from "aktion-runtime/test";
render returns a Screen scoped to that
instance’s shadow root (there is no single global screen
— each mounted program owns its own, which keeps shadow roots
isolated). Wire cleanup into your runner’s
afterEach so each test starts clean:
import { afterEach } from "vitest";
import { cleanup } from "aktion-runtime/test";
afterEach(cleanup); // unmounts every screen + restores any mocked fetch
Your first test
A complete test of a reactive counter — render, assert the initial
UI, click, then assert both the reactive $state and the
re-rendered DOM.
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup, flush } from "aktion-runtime/test";
afterEach(cleanup);
it("increments the counter", async () => {
const screen = render(`
$count = 0
$app(Column([
Text(`Count: ${$count}`),
Button("Increment", { onClick: () => $count = $count + 1 })
]))
`);
await flush(); // settle the first paint
expect(screen.getByText("Count: 0", { exact: false })).toBeTruthy();
await screen.click("Increment"); // finds the button by text
expect(screen.state.get("count")).toBe(1); // reactive state updated
expect(screen.getByText("Count: 1", { exact: false })).toBeTruthy();
});
render(...) is synchronous (like RTL), but the first paint
lands on the next microtask — so await flush() once
before synchronous getBy* assertions, or use
await screen.findByText(...) which waits for you.
Interactions (screen.click, screen.user.*)
always auto-flush.
Queries
Queries search the shadow DOM for elements. As in Testing Library, prefer queries that resemble how a user finds things — by ARIA role and accessible name, then visible text, then label.
| Query | Finds | Example |
|---|---|---|
getByRole(role, { name }) | An element by ARIA role (explicit or implicit) and optional accessible name. | getByRole("button", { name: "Save" }) |
getByText(matcher) | The innermost element whose visible text matches. | getByText("Welcome back") |
getByLabelText(matcher) | A form field by its associated <label> / aria-label. | getByLabelText("Email") |
getByPlaceholderText(matcher) | An input by its placeholder. | getByPlaceholderText("Search…") |
getByTestId(id) | An element by data-testid (escape hatch — prefer the above). | getByTestId("row-7") |
The matcher is a string (exact, normalised text by
default; pass { exact: false } for a substring), a
RegExp, or a predicate (text, el) => boolean.
Query variants
Every query comes in the familiar six flavours:
| Variant | No match | 1 match | >1 match | Async |
|---|---|---|---|---|
getBy* | throws | returns it | throws | no |
queryBy* | returns null | returns it | throws | no |
getAllBy* | throws | returns [el] | returns all | no |
queryAllBy* | returns [] | returns [el] | returns all | no |
findBy* | retries, then throws | resolves to it | throws | yes |
findAllBy* | retries, then throws | resolves to [el] | resolves to all | yes |
Use queryBy* to assert absence
(expect(screen.queryByText("Error")).toBeNull()) and
findBy* to assert something that appears after an
effect, timer, or network request resolves.
Scoped queries with within
When a page has several similar regions (cards, rows, list items), scope a
query to one subtree with within(node). It returns the same query
suite, but rooted at node instead of the whole shadow tree.
import { render, within } from "aktion-runtime/test"
const { getByTestId } = render(program)
const row = getByTestId("row-7")
expect(within(row).getByRole("button", { name: "Delete" })).toBeTruthy()
Interactions
Interactions dispatch real DOM events — the same ones a browser
fires — and then flush the reactive queue. Two-way binding,
onClick handlers, and effects all run exactly as in
production.
| Method | Does |
|---|---|
screen.click(targetOrText) | Clicks an element, or finds a button/link by text and clicks it. |
screen.type(targetOrLabel, text) | Types into an element, or into a field found by label/placeholder. |
screen.user.click(el) | Click an element. |
screen.user.type(el, text) | Type character-by-character (fires an input event per key, like real typing). |
screen.user.clear(el) | Clear an input’s value. |
screen.user.selectOption(select, value) | Choose a <select> option. |
screen.user.check(el) / uncheck | Toggle a checkbox / radio. |
screen.user.keyboard(el, key) | Fire keydown + keyup for a key. |
screen.user.hover(el) / unhover | Fire pointer enter/leave (for tooltips, hover cards). |
screen.user.submit(form) | Submit a form. |
screen.fireEvent(el, type, init?) | Low-level escape hatch: dispatch any event and flush. |
Two-way binding
Typing into a bound input writes straight through to the reactive atom — the genuine binding path, not a simulation.
it("binds an input to $state", async () => {
const screen = render(`
$name = ""
$app(Column([
Input("Name", { value: $name }),
Text(`Hi ${$name}`)
]))
`);
await flush();
await screen.type(screen.getByRole("textbox"), "Ada");
expect(screen.state.get("name")).toBe("Ada");
expect(screen.getByText("Hi Ada", { exact: false })).toBeTruthy();
});
Asserting on reactive state
screen.state reads the live reactive snapshot. It is the
cleanest way to assert behaviour that isn’t (or isn’t only)
visible in the DOM.
| Member | Returns |
|---|---|
screen.state.get(name) | One atom’s current value. |
screen.state.has(name) | Whether the atom exists yet. |
screen.state.snapshot() | The whole state object — great for toMatchSnapshot(). |
screen.state.set(name, value) | Simulate a host-side write (hydrateState) and re-render. |
screen.waitForState(name, predicate?) | Await an atom until predicate(value) holds (or it becomes non-null). |
// Seed initial state, then assert a derived/computed atom.
const screen = render(`
$price = 0
$qty = 0
$total = $price * $qty
$app(Text(`Total: ${$total}`))
`, { state: { price: 10, qty: 3 } });
await flush();
expect(screen.state.get("total")).toBe(30);
Async, effects & timers
Aktion schedules renders on the microtask queue. The library exposes three escalating tools:
| Tool | Use for |
|---|---|
await flush() | Settle a synchronous chain (a click that writes state that re-derives a computed atom). Drains the microtask queue. |
await screen.findByText(...) | Wait for something to appear after a promise / timer / request. Retries with a timeout. |
await waitFor(fn) / screen.waitForState(...) | Wait for an arbitrary condition / a state atom to satisfy a predicate. |
await act(fn) | Run a block that triggers reactive writes, then flush — the Aktion analogue of React’s act(). |
For setInterval / "every(N)" / debounced
effects, use your runner’s fake timers. flush() still
works because microtasks are not faked.
import { vi } from "vitest";
it("ticks every second", async () => {
vi.useFakeTimers();
const screen = render(`
$now = 0
$effect(() => {
let id = setInterval(() => { $now = $now + 1 }, 1000)
cleanup(() => clearInterval(id))
}, ["mount"])
$app(Text(`Ticks: ${$now}`))
`);
await flush();
await vi.advanceTimersByTimeAsync(3000); // fire three intervals
await flush();
expect(screen.state.get("now")).toBe(3);
vi.useRealTimers();
});
Testing a single component
renderComponent(expression, options) renders one component
call in isolation — it wraps the expression in $app(...)
and lets you prepend a setup block of helper DSL (component
definitions, seed state).
import { renderComponent, flush } from "aktion-runtime/test";
it("colours a PriceTag by amount", async () => {
const screen = renderComponent(`PriceTag(42)`, {
setup: `
function PriceTag(amount) {
return Badge(`$${amount}`, { tone: amount > 100 ? "danger" : "success" })
}
`,
});
await flush();
expect(screen.getByText("$42", { exact: false })).toBeTruthy();
});
To assert that a callback prop fired, capture it the idiomatic Aktion
way — write a $state flag (or $emit) from
an inline handler, then read it back. This keeps the test in the
language rather than reaching for host-side spies.
it("fires onClick", async () => {
const screen = renderComponent(`Button("Save", { onClick: () => $saved = true })`);
await flush();
await screen.click("Save");
expect(screen.state.get("saved")).toBe(true);
});
Testing events
An Aktion program talks to its host by dispatching DOM events. The
Screen captures them so you can assert on the payloads. The three
built-ins — assistant-message,
route-change, and error — are always
captured. For a custom $emit("name", ...) event, list its
name in captureEvents (or call screen.listen("name"))
before the trigger, since custom names aren’t
known in advance.
it("emits a saved event with the new id", async () => {
const screen = render(`
function save() { $emit("saved", { id: 7 }) }
$app(Button("Save", { onClick: save }))
`, { captureEvents: ["saved"] });
await flush();
await screen.click("Save");
expect(screen.emitted("saved")).toEqual([{ id: 7 }]);
expect(screen.lastEvent("saved")).toEqual({ id: 7 });
});
| Member | Returns |
|---|---|
screen.emitted(type?) | Array of detail payloads for that event type (or all events). |
screen.lastEvent(type) | The most recent detail for that type. |
screen.events | Every captured { type, detail, event }. |
screen.listen(type) | Start capturing a custom event type (call before the trigger). |
await screen.waitForEvent(type) | Resolve to the next detail of that type (async work). |
Testing $http
Pass a fetch mock to render and it replaces the
global fetch for the lifetime of the test (restored on
cleanup). The handler receives the resolved URL and the
request init; return a Response, a string, or a plain
object — the json(data, status?) helper builds a JSON
response for you.
import { render, json, flush } from "aktion-runtime/test";
it("loads and renders the user list", async () => {
const screen = render(`
$users = $http({ url: "https://api.example.com/users" })
$app(Async($users, {
loading: Text("Loading…"),
data: Text(`${$users.data.length} users`)
}))
`, {
fetch: (url, init) => json([{ id: 1 }, { id: 2 }]),
});
// findBy* waits for the request to resolve and the data slot to render.
expect(await screen.findByText("2 users", { exact: false })).toBeTruthy();
// Assert what was requested.
expect(screen.requests[0].url).toContain("/users");
expect(screen.requests[0].method).toBe("GET");
});
Branch on method / URL to drive writes, errors, and refetches:
const screen = render(programSource, {
fetch: (url, init) => {
if (init.method === "POST") return json({ id: 99 }, 201);
if (url.includes("/fail")) return { status: 500, json: { message: "boom" } };
return json([]);
},
});
// later — assert the POST body the program sent
await screen.click("Create");
const post = screen.requests.find((r) => r.method === "POST");
expect(post.body).toEqual({ name: "New order" });
Testing routing
Set the initial hash route with the route option, then
drive navigation with screen.navigate(...). The matching
$router arm renders just like in the browser.
it("renders the matched route and follows navigation", async () => {
const screen = render(`
$app($router({
"/": Text("Home page"),
"/about": Text("About page"),
default: Text("Not found")
}))
`, { route: "/about" });
await flush();
expect(screen.getByText("About page", { exact: false })).toBeTruthy();
expect(screen.route).toBe("/about");
await screen.navigate("/");
expect(screen.getByText("Home page", { exact: false })).toBeTruthy();
});
Testing streaming
Aktion is streaming-first — an LLM emits the program token by
token. screen.stream(chunks) appends each chunk (with
streaming on, so transient mid-token parse errors are
suppressed) and flushes, letting you assert the UI converges once the
program completes. screen.appendChunk appends a single
chunk for finer control.
it("renders progressively as chunks arrive", async () => {
const screen = render(""); // start empty
await screen.stream([
`$app(Column([`,
` Text("First"),`,
` Text("Second")`,
`]))`,
]);
expect(screen.getByText("First", { exact: false })).toBeTruthy();
expect(screen.getByText("Second", { exact: false })).toBeTruthy();
});
Snapshots
Two snapshot surfaces — the rendered markup and the reactive state:
const screen = render(programSource);
await flush();
expect(screen.html()).toMatchSnapshot(); // rendered shadow DOM
expect(screen.state.snapshot()).toMatchSnapshot(); // every reactive atom
screen.debug() prints the current shadow-DOM markup to the
console while you iterate — the equivalent of RTL’s
screen.debug().
Custom (host-registered) components
If your app registers TypeScript ComponentSpecs via
registerComponents(...), pass them to render
through the components option so your program can call them.
import { render } from "aktion-runtime/test";
import { ProductCard } from "../src/components/product-card";
const screen = render(`$app(ProductCard("Widget", { price: 9.99 }))`, {
components: [ProductCard],
});
await flush();
expect(screen.getByText("Widget", { exact: false })).toBeTruthy();
Accessibility audits with axe
Run a lightweight a11y audit on any rendered subtree with
axe(node). It returns an array of violations — missing
image alt text, buttons without an accessible name, inputs
without a label, duplicate ids, and invalid tabindex — so
you can fail a test the moment a regression slips in.
import { render, axe } from "aktion-runtime/test"
test("dashboard has no obvious a11y violations", () => {
const { container } = render(program)
expect(axe(container)).toHaveLength(0)
})
Testing SSR output
Assert on server‑rendered markup with renderToString from the
runtime entry. It returns { html, state }; check that critical
content is present in the string (no client mount required) and that the
hydration state carries what the page needs.
import { renderToString } from "aktion-runtime"
test("renders the headline on the server", () => {
const { html, state } = renderToString(program, { path: "/" })
expect(html).toContain("Welcome back")
expect(state.user).toBeDefined()
})
In Node, register a DOM shim (happy-dom / jsdom) on
globalThis first — the same setup described in
Setup.
API reference
render(program, options?) & renderComponent(expression, options?)
| Option | Type | Purpose |
|---|---|---|
state | object | Seed reactive $state before the first plan (inject props / fixtures). |
theme | string | object | Theme name or token map applied before the first render. |
route | string | Initial hash route, e.g. "/orders/42". |
fetch | (url, init) => result | Mock the global fetch used by $http(...). |
components | ComponentSpec[] | Host-registered custom components. |
captureEvents | string[] | Custom $emit event names to capture from the first render. |
showErrors | boolean | Render the in-shadow parse-error banner (off by default). |
container | HTMLElement | Mount target (defaults to document.body). |
setup (renderComponent only) | string | DSL statements injected above the $app(expression) line. |
The Screen
| Group | Members |
|---|---|
| Queries | getByText, getByRole, getByLabelText, getByPlaceholderText, getByTestId — each with query/getAll/queryAll/find/findAll variants. |
| Interaction | click, type, fireEvent, and the user.* suite. |
| State | state.get, state.has, state.snapshot, state.set, waitForState. |
| Events | events, emitted, lastEvent, listen, waitForEvent. |
| Routing | navigate, route. |
| Lifecycle | rerender, appendChunk, stream, setStreaming, setTheme, flush, unmount. |
| Inspect | html, debug, container, shadowRoot, requests. |
Module functions
| Export | Purpose |
|---|---|
render(program, options?) | Mount a full program; returns a Screen. |
renderComponent(expr, options?) | Mount one component expression in isolation. |
within(node) | Return the query suite scoped to a subtree. |
axe(node) | Audit a subtree and return an array of a11y violations. |
cleanup() | Unmount every screen + restore mocked fetch (wire into afterEach). |
flush(times?) | Drain the microtask queue so the render cascade settles. |
act(fn) | Run a reactive-write block, then flush. |
waitFor(fn, opts?) | Poll until fn returns a non-empty value or times out. |
json(data, status?) | Build a JSON MockResult for a fetch handler. |
Coming from React Testing Library
The mental model transfers almost one-to-one:
| React Testing Library | Aktion Testing Library |
|---|---|
render(<App />) | render(`$app(...)`) — you pass a program string, not JSX. |
screen.getByRole / getByText | Same names, same semantics — but on the returned screen, scoped to the shadow root. |
userEvent.click(el) | screen.user.click(el) or screen.click("text"). |
userEvent.type(el, "x") | screen.user.type(el, "x") or screen.type("Label", "x"). |
await screen.findByText / waitFor | Identical. |
act(() => …) | act(...) / await flush(). |
jest.fn() spy passed as a prop | Capture with an inline $state flag or $emit(...), assert via screen.state / screen.emitted. |
MSW / fetch mock | { fetch } option + json() helper; inspect screen.requests. |
| Redux store probing | screen.state.get(...) / state.snapshot() (the reactive store). |
rerender(<App />) | screen.rerender(`…`). |
| (no equivalent) | screen.stream([...]) — test streaming / partial rendering. |
Best practices
- Query by role / text, not by class. Tests that find a button by its label survive refactors; tests that query
.rui-buttonbreak when internals change. - Assert on what the user sees first. Reach for
screen.statewhen the behaviour is genuinely invisible (a computed total, a flag), not as a shortcut around the DOM. - Use
findBy*for anything async. NeversetTimeoutin a test —findBy*/waitForretry deterministically. - One behaviour per test. Render, do one thing, assert the consequence — the counter test above is the shape to copy.
- Wire
afterEach(cleanup)once per file so a leaked element or mockedfetchcan’t bleed into the next test. - Prefer real interactions to direct state writes.
screen.click("Save")exercises the handler, the action, and the re-render;state.set(...)only seeds a value.
Next
Hooks & state
The reactive model your tests assert against — $state, $memo, and per-instance state.
Side effects
Effects, timers, and cleanup — what fake timers and findBy* let you test.
HTTP & resources
The $http({…}) resource bag you mock with the fetch option.