Testing — @zoijs/testing

Test your app the way a user uses it: render a real component, query the real DOM, fire real events. @zoijs/testing is a tiny set of helpers — no custom renderer, no snapshot format, no test runner. It works with node:test or Vitest and any DOM (jsdom, happy-dom, a real browser), and depends only on @zoijs/core.

npm i -D @zoijs/testing

A first test#

import test from "node:test";
import assert from "node:assert/strict";
import { render, fireEvent, cleanup } from "@zoijs/testing";
import { Counter } from "../src/Counter.js";

test.afterEach(() => cleanup());

test("counts up on click", async () => {
  const { getByRole } = render(Counter);
  const button = getByRole("button");

  await fireEvent.click(button);
  assert.equal(button.textContent.trim(), "1");
});

render mounts the component into a fresh container and returns queries scoped to it. await fireEvent.click(...) dispatches a real event and resolves after Zoijs's batched update, so the next line sees the new DOM.

Queries#

Find elements the way users (and screen readers) do — by what's on screen, not by CSS internals:

QueryFinds by
getByText(text)visible text (string or RegExp)
getByRole(role, { name })ARIA role, optionally filtered by accessible name
getByLabelText(text)a form control's <label>
getByTestId(id)data-testid

Each comes in variants:

  • getBy* — returns the element, throws if none or more than one.
  • queryBy* — returns the element or null (for asserting absence).
  • getAllBy* — returns an array (throws if none).
  • findBy*async; retries until the element appears (for async data).

screen gives you the same queries bound to the whole page:

import { screen } from "@zoijs/testing";
screen.getByRole("heading", { name: "Welcome" });

Events and async#

  • fireEvent(el, type, init?) and shortcuts fireEvent.click / .input / .change / .submit / … dispatch real events. await them to see the update. Set a value first with { target: { value: "hi" } }.
  • waitFor(fn) retries an assertion until it passes — for data that loads, animations, or anything async.
  • tick()await tick() yields until Zoijs's reactive flush has run, if you ever need it directly.
await fireEvent.input(getByLabelText("Email"), { target: { value: "a@b.c" } });
await waitFor(() => screen.getByText("Saved"));

Cleanup#

Call cleanup() after each test to unmount everything render created and remove its containers:

import { cleanup } from "@zoijs/testing";
test.afterEach(() => cleanup());

Testing components that use the router#

mockRouter is a controllable stand-in for an @zoijs/router instance — pass it where a component expects a router, and drive it with setPath / go:

import { mockRouter } from "@zoijs/testing";
const router = mockRouter({ path: "/" });
// render a component that reads router.path()…
router.go("/about"); // bindings that read the path update

Philosophy#

These helpers wrap the platform; they don't replace it. You assert on real nodes with textContent, getAttribute, and checked — the same DOM your users get. No virtual snapshot can drift from what actually renders.


Next: DevTools.