zudo-test-wisdom
GitHub repository

Type to search...

to open search from anywhere

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

ToolRole
vitestTest runner
jsdom or happy-domSimulated browser DOM environment
@testing-library/reactDOM queries and user event simulation
@testing-library/preactFor 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 /verify-ui: 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

ScenarioLevel 2 Appropriate?
Component renders wrong textYes
Props not passed correctlyYes
Click handler not updating stateYes
Element present but not visibleNo -- use Level 5
CSS layout brokenNo -- use Level 5
Assert a real computed style for a component in isolationYes -- via Vitest browser mode (see above)
Multi-page navigation flowNo -- use Level 4

Revision History

CreatedUpdated