Level 2: DOM-based Component Tests
Component testing with jsdom or happy-dom and Testing Library.
What Level 2 Tests
Level 2 tests verify component behavior in a simulated DOM environment. They can check that components render the right elements, respond to user events, and update state correctly -- all without a real browser.
Typical targets:
Component rendering (does it output the right elements?)
Conditional display (does it show/hide based on props or state?)
Event handlers (does clicking trigger the right behavior?)
Prop-driven behavior
Component integration (parent-child communication)
Tools
| Tool | Role |
|---|---|
| vitest | Test runner |
| jsdom or happy-dom | Simulated browser DOM environment |
| @testing-library/react | DOM queries and user event simulation |
| @testing-library/preact | For Preact projects |
Setup
Configure vitest to use a DOM environment:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom", // or "happy-dom"
globals: true, // required for Testing Library auto-cleanup between tests
},
});Tip
happy-dom is faster than jsdom for most use cases. Use jsdom when you need broader browser API compatibility. Either way, jsdom and happy-dom are separate devDependencies from Vitest itself -- install whichever one you reference, or the first run fails with a module-not-found error.
Note
Without globals: true, Testing Library's auto-cleanup never registers, so a second render() in the same file leaves the previous component's DOM behind (e.g. two buttons match getByRole("button")). If you would rather not enable globals: true project-wide, clean up explicitly instead:
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(cleanup);Example
// components/Toggle.tsx
import { useState } from "react";
export function Toggle({ label }: { label: string }) {
const [on, setOn] = useState(false);
return (
<button onClick={() => setOn(!on)}>
{label}: {on ? "ON" : "OFF"}
</button>
);
}// components/Toggle.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Toggle } from "./Toggle";
describe("Toggle", () => {
it("renders with OFF state", () => {
render(<Toggle label="Sound" />);
expect(screen.getByText("Sound: OFF")).toBeTruthy();
});
it("toggles to ON on click", async () => {
const user = userEvent.setup();
render(<Toggle label="Sound" />);
await user.click(screen.getByRole("button"));
expect(screen.getByText("Sound: ON")).toBeTruthy();
});
});Render-to-String SSG-Presence Tests
jsdom is not the only way to do Level 2. For islands and SSG frameworks, there is a second, cheaper variant: render the component to a string with the framework's render-to-string under plain Node, then assert the static markup contract -- the parts of the output that crawlers and JS-off users depend on. This needs no simulated DOM environment at all, so it runs at roughly Level 1 cost. In one documentation-site project it became the default Level 2 layer, used 27+ times.
The typical target is a presence contract: "these links must exist in the server-rendered HTML, before any JavaScript runs."
// toc.presence.test.tsx
import { describe, it, expect } from "vitest";
import { renderToString } from "preact-render-to-string";
import { TableOfContents } from "./TableOfContents";
describe("TableOfContents SSG presence", () => {
it("emits every heading link in static markup", () => {
const headings = [
{ id: "intro", text: "Intro" },
{ id: "setup", text: "Setup" },
];
// Rendered under plain Node -- no jsdom, no browser.
const html = renderToString(<TableOfContents headings={headings} />);
// The contract crawlers and JS-off readers rely on:
expect(html).toContain('href="#intro"');
expect(html).toContain('href="#setup"');
});
});What it proves: the static, pre-hydration markup the framework emits on the server. What it can never prove: browser parse repair, CSS visibility, event wiring, the island's hydration lifecycle, or the DOM shape after hydration. Those need a real browser -- and one specific post-hydration failure is documented as the hydration mis-nesting variant, which requires a Level 4 test with a hydration wait.
Blind Spots
Warning
Level 2 tests run outside a real browser -- whether against a simulated DOM (jsdom / happy-dom) or as render-to-string markup with no DOM at all. Neither variant can detect:
CSS effects (there is no CSS engine)
Visual layout (elements may exist in the markup but be invisible via CSS)
Browser-specific rendering
Scroll behavior
Animation and transition states
Computed styles
The critical gap: an element can be present in the jsdom tree or the render-to-string output (Level 2 passes) while being completely invisible on screen due to CSS (Level 5 would catch this).
When jsdom Is Not Enough (But Full E2E Is Too Much)
jsdom and happy-dom have no CSS engine, so everything in the Blind Spots list above -- computed styles, var() and oklch() resolution, real layout -- stays invisible to a Level 2 test. Vitest's browser mode closes that specific gap without leaving Level 2: it runs component tests inside a real Playwright-driven Chromium instead of a simulated DOM, so computed styles resolve for real because an actual browser's CSS engine is doing the resolving.
The test file itself barely changes -- same describe/it, same component-render API. What changes is the environment underneath a second Vitest project scoped to a *.browser.test.ts naming convention, alongside the existing jsdom unit project. See the two-project recipe for the concrete config.
Note
Live practice: zdtp runs exactly this split -- jsdom for the bulk of component tests, and a browser project running *.browser.test.ts for the handful that need to assert a real computed style.
Where this sits in the level model: still Level 2 ergonomics -- component isolation, the vitest API, no app server to boot -- but with Level 5-grade CSS observability, for library code exercised in isolation. It does not replace /: that skill remains the tool for verifying a full running app, where the assertion is about the real page rather than an isolated component.
When to Use Level 2
| Scenario | Level 2 Appropriate? |
|---|---|
| Component renders wrong text | Yes |
| Props not passed correctly | Yes |
| Click handler not updating state | Yes |
| Element present but not visible | No -- use Level 5 |
| CSS layout broken | No -- use Level 5 |
| Assert a real computed style for a component in isolation | Yes -- via Vitest browser mode (see above) |
| Multi-page navigation flow | No -- use Level 4 |