MATES / Docs

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
vitest.config.ts
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():

counter.test.ts
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:

Counter.ts
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>
  `;
};
Counter.test.ts
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