Setting up React Testing Library

Component tests with React Testing Library on Bun's test runner, using Happy DOM as the DOM environment (not jsdom).

Prerequisites

  • Bun project with React
  • tsconfig.json already includes DOM libs ("lib": ["ESNext", "DOM", "DOM.Iterable"]) — see the TypeScript guide for the browser-app config

Steps

  1. Install

    bun add -d @happy-dom/global-registrator
    bun add -d @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event
    • @happy-dom/global-registrator injects browser APIs (document, window, …) into the test process
    • @testing-library/jest-dom adds matchers like toBeInTheDocument
    • @testing-library/user-event is the interaction helper — prefer it over fireEvent
  2. Create the Happy DOM preload

    • This must run before any @testing-library/* import, so keep it in its own file
    // src/happydom.ts
    import { GlobalRegistrator } from "@happy-dom/global-registrator";
    
    GlobalRegistrator.register();
  3. Create the Testing Library preload

    • Extends Bun's expect with jest-dom matchers, and unmounts render after each test so leftover DOM doesn't leak between cases
    // src/testing-library.ts
    import { afterEach, expect } from "bun:test";
    import { cleanup } from "@testing-library/react";
    import * as matchers from "@testing-library/jest-dom/matchers";
    
    expect.extend(matchers);
    
    afterEach(() => {
      cleanup();
    });
  4. Register both preloads in bunfig.toml

    • Order matters: Happy DOM first, Testing Library second
    # bunfig.toml
    [test]
    preload = ["./src/happydom.ts", "./src/testing-library.ts"]
  5. Add matcher types

    • Declaration merging so toBeInTheDocument and the other jest-dom matchers type-check on expect(...). Lives under src/ so the browser-app tsconfig.json "include" picks it up
    // src/matchers.d.ts
    import type { TestingLibraryMatchers } from "@testing-library/jest-dom/matchers";
    import type { Matchers, AsymmetricMatchers } from "bun:test";
    
    declare module "bun:test" {
      interface Matchers<T> extends TestingLibraryMatchers<typeof expect.stringContaining, void> {}
      interface AsymmetricMatchers extends TestingLibraryMatchers<any, any> {}
    }
  6. Write a smoke test

    • Colocate tests next to the file they cover (*.test.tsx for components). Query by role or label — getByTestId is a last resort
    // src/components/greeting.test.tsx
    import { test, expect } from "bun:test";
    import { render, screen } from "@testing-library/react";
    
    function Greeting({ name }: { name: string }) {
      return <h1>Hello, {name}</h1>;
    }
    
    test("renders the name in a heading", () => {
      render(<Greeting name="Ada" />);
      expect(screen.getByRole("heading", { name: "Hello, Ada" })).toBeInTheDocument();
    });
    • For clicks and typing, import userEvent from @testing-library/user-event and call userEvent.setup() inside each test so instances don't share event state

Verification

  • bun test runs the smoke test and it passes
  • expect(...).toBeInTheDocument type-checks in the editor (no red squiggle on the matcher)

References