Fix: Bun Test Not Working — Module Mocking, DOM Setup, Coverage, and Watch Mode
Quick Answer
How to fix Bun test runner issues — mock.module not isolating, happy-dom setup for DOM tests, --coverage missing files, timer mocks, snapshot updates, TypeScript path aliases, and preload files.
The Error
You run bun test and your DOM tests fail because window is undefined:
ReferenceError: window is not defined
at Object.<anonymous> (src/components/Button.test.ts:5:1)Or a mock.module from one test leaks into another:
// a.test.ts
mock.module("./db", () => ({ getUser: () => fakeUser }));
// b.test.ts (runs later)
import { getUser } from "./db";
console.log(getUser()); // Returns fakeUser, not the real getUser!Or bun test --coverage shows 0% for files you know are imported:
File | % Funcs | % Lines | Uncovered Line #s
---------------------------------------------------
src/util.ts | 0.00 | 0.00 |Or bun --watch test doesn’t re-run tests when you edit a shared util:
[bun test] Watching for changes...
# You edit src/util.ts.
# Nothing happens.Why This Happens
Bun’s test runner ships with the runtime — bun test is a built-in command, not a separate package. It implements most of the Jest API (describe, test, expect, mock, snapshots) but has its own quirks because the underlying runtime is Bun, not Node.
Three sources of friction:
- No DOM by default. Bun is a server-side runtime.
document,window,localStoragedon’t exist. To test browser code you need to set uphappy-dom(orjsdom) explicitly. mock.moduleis hoisted and global. Calling it inside a test file replaces the module for the whole process. If another test imports the same module after, it sees the mock. The Jest analogjest.mockhad similar issues but with file-level scoping.- Coverage is line-based and excludes some files by default. Bundled deps,
.d.tsfiles, and untouched imports may be missing from the report.
Fix 1: Set Up happy-dom for DOM Tests
Install happy-dom and a preload file that installs globals:
bun add -d happy-dom @happy-dom/global-registratorCreate tests/setup.ts:
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();Tell Bun to preload it via bunfig.toml:
# bunfig.toml
[test]
preload = ["./tests/setup.ts"]Now window, document, and friends exist in every test file:
import { describe, test, expect } from "bun:test";
describe("Button", () => {
test("renders text", () => {
document.body.innerHTML = `<button>Hi</button>`;
expect(document.querySelector("button")?.textContent).toBe("Hi");
});
});Pro Tip: For React component tests, also install @testing-library/react and @testing-library/dom. They work the same on Bun as on Node.
For tests that don’t need DOM, prefer a file pattern split — put *.dom.test.ts files in a directory that loads the preload, and keep server-side *.test.ts files in a separate path that doesn’t. Avoids pulling happy-dom into pure-Node tests.
Fix 2: Isolate mock.module Per Test
mock.module mutates the module registry globally. To clean up between tests, save the return value and restore in afterEach:
import { afterEach, beforeEach, describe, test, expect, mock } from "bun:test";
describe("auth", () => {
beforeEach(() => {
mock.module("./db", () => ({
getUser: () => ({ id: 1, name: "Alice" }),
}));
});
afterEach(() => {
mock.restore(); // Restores all mocks set via mock.module since the last restore.
});
test("logs the user", () => {
// ...
});
});mock.restore() reverts every mock.module and mock(...) call since the last restore. Without it, mocks from earlier tests bleed into later ones.
Common Mistake: Calling mock.module at the top of a test file outside any hook. Bun hoists the call, the mock is installed before any test runs, and there’s no automatic teardown. Either accept the global behavior (and don’t mock that module differently elsewhere) or move it into beforeEach.
For function-level mocks, use mock:
import { mock } from "bun:test";
const fetcher = mock(() => Promise.resolve("data"));
await fetcher();
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher).toHaveBeenLastCalledWith();mock returns a Jest-compatible mock function — toHaveBeenCalled, mockReturnValueOnce, etc. all work.
Fix 3: Fake Timers and System Time
For tests that depend on Date.now or setTimeout:
import { beforeEach, afterEach, test, expect, mock } from "bun:test";
beforeEach(() => {
// Freeze the clock at a specific date.
mock.module("./time", () => ({
now: () => new Date("2026-01-01T00:00:00Z").getTime(),
}));
});For setTimeout / setInterval, Bun has Bun.sleep and an advancing-time helper. As of recent Bun versions, you can also use:
import { test } from "bun:test";
test("after one minute", async () => {
const start = Bun.nanoseconds();
// Bun doesn't yet have a full fake-timer API like Jest.
// For now, mock the function under test to inject a clock.
});If your code reads Date.now() directly, refactor to inject a clock function — testable with mock cleanly and avoids depending on a runtime fake-timer API that may still be evolving.
Note: If you’ve been using Jest’s jest.useFakeTimers() heavily, expect to refactor some tests when moving to Bun. Bun’s fake-timer support is improving but still narrower than Jest’s.
Fix 4: Coverage With --coverage and Includes
bun test --coverage prints a table by default. Configure thresholds and includes in bunfig.toml:
[test]
coverage = true
coverageThreshold = 0.80 # Fail if below 80%.
coverageSkipTestFiles = true # Don't count test files themselves.
coverageReporter = ["text", "lcov"]
coverageDir = "./coverage"If files you expect aren’t appearing, check two things:
- Are they actually imported by a test? Coverage tracks executed code. A util that’s never imported in any test file shows 0%.
- Are they excluded by
include/excludeglobs? By default Bun covers all.ts/.tsx/.js/.jsxunder your project root. Override:
[test]
coveragePathIgnorePatterns = ["node_modules", "build", "**/*.d.ts"]For CI, set the reporter to lcov in bunfig.toml and run with --coverage:
[test]
coverageReporter = ["lcov"]bun test --coverage
# coverage/lcov.info is now ready to upload.Fix 5: Snapshot Tests
expect(value).toMatchSnapshot() works the same as Jest, including inline snapshots:
test("renders correctly", () => {
const rendered = render(<Button />);
expect(rendered.toString()).toMatchSnapshot();
});
test("inline snapshot", () => {
expect({ a: 1, b: 2 }).toMatchInlineSnapshot(`
{
"a": 1,
"b": 2,
}
`);
});Update snapshots when intentional changes break them:
bun test --update-snapshotsSnapshots live in __snapshots__/ next to the test file. Commit them — they’re the contract.
Common Mistake: Snapshotting objects with non-deterministic fields (timestamps, IDs, random values). Filter or normalize before snapshotting:
const { id, createdAt, ...stable } = result;
expect(stable).toMatchSnapshot();Fix 6: TypeScript Path Aliases (@/...)
Bun reads tsconfig.json paths natively — no separate config needed. If @/foo works in your runtime but not in tests, two things to check:
tsconfig.jsoncompilerOptions.pathsis set:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}- The test file is included in
tsconfig.json. Some setups exclude*.test.ts. Either include them or use a separatetsconfig.test.json.
If your editor errors on @/foo but bun test resolves it (or vice versa), the issue is path config mismatch between the editor’s TypeScript Language Server and Bun’s resolver.
Fix 7: Watch Mode and File Detection
bun test --watch re-runs on changes — but only files Bun considers part of the dependency graph. If editing a util doesn’t trigger a re-run:
- The util isn’t imported by any test. Check with
grepor your editor’s “find references.” If it’s imported lazily (await import(...)), Bun may not include it in the static graph. - You’re outside the working directory.
--watchwatches the cwd. If your test file imports../../shared/util.ts, edits toshared/util.tsshould still trigger a re-run — but only if the path resolves within Bun’s watcher root.
For monorepos, run the watcher at the workspace root rather than per-package, or run bun test --watch per package with separate watchers in each terminal.
Fix 8: bun:test vs Jest API Compatibility
Most Jest APIs work on Bun. Some don’t, or have differences:
jest.fn()→ usemock()frombun:test. The Bun mock has the same API.jest.mock("./mod", factory)→ usemock.module("./mod", factory). Same hoisting behavior.jest.spyOn(obj, "method")→ usespyOn(obj, "method")frombun:test.beforeAll,afterAll,beforeEach,afterEach,describe,it,test— all work, imported from"bun:test".jest.useFakeTimers()— partial support; see Fix 3.jest.requireActual("./mod")— no exact equivalent. Capture the real module before mocking and reuse the reference.
import * as realDb from "./db";
const actualGetUser = realDb.getUser;
mock.module("./db", () => ({
getUser: () => ({ ...actualGetUser(), patched: true }),
}));For projects considering migration from Jest, run bun test first and fix the API differences case-by-case — most code compiles unchanged.
Still Not Working?
A few less-obvious failures:
bun testruns but no tests are found. Bun looks for*.test.{js,ts,tsx,jsx}and*.spec.{js,ts,tsx,jsx}by default. Rename or pass a glob:bun test "**/__tests__/*.ts".__dirnameis undefined in ESM tests. Bun supports both, but in pure ESM you needimport.meta.dirinstead of__dirname.- Module resolution differs from production.
bun testuses the same resolver asbun run, but if you have package conditions, check"bun"vs"node"vs"import"conditions inpackage.jsonexports. fetchmocks don’t work. Bun has a globalfetchthat’s not the Node implementation. Usemock.module("node:fetch", ...)if your code imports it explicitly, or overrideglobalThis.fetchinbeforeEach.- Async test never finishes. A pending promise (unhandled
awaiton an external service) holds the test open. Set a timeout:test("...", async () => {...}, 5000);and audit for missingawait. expect(promise).resolves.toBe(...)doesn’t reject the test. Make sure toreturnorawaitthe expect:await expect(promise).resolves.toBe(...).- TypeScript decorator errors in tests. Bun supports decorators but needs
"experimentalDecorators": true(or the new TC39 decorators flag) intsconfig.json. Cannot find module 'bun:test'in your editor. Add"types": ["bun-types"]totsconfig.jsonandbun add -d bun-types.
For related testing and Bun runtime issues, see Bun not working, Jest async test timeout, Jest mock not working, and Vitest setup not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Node.js Test Runner Not Working — node --test, TypeScript, Mocks, Coverage, and Watch Mode
How to fix Node.js built-in test runner errors — node --test not finding files, ESM vs CJS imports, TypeScript with --experimental-strip-types, mock.method isolation, coverage reporting, and watch mode setup.
Fix: Jest Setup File Not Working — setupFilesAfterFramework Not Running or Globals Not Applied
How to fix Jest setup file issues — setupFilesAfterFramework vs setupFiles, global mocks not applying, @testing-library/jest-dom matchers, module mocking in setup, and TypeScript setup files.
Fix: Jest Async Test Timeout — Exceeded 5000ms or Test Never Resolves
How to fix Jest async test timeouts — missing await, unresolved Promises, done callback misuse, global timeout configuration, fake timers, and async setup/teardown issues.
Fix: Jest Coverage Not Collected — Files Missing from Coverage Report
How to fix Jest coverage not collecting all files — collectCoverageFrom config, coverage thresholds, Istanbul ignore comments, ts-jest setup, and Babel transform issues.