Skip to main content

Preact testing in 2025: All About Compat

· 5 min read
Jennings Zhang
Research Developer @ Boston Children's Hospital

This is part 2 of my investigation into tooling for Preact in 2025. In summary, Preact component testing is easy and works using vitest or bun. However, the only way to get preact/compat to work seamlessly is using vitest-browser-preact, which has other advantages too but at the cost of performance and stability.

Introduction

What makes component testing hard? Web apps are supposed to run in a browser, but they are developed on Node.js (or bun). The original idea behind Node.js was to make things easier for developers—use one language everywhere. However, what we ended up with are two systems (browser v.s. server JS) which are very similar yet subtly different.

Component testing usually entails trying to run browser JavaScript outside of a browser. Browser component testing in real browsers is becoming more popular, but as of now the technology is still in early development.

bun test and Preact

bun test can almost replace vitest or Jest for Preact. It only works with pure Preact components.

Setup is pretty easy: follow the official documentation, but replace @testing-library/react with @testing-library/preact. -> https://bun.com/guides/test/testing-library

// Example testing a Preact component using bun

import { expect, test } from "bun:test";
import { signal } from "@preact/signals";
import { fireEvent, render } from "@testing-library/preact";

const count = signal(0);

function increment() {
count.value++;
}

function Counter() {
return (
<button type="button" onClick={increment}>
click me
</button>
);
}

test("Counter", () => {
const { getByText } = render(<Counter />);
expect(count.value).toBe(0);
fireEvent.click(getByText("click me"));
expect(count.value).toBe(1);
});

My futile attempts to get bun test working with preact/compat

Dependencies on React need to be aliased to preact/compat:

bun add --dev react@npm:@preact/compat react-dom@npm:@preact/compat

Unfortunately, we get to an unfixable error. The specific error message is

TypeError: undefined is not an object (evaluating 't.__H')
note

JavaScript is infamous for its incomprehensible error messages. The problem is exacerbated by how Preact publishes minified code to npm. See https://github.com/preactjs/preact/issues/2233

The t.__H symbol comes from node_modules/preact/hooks/dist/hooks.js. This bug was reported many times to vitest and preact:

The workarounds mentioned in those issues are for vitest so needless to say, no solution has been found for bun test. I tried to use Bun's plugin system to implement a solution, however its plugin API is not well maintained.

vitest and happy-dom

vitest works with Preact. Setup is somewhat straightforward, but adding support for React dependencies can get ugly.

1. Install Dependencies

bun add -D vitest happy-dom

2. Alias react and react-dom Packages

Necessary if components depend on React.

bun add --dev react@npm:@preact/compat react-dom@npm:@preact/compat

3. Configure vite.config.ts

/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";
export default defineConfig({
plugins: [preact()],
test: {
environment: "happy-dom",

// Automatically cleanup `render` after each test.
// https://testing-library.com/docs/react-testing-library/setup/#auto-cleanup-in-vitest
globals: true,

deps: {
// workaround to make sure preact/compat alias works in dependencies.
// See https://github.com/vitest-dev/vitest/issues/5915#issuecomment-2179794149
optimizer: {
web: {
enabled: true,
include: [
// list packages which depend on React, e.g. component libraries
"@patternfly/react-core",
]
}
}
}
}
});

The test.deps.optimizer.web section is a workaround for the aforementioned challenges of testing browser JS outside of a browser. It bundles the specified dependencies so that they work properly.

Limitations

A key strength of Preact is that it uses browser-native events instead of React "synthetic events". I found that this discrepancy can prevent events (interactivity) from working with @testing-library/preact:

import "@testing-library/jest-dom";
import { expect, test } from "vitest";
import { fireEvent, render } from "@testing-library/preact";
import { ThemeSelect, themePreference } from "./ThemeSelect";

test("Can set dark theme", () => {
// ThemeSelect is my code which depends on a React-based component library
const { queryByText } = render(<ThemeSelect />);

// Sometimes, clicking works
fireEvent.click(container.firstChild);
expect(queryByText("Always use dark theme")).toBeVisible();

// However, sometimes nothing works
fireEvent.change(queryByText("Dark"));
fireEvent.click(queryByText("Dark"));
fireEvent.select(queryByText("Dark"));
fireEvent.submit(queryByText("Dark"));
fireEvent.mouseDown(queryByText("Dark"));
fireEvent.mouseUp(queryByText("Dark"));

// Works in browser, but not in vitest. "auto" !== "dark"
expect(themePreference.value).toBe("dark");
});

vitest browser mode

vitest-browser-preact is endorsed by both the Preact and Vitest teams. It is also simple to configure:

/// <reference types="vitest/config" />
import { defineConfig } from "vite";

export default defineConfig({
plugins: [preact()],
test: {
browser: {
enabled: true,
provider: 'playwright',
instances: [
{ browser: 'firefox' }
]
},
}
});

Everything works great, and it comes with a fancy GUI too. The downside is that continuous integration will run 200% slower plus an extra 1–2 minutes to setup the Playwright browsers.