| name | testing |
| description | Use when writing or running tests in either package (frontend Stimulus/lib or server Fastify/sanitizer/Playwright). Provides Vitest setup, mounting helpers, route inject patterns, and Playwright stubbing templates. |
Skill: Testing
Read docs/11-testing.md first. This skill is the practical companion.
Frontend setup (one-time per package)
frontend/package.json devDeps:
vitest, jsdom, @testing-library/dom, @testing-library/user-event,
@vitest/coverage-v8 (optional)
frontend/vitest.config.ts:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
globals: false,
include: ["src/**/*.test.ts"],
setupFiles: ["src/test/setup.ts"],
},
});
frontend/src/test/setup.ts:
Mounting a Stimulus controller in a test
import { Application } from "@hotwired/stimulus";
import { fireEvent } from "@testing-library/dom";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import AppController from "./app_controller";
function mount(html: string) {
document.body.innerHTML = html;
const app = Application.start();
app.register("app", AppController);
return {
app,
el: document.body.firstElementChild as HTMLElement,
teardown: () => app.stop(),
};
}
describe("AppController", () => {
let ctx: ReturnType<typeof mount>;
afterEach(() => ctx?.teardown());
it("emits open-modal on valid URL", async () => {
ctx = mount(`
<form data-controller="app" data-action="submit->app#open">
<input name="url" value="https://example.com">
<input name="webhookUrl" value="https://hooks.example.com/x">
<button type="submit">Open</button>
</form>
`);
const events: CustomEvent[] = [];
ctx.el.addEventListener("open-modal", (e) => events.push(e as CustomEvent));
fireEvent.submit(ctx.el);
expect(events).toHaveLength(1);
expect(events[0].detail).toEqual({
url: "https://example.com",
webhookUrl: "https://hooks.example.com/x",
});
});
});
Rules:
- Always
teardown() in afterEach.
- Reset
document.body.innerHTML each test (mount does it).
- Assert observable behavior, not internal Stimulus state.
Mocking the API layer
import { vi } from "vitest";
vi.mock("../lib/api", () => ({
fetchPage: vi.fn(async () => ({ html: "<html></html>", baseUrl: "https://x" })),
sendWebhook: vi.fn(async () => ({ ok: true, status: 200, body: "OK" })),
}));
Server setup
server/package.json devDeps: vitest, @types/node.
server/vitest.config.ts:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
buildApp pattern (DI)
server/src/app.ts:
import Fastify from "fastify";
import type { FastifyInstance } from "fastify";
export interface Deps {
pool: { withPage: <T>(fn: (page: any) => Promise<T>) => Promise<T> };
fetcher?: typeof fetch;
}
export function buildApp(deps: Deps): FastifyInstance {
const app = Fastify({ logger: false });
return app;
}
Tests call buildApp({ pool: fakePool }). Production entry constructs
the real pool.
Route test template
import { describe, it, expect, vi } from "vitest";
import { buildApp } from "../app";
describe("POST /fetch", () => {
it("returns sanitized html on success", async () => {
const fakePool = {
withPage: async (fn: any) =>
fn({
goto: vi.fn().mockResolvedValue(undefined),
content: async () => "<html><body>hi<script>x</script></body></html>",
url: () => "https://example.com/page",
}),
};
const app = buildApp({ pool: fakePool });
const res = await app.inject({
method: "POST",
url: "/fetch",
payload: { url: "https://example.com/page" },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.baseUrl).toBe("https://example.com/page");
expect(body.html).not.toContain("<script>");
});
it("rejects invalid url", async () => {
const app = buildApp({ pool: { withPage: vi.fn() as any } });
const res = await app.inject({
method: "POST",
url: "/fetch",
payload: { url: "not-a-url" },
});
expect(res.statusCode).toBe(400);
expect(res.json()).toEqual({ error: "invalid_url" });
});
});
Don'ts
- Don't make real HTTP requests in unit tests. Mock undici/fetch.
- Don't launch real Chromium in unit tests. Stub the pool.
- Don't sleep/setTimeout to wait for async — use
await and
await vi.waitFor(...).
- Don't assert on Tailwind class strings unless they encode behavior;
prefer
data-state="loading" attributes you can assert against.