Skip to content

Fix: Bun Test Not Working — Module Mocking, DOM Setup, Coverage, and Watch Mode

FixDevs ·

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, localStorage don’t exist. To test browser code you need to set up happy-dom (or jsdom) explicitly.
  • mock.module is 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 analog jest.mock had similar issues but with file-level scoping.
  • Coverage is line-based and excludes some files by default. Bundled deps, .d.ts files, 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-registrator

Create 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:

  1. Are they actually imported by a test? Coverage tracks executed code. A util that’s never imported in any test file shows 0%.
  2. Are they excluded by include/exclude globs? By default Bun covers all .ts/.tsx/.js/.jsx under 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-snapshots

Snapshots 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:

  1. tsconfig.json compilerOptions.paths is set:
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}
  1. The test file is included in tsconfig.json. Some setups exclude *.test.ts. Either include them or use a separate tsconfig.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 grep or 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. --watch watches the cwd. If your test file imports ../../shared/util.ts, edits to shared/util.ts should 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() → use mock() from bun:test. The Bun mock has the same API.
  • jest.mock("./mod", factory) → use mock.module("./mod", factory). Same hoisting behavior.
  • jest.spyOn(obj, "method") → use spyOn(obj, "method") from bun: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 test runs 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".
  • __dirname is undefined in ESM tests. Bun supports both, but in pure ESM you need import.meta.dir instead of __dirname.
  • Module resolution differs from production. bun test uses the same resolver as bun run, but if you have package conditions, check "bun" vs "node" vs "import" conditions in package.json exports.
  • fetch mocks don’t work. Bun has a global fetch that’s not the Node implementation. Use mock.module("node:fetch", ...) if your code imports it explicitly, or override globalThis.fetch in beforeEach.
  • Async test never finishes. A pending promise (unhandled await on an external service) holds the test open. Set a timeout: test("...", async () => {...}, 5000); and audit for missing await.
  • expect(promise).resolves.toBe(...) doesn’t reject the test. Make sure to return or await the expect: await expect(promise).resolves.toBe(...).
  • TypeScript decorator errors in tests. Bun supports decorators but needs "experimentalDecorators": true (or the new TC39 decorators flag) in tsconfig.json.
  • Cannot find module 'bun:test' in your editor. Add "types": ["bun-types"] to tsconfig.json and bun 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles