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 mocked fetch + the route

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.

Escape backticks inside the program string. You pass the Aktion program to render() as a JavaScript template literal, so an Aktion template literal nested inside it must escape both the backtick and the interpolation: write Text(\`Count: \${$count}\`), not Text(`Count: ${$count}`). Unescaped, the inner backtick closes the outer literal (a SyntaxError) and ${$count} is interpolated by JavaScript — where $count does not exist. If the escaping gets noisy, keep the program in its own .aktion file and render(readFileSync(path, "utf8")).

Testing a real app: renderCompiled

render(source) takes a program string, which cannot express a multi-file app: imports are resolved by the linker at build time, so the text of an entry module is not a runnable program on its own. Pass the compiled artefact instead — what aktion-runtime/vite turns your entry into.

import { renderCompiled, json } from "aktion-runtime/test";
import app from "../src/app.aktion";          // a CompiledProgram

it("shows the cluster list", async () => {
  const screen = renderCompiled(app, {
    route: "/clusters",
    fetch: () => json({ items: [] }),
  });
  await screen.findByText("No clusters yet");
});

Every RenderOptions field applies. Two are worth calling out for a real app: httpInterceptors installs the same registerHttpInterceptors(...) hooks your host page uses (so you can assert the auth header your program never sees), and state seeds $state before the first plan.

$state names in a multi-file program. The linker gives every non-entry module private scope by renaming its atoms, so $filter declared in lib/store.aktion appears in serializeState() as __a4_filter — and the number comes from import traversal order, so it changes when an import is added. screen.state.get("filter") resolves that for you and throws a naming-collision error rather than guessing when two modules declare the same atom. Never hard-code the mangled key.

Outside a Vite build — a Node script, an SSR pass, a lint rule — aktion-runtime/vite exports the compiler directly:

import { compileAktionFile, compileAktionSource } from "aktion-runtime/vite";

const app = compileAktionFile("src/app.aktion", { root: "src" });

// An inline program whose imports resolve against the real filesystem: the way
// to unit-test a `.aktion` helper module without a fixture file per case.
const probe = compileAktionSource(
  `import { formatBytes } from "./src/lib/format.aktion"\n` +
  `$app(Text(formatBytes(2048)))`,
  "tests/probe.aktion",
  { root: process.cwd() },
);

Coverage

A .aktion file compiles to a module that does nothing but JSON.parse its own AST, so V8 and Istanbul report one executed line however much DSL sits behind it. Real coverage is a property of the interpreter: which AST nodes did the evaluator reach? Turn the recorder on and read it back.

import { beforeAll, afterAll } from "vitest";
import { coverage } from "aktion-runtime/test";

beforeAll(() => { coverage.start(); });

afterAll(() => {
  const report = coverage.report();
  console.log(coverage.formatSummary(report));
  writeFileSync("coverage/aktion.lcov", coverage.toLcov(report));
});

The report is per .aktion file, in the same terms lcov uses: lines (every line carrying a node the evaluator can reach), functions (each component / action / hook / effect / lambda, counted when its body actually runs — so a click handler that never fires shows up), and branches (both arms of every if and ternary, each short-circuiting &&/||/??, and each switch case). uncoveredLines per file is the actionable list.

Measurement accumulates across every render/renderCompiled in the file, so each test contributes. Because a runner isolates each test file in its own worker, a whole-suite number has to be assembled from shards: write coverage.report() per file, then coverage.merge(shards) in a global teardown.

Use aktion-runtime/coverage outside the DOM. aktion-runtime/test bundles the <aktion-app> element, so importing it in a Vitest globalSetup or a plain Node script throws HTMLElement is not defined. The ./coverage entry exposes the identical coverage API with no DOM dependency; both share one session.

What is not instrumented, stated plainly because a coverage number that hides its gaps is worse than none: blank lines, comments and closing braces are not executable and are absent from the report entirely (as in an instrumented JS file); Literal, Member, Object and Array nodes carry no source position, so a line holding only those is not counted; and a literal $x = 1 initialiser is attributed to its declaration rather than folded separately.

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. Still second choice after role / label / text, but backed by a first‑class testId prop — see the universal props for how to put one on a component.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.

Test ids: root elements and empty output

getByTestId only finds something once a component actually carries a matching data-testid — give it one with the testId prop (see the universal props). Two things to know before you rely on it:

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.state.key(name)The runtime key backing name — the mangled symbol for a module-local atom.
screen.state.plannedWhether the program has planned yet, so its atoms exist.
screen.waitForState(name, predicate?)Await an atom until predicate(value) holds (or it becomes non-null).

Names are the ones the author wrote. In a multi-file program the linker gives every non-entry module private scope by renaming its atoms — $count declared in lib/store.aktion is __a1_count in snapshot(), and the numeric part changes when an unrelated import is added. Every member above resolves that for you; hard-coding the mangled key is what breaks.

Flush before you read or write state. render / renderCompiled only schedule the first render — Aktion plans on the microtask queue — so until one await flush() (or any findBy*) has happened the store is still empty and state.planned is false. state.set called that early throws, rather than declaring an unrelated top-level atom the program never reads. To seed a value before the first plan, use render(…, { state }) or screen.state.hydrate(…), which are built for it.

// 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 from source; returns a Screen.
renderCompiled(compiled, options?)Mount a linked multi-file program (a CompiledProgram from the Vite plugin).
renderComponent(expr, options?)Mount one component expression in isolation.
coverageDSL coverage: start, stop, reset, report, merge, toLcov, formatSummary.
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 and put location.hash back (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 settles or times out. null, false and [] retry; pass until to decide yourself.
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