Testing
Unit-test atoms, actions, and components with Vitest — the same stack the Mates codebase itself runs on.
Why Vitest + happy-dom
Mates works in any test runner. Atoms and validators are plain functions — most logic needs no DOM. For components, the framework's own suite uses vitest with happy-dom, which provides a lightweight DOM so renderApp and event handlers behave like the browser.
Setup
npm install -D vitest happy-dom
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "happy-dom",
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});
Testing atoms and effects
Atoms are plain reactive values — no component or DOM needed. Read with (), write with .set(), and observe with effect():
import { describe, expect, it } from "vitest";
import { atom, effect } from "mates";
describe("counter atom", () => {
it("updates and notifies subscribers", () => {
const count = atom(0);
const seen: number[] = [];
const stop = effect(() => seen.push(count()));
count.set(1);
count.set((n) => n + 1);
expect(count()).toBe(2);
expect(seen).toEqual([0, 1, 2]);
stop();
});
});
The same pattern works for store, memo, and form helpers — formAtom plus validators are pure functions over atoms, so they test without DOM:
import { describe, expect, it } from "vitest";
import { checkEmail, checkMinLength } from "mates";
describe("validators", () => {
it("rejects invalid emails", () => {
expect(checkEmail("not-an-email")).toBe(false);
expect(checkEmail("dev@mates.dev")).toBe(true);
});
it("enforces min length", () => {
expect(checkMinLength("ab", 3)).toBe(false);
expect(checkMinLength("abc", 3)).toBe(true);
});
});
Testing components
Components are functions that return templates. Mount one with renderApp() into a happy-dom element, then assert on the DOM. renderApp wraps the component in a scope so useUtils() and hooks work:
import { atom, html } from "mates";
export const Counter = () => {
const count = atom(0);
return () => html`
<div>
<span class="value">${count()}</span>
<button @click=${() => count.set((n) => n + 1)}>+1</button>
</div>
`;
};
import { describe, expect, it, afterEach } from "vitest";
import { renderApp } from "mates";
import { Counter } from "./Counter.ts";
describe("Counter", () => {
let host: HTMLDivElement;
afterEach(() => host?.remove());
it("renders and increments", () => {
host = document.createElement("div");
document.body.appendChild(host);
renderApp(Counter, host);
const value = host.querySelector(".value")!;
expect(value.textContent).toBe("0");
(host.querySelector("button") as HTMLButtonElement).click();
expect(value.textContent).toBe("1");
});
});
Testing async actions
asyncAction wraps any promise-returning function with loading / error / data state. For unit tests, mock the network at the function boundary — the action never needs to know:
import { describe, expect, it, vi } from "vitest";
import { asyncAction } from "mates";
const fetchUsers = asyncAction(async () => {
const res = await fetch("/api/users");
return res.json();
});
describe("fetchUsers", () => {
it("exposes loading and data", async () => {
const payload = [{ id: 1, name: "Ada" }];
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify(payload)),
);
const promise = fetchUsers();
expect(fetchUsers.loading()).toBe(true);
await promise;
expect(fetchUsers.loading()).toBe(false);
expect(fetchUsers.data()).toEqual(payload);
});
it("surfaces errors", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
await expect(fetchUsers()).rejects.toThrow("offline");
expect(fetchUsers.error()).toBeTruthy();
});
});
For typed HTTP, mock FetchClient methods the same way — actions stay ignorant of transport, so a vi.spyOn on the client is enough. End-to-end request tests belong in an integration suite against a real server. See FetchClient and Error handling.
Testing WebSocket code
The repo ships a small WebSocket test server (lib/socket/ws-test-server/server.cjs) used by the framework's own socket tests. Point ws() at it to exercise reconnects, auth refresh, and message flow:
import { ws } from "mates";
const socket = ws("ws://localhost:8787");
socket.status(); // reactive connection status atom
What to test
- State logic (atoms, stores, derived values) — cheap and highest value
- Validators and form rules as pure functions
- Action state machines: loading → data / loading → error
- Component rendering and user-event behavior (click, input)
- Route guards and navigation hooks with
navigateTo