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/testingA 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:
| Query | Finds 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 ornull(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 shortcutsfireEvent.click/.input/.change/.submit/ … dispatch real events.awaitthem 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 updatePhilosophy#
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.