Vanilla DOM form testing with Testing Library + user-event on happy-dom (Vitest): working versions (tested 2026-10-15)
Test framework-free DOM code (a validated signup form) the way users use it, with @testing-library/dom queries and user-event typing on happy-dom.
passed3 passed · 0 failed2026-10-15 11:52 UTC- Status
- passed
- Last passed
- 2026-10-15 11:52 UTC
- Tests
- 3 / 3
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38730348195
The code
Exactly the files that passed. Your agent can pull them with get_recipe("dom-testing-library-happy-dom") (install), or see them on GitHub.
{
"name": "dom-testing-library-happy-dom",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run --reporter=default --reporter=json --outputFile=test-report.json"
},
"dependencies": {},
"devDependencies": {
"@types/node": "24.19.2",
"vitest": "5.0.4",
"@testing-library/dom": "10.4.2",
"@testing-library/user-event": "14.6.8",
"happy-dom": "21.0.1"
}
}
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "happy-dom" } });
export function mountSignup(root: HTMLElement, onSubmit: (data: { email: string }) => void) {
root.innerHTML = `
<form novalidate aria-label="Sign up">
<label for="email">Email</label>
<input id="email" name="email" type="email" aria-describedby="email-error" />
<p id="email-error" role="alert" hidden></p>
<button type="submit" disabled>Create account</button>
</form>`;
const form = root.querySelector("form")!;
const input = root.querySelector<HTMLInputElement>("#email")!;
const error = root.querySelector<HTMLParagraphElement>("#email-error")!;
const button = root.querySelector("button")!;
input.addEventListener("input", () => { button.disabled = input.value.trim() === ""; });
form.addEventListener("submit", (e) => {
e.preventDefault();
const ok = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input.value);
error.hidden = ok;
error.textContent = ok ? "" : "Enter a valid email";
input.setAttribute("aria-invalid", String(!ok));
if (ok) onSubmit({ email: input.value.trim() });
});
}
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";
import { mountSignup } from "../src/form";
let onSubmit: ReturnType<typeof vi.fn>;
beforeEach(() => { document.body.innerHTML = ""; onSubmit = vi.fn(); mountSignup(document.body, onSubmit); });
describe("signup form", () => {
it("button enables once the user types", async () => {
const user = userEvent.setup();
const button = screen.getByRole("button", { name: "Create account" }) as HTMLButtonElement;
expect(button.disabled).toBe(true);
await user.type(screen.getByLabelText("Email"), "a");
expect(button.disabled).toBe(false);
});
it("shows an accessible error for invalid email", async () => {
const user = userEvent.setup();
await user.type(screen.getByLabelText("Email"), "nope");
await user.click(screen.getByRole("button"));
expect(screen.getByRole("alert").textContent).toBe("Enter a valid email");
expect(screen.getByLabelText("Email").getAttribute("aria-invalid")).toBe("true");
expect(onSubmit).not.toHaveBeenCalled();
});
it("submits valid data with Enter", async () => {
const user = userEvent.setup();
await user.type(screen.getByLabelText("Email"), "ada@example.com{Enter}");
expect(onSubmit).toHaveBeenCalledWith({ email: "ada@example.com" });
});
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Astro 7 component rendering tests with the Container API (Vitest)
Render Astro components to HTML in Vitest with the Container API and getViteConfig, checking props, slots and conditional markup.
Hono + Drizzle ORM + SQLite REST API (Vitest)
CRUD REST API in Hono backed by Drizzle ORM on better-sqlite3, with Zod validation, tested with Vitest and app.request().
Hono + Valibot request validation (Vitest)
Validate JSON bodies and query strings in Hono with Valibot schemas via @hono/valibot-validator, with custom error responses, tested with Vitest.
ioredis cache-aside with TTL, tested with ioredis-mock (Vitest)
Cache-aside helper on ioredis with JSON values, TTLs, stampede-safe single flight and invalidation, tested offline with ioredis-mock in Vitest.
All @testing-library/dom recipesAll @testing-library/user-event recipesAll happy-dom recipesAll vitest recipes