DEV Community

RAXXO Studios
RAXXO Studios

Posted on • Originally published at raxxo.shop

Bun's Test Runner Replaced Vitest in My New Projects

  • 5-15x faster on real suites

  • Migration takes 30 minutes, almost drop-in

  • mock.module and spyOn with auto restore

  • Inline and file snapshots without plugins

  • Built-in coverage with CI threshold gates

  • Stay on Vitest only for browser mode

Most JavaScript test runners are slow because they were built on top of bundlers that were never meant to run tests. Vitest stacks on Vite, Jest stacks on Babel, and both pay a tax in cold start, transform pipelines, and worker spin-up. For years I accepted this. A 1500-test backend suite that takes 22 seconds locally and 48 seconds on CI felt normal. Then I moved a few projects over to Bun's built-in bun test runner and the numbers stopped feeling normal. The same suite finished in 3.1 seconds locally. CI dropped to 6 seconds. No config file. No plugin chain. The API is the same as Vitest, so the migration was almost a find-and-replace. This post is the honest comparison after running Bun test in production for several months across four backends, including the same stack I covered in Bun replaced Node in my new projects. Speed numbers, migration gotchas, four patterns I now use everywhere, and the cases where Vitest is still the right call.

The Speed Difference Is Real

I benchmarked one of my Hono backend stack test suites three ways. Same tests, same machine (M2 Pro, 16GB), same Postgres test container. The suite has 1487 tests across 142 files, mostly route handlers, Drizzle queries, and a few integration tests that hit a real database.


# Vitest 1.6 with default config
$ time vitest run
Test Files  142 passed (142)
     Tests  1487 passed (1487)
   Duration  21.84s

# Vitest 1.6 with threads pool tuned to 8
$ time vitest run --pool=threads --poolOptions.threads.maxThreads=8
   Duration  18.12s

# Bun test 1.2.4
$ time bun test
 1487 pass
   0 fail
 Ran 1487 tests across 142 files. [3.07s]

Enter fullscreen mode Exit fullscreen mode

That is a 7x local speedup with zero tuning. On GitHub Actions runners (ubuntu-latest, 2 vCPU), the gap widens because Vitest pays more cold start tax on slower CPUs. Vitest cold runs on CI took 48 seconds. Bun cold runs took 6.2 seconds. That is the difference between waiting for a check to finish and not noticing it ran at all.

The reason is not magic. Bun runs tests directly in its JavaScriptCore-based runtime with a fast TypeScript transpiler baked into the loader. There is no separate bundler step, no worker pool serializing test results over IPC, and no plugin pipeline running on every file. Files load and run. That is it.


# Cold start comparison (no cache, fresh clone)
$ hyperfine --warmup 0 --runs 3 \
  "vitest run --reporter=basic" \
  "bun test"

Benchmark 1: vitest run --reporter=basic
  Time (mean):     22.461 s
Benchmark 2: bun test
  Time (mean):      3.184 s
Summary: 'bun test' ran 7.05 times faster

Enter fullscreen mode Exit fullscreen mode

Parallel scaling is also better. Vitest hits diminishing returns past four worker threads on my machine because the IPC overhead eats the gains. Bun runs tests concurrently within a single process by default, with file-level parallelism handled by the runtime. On the same suite, doubling the test file count from 142 to 284 added only 1.4 seconds to the Bun run. Vitest added 18 seconds.

Migration: From Vitest to Bun in 30 Minutes

The Bun test API is intentionally compatible with Jest and Vitest. Same describe, test, expect, beforeEach, afterAll. Same matcher names. Same lifecycle hooks. The migration is mostly an import swap.


// Before: Vitest
import { describe, test, expect, beforeEach, vi } from "vitest";

// After: Bun test
import { describe, test, expect, beforeEach, mock, spyOn } from "bun:test";

Enter fullscreen mode Exit fullscreen mode

The four real changes I had to make on a 1487-test suite:


# 1. Replace vi.fn() with mock()
$ rg -l "vi\.fn\(" src/ | xargs sd "vi\.fn\(" "mock("

# 2. Replace vi.spyOn with spyOn (already imported)
$ rg -l "vi\.spyOn" src/ | xargs sd "vi\.spyOn" "spyOn"

# 3. Replace vi.mock with mock.module
$ rg -l "vi\.mock\(" src/ | xargs sd "vi\.mock\(" "mock.module("

# 4. Update package.json scripts
$ sd '"test": "vitest run"' '"test": "bun test"' package.json
$ sd '"test:watch": "vitest"' '"test:watch": "bun test --watch"' package.json

Enter fullscreen mode Exit fullscreen mode

Three gotchas worth flagging. First, Bun does not support vi.useFakeTimers() with the exact same API. It has setSystemTime on the Bun namespace, but if your tests lean heavily on advancing timers, that block will need rewrites. Second, ESM mocking inside the same file works differently. Vitest hoists vi.mock calls automatically, Bun's mock.module runs at call time, so you call it inside beforeAll or before the first import that needs the mocked module. Third, import.meta.vitest does not exist. If you write inline tests in source files (a Vitest pattern I never loved anyway), you cannot port them directly.


// Bun does not hoist mock.module, so order matters
import { describe, test, expect, mock, beforeAll } from "bun:test";

beforeAll(() => {
  mock.module("./payments", () => ({
    chargeCard: mock(() => ({ ok: true, id: "test_charge" })),
  }));
});

describe("checkout", () => {
  test("creates an order", async () => {
    const { checkout } = await import("./checkout");
    const result = await checkout({ amount: 25 });
    expect(result.amount).toBe(25);
  });
});

Enter fullscreen mode Exit fullscreen mode

Coverage configuration is now a flag rather than a config file. Every vitest.config.ts I deleted made the project simpler. One exception: if your project still uses Vite for the dev server, keep that config. Bun test does not need it, but your app does.

Pattern 1: Mocking and Spies the Bun Way

Mocking in Bun is built into the bun:test module and behaves the way I always wished Jest's mocks behaved: predictable, scoped, and automatically restored at the end of the file. There are three primitives I use every day.


import { describe, test, expect, mock, spyOn, afterEach } from "bun:test";
import * as logger from "./logger";

describe("order processor", () => {
  // 1. Inline mock function with call tracking
  test("retries on transient failure", async () => {
    const send = mock((payload: { id: string }) => {
      if (send.mock.calls.length === 1) throw new Error("flaky");
      return { ok: true, id: payload.id };
    });

    const result = await processOrder({ id: "ord_1" }, { send });
    expect(send).toHaveBeenCalledTimes(2);
    expect(result.ok).toBe(true);
  });

  // 2. Spy on a real method, restore automatically
  test("logs failures without breaking the flow", async () => {
    const errorSpy = spyOn(logger, "error").mockImplementation(() => {});
    await processOrder({ id: "ord_bad" }, { send: () => { throw new Error("nope"); } });
    expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("nope"));
  });
});

Enter fullscreen mode Exit fullscreen mode

The spyOn helper restores the original implementation when the test file finishes. No afterEach(() => vi.restoreAllMocks()) boilerplate. If you want manual control, every spy returns a handle with .mockRestore().

Module mocking with mock.module replaces a whole module's exports for the rest of the file. I use it for swapping out external API clients in route tests so I can assert the right shape was sent without hitting a real service.


import { describe, test, expect, mock, beforeAll } from "bun:test";

beforeAll(() => {
  mock.module("./shopify-client", () => ({
    createOrder: mock(async (input: { lineItems: unknown[] }) => ({
      id: "gid://shopify/Order/123",
      total: 25,
      currency: "EUR",
      lineItems: input.lineItems,
    })),
  }));
});

test("posting a checkout creates a Shopify order", async () => {
  const { app } = await import("./app");
  const res = await app.request("/checkout", {
    method: "POST",
    body: JSON.stringify({ items: [{ sku: "abc", qty: 1 }] }),
  });
  expect(res.status).toBe(200);
  const body = await res.json();
  expect(body.total).toBe(25);
});

Enter fullscreen mode Exit fullscreen mode

Three rules I learned the hard way. Always import the module under test with a dynamic import() after mock.module, otherwise the real module gets cached. Keep mocks shallow, return only the surface the test needs. Use mock.restore() in afterAll if you want a different file to see the real module.

Pattern 2: Snapshot Testing That Does Not Suck

Snapshots in Vitest were always a love-hate thing for me. Useful for catching unintended output drift, painful when every UI tweak rewrote 40 files. Bun's snapshot support is leaner. There are two flavors and both behave well.

Inline snapshots live next to the assertion. They are best for small payloads where the value is part of the test's documentation.


import { test, expect } from "bun:test";
import { formatInvoice } from "./invoice";

test("formatInvoice renders a clean text block", () => {
  const out = formatInvoice({
    customer: "Acme Ltd",
    items: [{ name: "Statusline Builder", price: 5 }],
    total: 5,
    currency: "EUR",
  });

  expect(out).toMatchInlineSnapshot(`
    "Invoice for Acme Ltd
    -------------------------
    Statusline Builder    5 EUR
    -------------------------
    Total: 5 EUR"
  `);
});

Enter fullscreen mode Exit fullscreen mode

File snapshots go to a sibling __snapshots__/ folder, same as Vitest. They are the right choice for larger structured payloads, like a serialized response or a generated SQL statement.


import { test, expect } from "bun:test";
import { buildSelectQuery } from "./query-builder";

test("complex select with joins serializes correctly", () => {
  const sql = buildSelectQuery({
    from: "orders",
    join: { table: "customers", on: "orders.customer_id = customers.id" },
    where: { "orders.status": "paid" },
    orderBy: ["orders.created_at desc"],
    limit: 50,
  });
  expect(sql).toMatchSnapshot();
});

Enter fullscreen mode Exit fullscreen mode

Update flow is a single flag.


# Update all snapshots in the suite
$ bun test --update-snapshots

# Update only one file's snapshots
$ bun test src/billing/invoice.test.ts --update-snapshots

Enter fullscreen mode Exit fullscreen mode

The thing I appreciate most: the diff output is readable. Vitest sometimes produced 200-line diffs full of escape characters for what was effectively a one-character change. Bun's diff is colorized, line-aware, and points to the exact key that drifted. I review snapshot changes in PRs now without fighting the format.

A guideline I follow: never snapshot anything with a timestamp, UUID, or sort-unstable map. Either strip the volatile fields or use a custom serializer. Brittle snapshots are worse than no snapshots.

Pattern 3: CI Integration with Coverage Gates

The last piece that pushed me fully off Vitest was coverage. Vitest's coverage stack uses c8 or istanbul plugins, both of which slow down runs significantly. Bun has coverage built into the runtime, so the overhead is minimal. On the same 1487-test suite, coverage adds 0.4 seconds in Bun versus 11 seconds in Vitest with c8.


# Local coverage report
$ bun test --coverage

----------------|---------|---------|-------------------
File            | % Funcs | % Lines | Uncovered Line #s
----------------|---------|---------|-------------------
All files       |   94.21 |   91.85 |
 src/billing.ts |  100.00 |   97.30 | 84
 src/checkout.ts|   90.00 |   88.46 | 22-24, 51
 src/orders.ts  |   95.00 |   93.10 | 17, 102
----------------|---------|---------|-------------------

# Enforce thresholds (Bun 1.2+)
$ bun test --coverage --coverage-threshold=0.85

Enter fullscreen mode Exit fullscreen mode

For CI, I use a single GitHub Actions workflow that runs the test suite with coverage on every PR. The threshold check fails the build if line coverage drops below 85 percent. I also upload the LCOV report so I can see drift across commits in Codecov.


# .github/workflows/test.yml
name: test
on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 3s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: 1.2.4
      - run: bun install --frozen-lockfile
      - run: bun test --coverage --coverage-reporter=lcov --coverage-threshold=0.85
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/postgres
      - uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info

Enter fullscreen mode Exit fullscreen mode

The full job, including container startup, finishes in about 35 seconds. The Vitest version of the same job took 2 minutes 40 seconds. Multiplied across the daily PR volume on a real project, that is hours of runner time saved per week. If you also use Drizzle, the 8 Drizzle patterns post pairs well with this CI setup, since most of the threshold gates I write target query helpers.

A small habit that pays off: store the coverage summary as a build artifact. When a PR drops coverage by 4 percent, I want the diff visible in the check, not buried in logs.

Bottom Line

Move to Bun test if you fit any of these: your suite is mostly pure logic and route handlers, your CI bill is annoying you, your suite is slow enough that you avoid running it locally, or you already use Bun as your runtime. The migration is a few hours and the speed gain is permanent.

Stay on Vitest if you need any of these: browser mode for component testing with real DOM (Bun has no equivalent yet), the Vite plugin ecosystem for transforming custom file types, or a heavy dependency on Vitest's UI runner. Vitest also still has more refined error formatting for some matcher failures, and its concurrent mode handles flaky integration tests slightly more gracefully.

Things Bun test still does not do well as of 1.2.4: parallel test isolation across files for tests that share global state (you have to design around it), sophisticated test sharding for very large monorepos (basic sharding works, but Vitest's setup is more mature), and visual regression testing through plugins. If you need any of those, the answer is Vitest, no debate.

For everything else, Bun test is the runner I now reach for by default. Zero config, faster feedback, and one less moving part in the stack. The fact that the API is compatible with Jest means I am not betting the project on a fork that could diverge. If Bun ever stops being the right call, I move two import lines and I am back on Vitest the same day.

If you are weighing a broader stack consolidation, the Lab overview collects every backend post in one place, including the runtime, framework, ORM, and now the test runner that quietly replaced the slowest part of my workflow.

Top comments (0)