Bun Test Runner: Fast JavaScript Testing Without Configuration
Jest is fast enough — until it isn't. Once your test suite crosses a few hundred tests, you start feeling it: the five-second startup, the transform overhead, the config file that has evolved into something no one fully understands. Bun's built-in test runner is a direct answer to that friction. It runs in the same process as your code, skips the Node.js transform layer entirely, and requires zero configuration to start. This guide covers everything from first run to CI integration, including the honest list of things Bun test still can't do.
What Bun's Built-In Test Runner Is and Why It's Fast
Bun is a JavaScript runtime, bundler, package manager, and test runner rolled into a single binary. The test runner is not a separate package you install — it ships with Bun and uses the same V8-replacing JavaScriptCore engine that powers the rest of Bun.
The speed advantage comes from a few architectural decisions:
No transpile step at runtime. Bun natively understands TypeScript, JSX, and ES modules. When you write a test in TypeScript, Bun parses and runs it directly without invoking ts-jest, babel-jest, or any transform pipeline. That alone eliminates 40–60% of Jest's startup time on most projects.
Native test runner, not a Node.js package. Jest's runner is JavaScript running on Node.js. Bun's runner is implemented in Zig and runs as native code. Test collection, filtering, and assertion checking all happen closer to the metal.
Parallel execution by default. Each test file runs in its own worker thread. There is no opt-in flag — it is the default.
On a MacBook Pro with a 300-test suite, bun test routinely finishes in under 400ms where Jest takes 4–8 seconds. On large monorepos, the gap is even wider.
Setting Up bun test (No Config Needed)
Install Bun if you haven't:
curl -fsSL https://bun.sh/install | bashThat's the entire setup. There is no jest.config.js, no babel.config.json, no @types/jest to install. Bun discovers test files automatically using these patterns:
**/*.test.ts**/*.test.js**/*.spec.ts**/*.spec.js**/_tests_/**
Run your tests:
bun testIf you want to limit discovery to a specific directory:
bun test src/TypeScript works out of the box. A file named user.test.ts runs without any additional tooling.
Writing Tests: describe, it, expect
Bun's API is a superset of Jest's. If you already know Jest, you already know Bun test. The same describe, it, test, expect, beforeEach, afterEach, beforeAll, and afterAll globals are available with identical signatures.
import { describe, it, expect, beforeEach } from "bun:test";
interface User {
id: string;
email: string;
role: "admin" | "viewer";
}
function canDeletePost(user: User): boolean {
return user.role === "admin";
}
describe("canDeletePost", () => {
let adminUser: User;
let viewerUser: User;
beforeEach(() => {
adminUser = { id: "1", email: "admin@example.com", role: "admin" };
viewerUser = { id: "2", email: "viewer@example.com", role: "viewer" };
});
it("returns true for admin users", () => {
expect(canDeletePost(adminUser)).toBe(true);
});
it("returns false for viewer users", () => {
expect(canDeletePost(viewerUser)).toBe(false);
});
it("throws when user object is missing role", () => {
// @ts-expect-error — intentional bad input
expect(() => canDeletePost({ id: "3", email: "x@x.com" })).not.toThrow();
});
});You can import from "bun:test" or use the globals directly — both work. The explicit import is recommended for editor autocompletion and to make test dependencies explicit.
All standard Jest matchers are supported: toBe, toEqual, toStrictEqual, toContain, toHaveLength, toThrow, toBeNull, toBeTruthy, toBeGreaterThan, and so on.
Mocking with mock() and spyOn()
Bun provides mock() for creating standalone mock functions and spyOn() for intercepting calls on existing objects. Both are imported from "bun:test".
Standalone mocks:
import { describe, it, expect, mock } from "bun:test";
const fetchUser = mock(async (id: string) => ({
id,
name: "Test User",
email: "test@example.com",
}));
describe("fetchUser mock", () => {
it("returns the mocked user", async () => {
const user = await fetchUser("abc-123");
expect(user.name).toBe("Test User");
expect(fetchUser).toHaveBeenCalledWith("abc-123");
expect(fetchUser).toHaveBeenCalledTimes(1);
});
});Module mocking:
import { mock, describe, it, expect } from "bun:test";
// Mock an entire module before importing it
mock.module("../lib/email", () => ({
sendEmail: mock(() => Promise.resolve({ messageId: "mock-id" })),
}));
import { sendEmail } from "../lib/email";
describe("email module mock", () => {
it("calls sendEmail with the right arguments", async () => {
await sendEmail("user@example.com", "Welcome", "Hello there");
expect(sendEmail).toHaveBeenCalledWith(
"user@example.com",
"Welcome",
"Hello there"
);
});
});spyOn for existing objects:
import { describe, it, expect, spyOn, afterEach } from "bun:test";
const logger = {
info: (msg: string) => console.log(`[INFO] ${msg}`),
error: (msg: string) => console.error(`[ERROR] ${msg}`),
};
describe("logger spy", () => {
afterEach(() => {
// Restore original implementations after each test
jest.restoreAllMocks(); // or use the spy's .mockRestore()
});
it("logs the correct message", () => {
const spy = spyOn(logger, "info");
logger.info("deployment started");
expect(spy).toHaveBeenCalledWith("deployment started");
});
it("does not call error for successful operations", () => {
const errorSpy = spyOn(logger, "error");
logger.info("all good");
expect(errorSpy).not.toHaveBeenCalled();
});
});One note: mock.module() must be called before the module is imported. Bun hoists these calls automatically when you use the mock.module pattern at the top level of a test file.
Snapshot Testing with toMatchSnapshot()
Snapshot testing works exactly like Jest. On first run, Bun creates a snapshot file. Subsequent runs compare output against the saved snapshot.
import { describe, it, expect } from "bun:test";
function formatApiError(code: number, message: string) {
return {
error: {
code,
message,
timestamp: "2026-01-01T00:00:00.000Z", // fixed for testing
docs: `https://api.example.com/errors/${code}`,
},
};
}
describe("formatApiError", () => {
it("matches snapshot for 404 errors", () => {
const result = formatApiError(404, "Resource not found");
expect(result).toMatchSnapshot();
});
it("matches snapshot for 500 errors", () => {
const result = formatApiError(500, "Internal server error");
expect(result).toMatchSnapshot();
});
});Snapshots are stored in a __snapshots__ directory next to your test file. Update them when intentional changes are made:
bun test --update-snapshotsRunning Specific Tests and Watch Mode
Filter tests by name using -t (or --test-name-pattern):
# Run only tests whose name contains "auth"
bun <span class="hljs-built_in">test -t <span class="hljs-string">"auth"
<span class="hljs-comment"># Run only tests in a specific file
bun <span class="hljs-built_in">test src/auth.test.ts
<span class="hljs-comment"># Run tests matching a pattern in a specific directory
bun <span class="hljs-built_in">test src/ -t <span class="hljs-string">"login"Watch mode re-runs relevant tests whenever a file changes:
bun test --watchBun's watch mode is smarter than Jest's — it only re-runs test files that import the changed module, not the entire suite. On large codebases this makes a significant difference.
For CI environments where you want a clear pass/fail without interactivity, the default non-watch mode is already appropriate. No --ci flag needed.
Code Coverage with bun test --coverage
Bun has built-in code coverage. No c8, no nyc, no additional packages:
bun test --coverageOutput looks like this:
src/auth.ts | 94.12% | 94.44% | 100% |
src/user.ts | 87.50% | 83.33% | 75.00% |
src/payments.ts | 61.29% | 55.00% | 50.00% |Set a minimum coverage threshold to fail the build if coverage drops:
bun test --coverage --coverage-threshold 80You can also configure coverage in package.json:
{
"scripts": {
"test": "bun test",
"test:coverage": "bun test --coverage --coverage-threshold 80"
}
}Coverage reports are generated as text by default. For HTML output (useful for local inspection), Bun is adding --coverage-reporter html — check the latest docs for availability, as this feature landed mid-2025.
Migrating from Jest to bun test
For most projects, migration is straightforward. The API surface is intentionally compatible.
Step 1 — Remove Jest dependencies:
bun remove jest @types/jest ts-jest babel-jest jest-environment-jsdomStep 2 — Delete Jest config files:
Remove jest.config.js, jest.config.ts, or the jest key from package.json. Bun needs none of it.
Step 3 — Update imports (optional but recommended):
Change implicit globals to explicit imports:
// Before (Jest globals)
describe("suite", () => { ... });
// After (explicit Bun imports)
import { describe, it, expect } from "bun:test";
describe("suite", () => { ... });This step is optional — Bun injects globals just like Jest does. But explicit imports give you better IDE support and make the dependency graph clearer.
Step 4 — Handle Jest-specific modules:
A few Jest-specific APIs don't exist in Bun:
jest.setTimeout()→ usesetDefaultTimeout()from"bun:test"jest.useFakeTimers()→ Bun hassetSystemTime()for date mocking, but full fake timer support is limited (see Limitations section)jest.isolateModules()→ not availablejest.requireActual()→ useimport.meta.require()patterns
For most application codebases (not testing framework code itself), Steps 1–3 are sufficient and everything just works.
CI Integration with Bun
GitHub Actions example:
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run tests
run: bun test --coverage --coverage-threshold 80The oven-sh/setup-bun action handles Bun installation and caching. The --frozen-lockfile flag ensures the lockfile is not modified in CI, equivalent to npm ci.
For monorepos, run tests across workspaces:
bun test --filter <span class="hljs-string">"./packages/*"Or in CI, matrix across packages:
strategy:
matrix:
package: [auth, payments, api]
steps:
- run: bun test packages/${{ matrix.package }}Limitations vs Jest
Bun's test runner is production-ready for most use cases, but there are genuine gaps:
Fake timers are incomplete. jest.useFakeTimers() with advanceTimersByTime() is not fully implemented. You can use setSystemTime() to mock Date.now() and new Date(), but controlling setTimeout/setInterval scheduling is limited. Code that depends on timer-based async behavior may not be testable without workarounds.
No jest.mock() auto-mocking. Jest's automatic mock generation (jest.mock("module") with no factory) creates a mock version of every export automatically. Bun requires you to provide explicit factory functions with mock.module().
DOM testing requires setup. Jest ships with jsdom integration via jest-environment-jsdom. Bun does not have a built-in DOM environment. For React component testing, you need to set testEnvironment via a workaround or use happy-dom manually. This is the most significant gap for frontend-heavy projects.
Snapshot serializers. Custom snapshot serializers (used by @testing-library/jest-dom and similar) may not be compatible without adaptation.
--testEnvironment flag. Jest supports --testEnvironment node|jsdom|... to switch runtime contexts per file. Bun doesn't have an equivalent. All tests run in the same Bun environment.
For projects that test React components with React Testing Library, Jest + jsdom is still the more complete solution. For server-side Node.js code, API handlers, utilities, and business logic, Bun is ready and the speed improvement is real.
E2E Coverage That Unit Tests Can't Provide
Bun test runner excels at what it does — fast, zero-config unit and integration testing for server-side JavaScript. But unit tests don't catch the class of bugs that only appear when a real browser interacts with your real application: broken auth flows, UI states that only appear under specific conditions, form submissions that fail silently, third-party scripts that block rendering.
This is where HelpMeTest picks up. It uses Robot Framework and Playwright under the hood, but the interface is plain English — describe what a user does, and HelpMeTest generates and runs the browser automation. No Playwright setup, no selector maintenance, no CI configuration for browser tests.
A realistic workflow: Bun test covers your TypeScript logic, pure functions, API handlers, and service layers. HelpMeTest covers the user-facing flows — login, checkout, settings changes, error messages — running on a schedule with alerts if something breaks.
The free plan includes 10 tests running 24/7. The Pro plan is $100/month for unlimited tests.
If you ship server-side code and want both layers covered — fast unit tests in Bun, continuous browser monitoring in HelpMeTest — the two tools complement each other without overlapping.
Bun's test runner is not a replacement for Jest in every situation, but for the majority of server-side JavaScript and TypeScript projects it is faster, simpler, and ready to use today. Run bun test in your existing Jest project — there is a reasonable chance it just works.