Quality

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:

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.

QueryFindsExample
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:

VariantNo match1 match>1 matchAsync
getBy*throwsreturns itthrowsno
queryBy*returns nullreturns itthrowsno
getAllBy*throwsreturns [el]returns allno
queryAllBy*returns []returns [el]returns allno
findBy*retries, then throwsresolves to itthrowsyes
findAllBy*retries, then throwsresolves to [el]resolves to allyes

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.

MethodDoes
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) / uncheckToggle a checkbox / radio.
screen.user.keyboard(el, key)Fire keydown + keyup for a key.
screen.user.hover(el) / unhoverFire 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.

MemberReturns
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:

ToolUse 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 });
});
MemberReturns
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.eventsEvery 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?)

OptionTypePurpose
stateobjectSeed reactive $state before the first plan (inject props / fixtures).
themestring | objectTheme name or token map applied before the first render.
routestringInitial hash route, e.g. "/orders/42".
fetch(url, init) => resultMock the global fetch used by $http(...).
componentsComponentSpec[]Host-registered custom components.
captureEventsstring[]Custom $emit event names to capture from the first render.
showErrorsbooleanRender the in-shadow parse-error banner (off by default).
containerHTMLElementMount target (defaults to document.body).
setup (renderComponent only)stringDSL statements injected above the $app(expression) line.

The Screen

GroupMembers
QueriesgetByText, getByRole, getByLabelText, getByPlaceholderText, getByTestId — each with query/getAll/queryAll/find/findAll variants.
Interactionclick, type, fireEvent, and the user.* suite.
Statestate.get, state.has, state.snapshot, state.set, waitForState.
Eventsevents, emitted, lastEvent, listen, waitForEvent.
Routingnavigate, route.
Lifecyclererender, appendChunk, stream, setStreaming, setTheme, flush, unmount.
Inspecthtml, debug, container, shadowRoot, requests.

Module functions

ExportPurpose
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 LibraryAktion Testing Library
render(<App />)render(`$app(...)`) — you pass a program string, not JSX.
screen.getByRole / getByTextSame 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 / waitForIdentical.
act(() => …)act(...) / await flush().
jest.fn() spy passed as a propCapture 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 probingscreen.state.get(...) / state.snapshot() (the reactive store).
rerender(<App />)screen.rerender(`…`).
(no equivalent)screen.stream([...]) — test streaming / partial rendering.

Best practices

Next