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 13:28 UTC- Status
- passed
- Last passed
- 2026-10-15 13:28 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 38742087302
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"), "[email protected]{Enter}");
expect(onSubmit).toHaveBeenCalledWith({ email: "[email protected]" });
});
});
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
Jotai atoms with React 19, tested with Testing Library and isolated stores
Jotai 3 primitive, derived, write-only and async atoms driving a React 19 todo UI with Suspense, tested in Vitest on happy-dom with @testing-library/react, user-event and a fresh createStore per test, plus store-only tests without React.
Preact components with Signals state, tested with Testing Library on jsdom
A Preact 11 cart using @preact/signals signal and computed state plus a hooks-based async form, tested in Vitest on jsdom with @testing-library/preact and user-event, including re-render on signal change without new props.
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.
AWS SDK v3 DynamoDB document client and SQS events, tested with Vitest matchers
A single-table DynamoDB order service on @aws-sdk/lib-dynamodb with conditional puts, begins_with queries and optimistic locking, publishing SQS events, tested offline with aws-sdk-client-mock and aws-sdk-client-mock-vitest matchers.
All @testing-library/dom recipesAll @testing-library/user-event recipesAll happy-dom recipesAll vitest recipes