Bun Test Deep Dive: Parallel Execution, Isolation, Sharding, and Smart Change Detection

Bun's built-in test runner has rapidly evolved from a fast alternative to a full-featured, production-grade testing solution. Starting from its initial Jest-compatible API, bun test now includes parallelization, per-file isolation, CI sharding, and intelligent change detection — all without installing a single dependency.

This article goes deep into how these features work, how to use them effectively, and why they matter for teams with hundreds or thousands of test files.

The Evolution of bun test

When Bun first launched its test runner, it focused on one thing: speed. Zero-installation, ESM-first, and significantly faster than jest — it addressed the most painful part of JavaScript testing: waiting.

But speed alone is not enough at scale. Large test suites face three fundamental challenges:

┌─────────────────────────────────────────────────────────┐
│             Large-Scale Testing Challenges              │
├─────────────────────────────────────────────────────────┤
│                                                         │
│   1. Test Interference                                  │
│      Tests pollute global state, causing flaky failures │
│      when run together but pass individually            │
│                                                         │
│   2. Long CI Times                                      │
│      Sequential test execution becomes a bottleneck     │
│      as test suites grow over months and years          │
│                                                         │
│   3. Developer Feedback Loop                            │
│      Running all tests on every change wastes time      │
│      and slows down the edit-test-debug cycle           │
│                                                         │
└─────────────────────────────────────────────────────────┘

Bun v1.3.13 addresses all three with four new flags: --parallel, --isolate, --shard, and --changed. Let's examine each one.

--isolate: Per-File Test Isolation

The --isolate flag runs each test file in a fresh global environment within the same process. Here's what Bun does between each test file:

  • Drains all pending microtasks
  • Closes all open sockets
  • Cancels all timers
  • Kills any lingering subprocesses
  • Creates a clean global object

The key innovation is that Bun maintains a VM-level transpilation cache. Shared dependencies are parsed only once, and subsequent test files reuse the cached source — skipping redundant transpilation entirely.

// tests/setup-teardown.test.ts
// This test modifies global state
(globalThis as any).__MOCK_DB_URL = "sqlite://test.db";

// Without --isolate, the next test file might see this pollution
// With --isolate, the global is fully reset between files

When to Use --isolate

--isolate should be your default for any non-trivial project:

# Run all tests with isolation (default in many CI setups)
bun test --isolate

It eliminates the most common category of flaky tests: files that interfere through global state, module-level side effects, or singleton instances that persist between test files.

--parallel: Multi-Worker Test Distribution

The --parallel[=N] flag distributes test files across N worker processes (defaults to CPU count). Bun's implementation is notably sophisticated:

┌──────────────────────────────────────────────────────────┐
│              Bun Parallel Architecture                   │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  Main Process                                            │
│  ┌─────────────────┐                                     │
│  │  Work Partition │────── cache locality grouping       │
│  │  for Locality   │                                     │
│  └────────┬────────┘                                     │
│           │                                              │
│           ▼                                              │
│  ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐                        │
│  │ Wk1 │ │ Wk2 │ │ Wk3 │ │ Wk4 │  Worker Pool           │
│  │ ███ │ │ ██  │ │ █   │ │     │                        │
│  └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘                        │
│     │       │       │       │                            │
│     ▼       ▼       ▼       ▼                            │
│  ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐                        │
│  │idle │ │steal│ │busy │ │done │  Work-Stealing          │
│  │  →  │ │  ←  │ │  →  │ │     │                        │
│  └─────┘ └─────┘ └─────┘ └─────┘                        │
│                                                          │
│  Output: Buffered, atomic flush — no interleaving        │
└──────────────────────────────────────────────────────────┘

File Partitioning and Work-Stealing

Bun doesn't just distribute files randomly. It partitions files for cache locality — files that share transpilation caches are grouped on the same worker. When a worker finishes its queue, it steals work from the busiest remaining queue, keeping all cores utilized efficiently.

Atomic Output Buffering

One major pain point with parallelized test runners is garbled console output. Bun solves this by buffering per-test console.log and console.error output, then flushing it atomically when each test completes. The output looks identical to serial execution:

# The output is clean and file-by-file, never interleaved
$ bun test --parallel

src/utils/math.test.ts:
(pass) add [1] returns correct sum
(pass) multiply [3] handles edge cases

src/api/users.test.ts:
(pass) getUser returns user object
(pass) createUser validates required fields
(pass) deleteUser soft-deletes correctly

Practical Usage

# Run tests in parallel across all CPU cores (default)
bun test --parallel

# Limit to 8 workers regardless of CPU count
bun test --parallel=8

# Combine with isolation for maximum test reliability
bun test --parallel --isolate

Both flags compose with existing options: --bail, --randomize, --dots, JUnit XML reporting, LCOV coverage, and inline snapshots. All transpiler/resolver flags (--define, --loader, --tsconfig-override, --conditions) are forwarded to workers automatically.

--shard: Distributed CI Test Execution

The --shard=M/N flag splits test files across multiple CI runners, matching the syntax used by Jest, Vitest, and Playwright.

# In a GitHub Actions matrix with 3 runners:
# Runner 1: bun test --shard=1/3
# Runner 2: bun test --shard=2/3
# Runner 3: bun test --shard=3/3

How Sharding Works

Test files are sorted by path for determinism and then distributed round-robin across shards. Each shard stays balanced to within one file of every other shard:

10 test files distributed across 3 shards:

Shard 1/3: f01.test.ts, f04.test.ts, f07.test.ts, f10.test.ts  (4 files)
Shard 2/3: f02.test.ts, f05.test.ts, f08.test.ts              (3 files)
Shard 3/3: f03.test.ts, f06.test.ts, f09.test.ts              (3 files)

The shard index is 1-based (1 <= index <= count), consistent with industry standards. Invalid inputs produce clear error messages:

Error: --shard=0/3: shard index must be between 1 and 3
Error: --shard=4/3: shard index must be between 1 and 3
Error: --shard=1/0: shard count must be positive

If a shard has zero files (e.g., 2 test files with --shard=5/5), it exits 0 gracefully rather than erroring with "No tests found!" — crucial for robust CI pipelines.

Composition with Other Flags

Sharding composes naturally:

  • --changed — sharding applies after the changed-files filter
  • --randomize — shuffle happens after shard selection, within the shard
# Only run changed tests, split across 3 CI runners
bun test --changed --shard=2/3

--changed: Smart Test Filtering

The --changed flag is perhaps the most impactful feature for developer productivity. It only runs test files affected by your git changes by building the full import graph of your test files and filtering down to those that transitively depend on a file git reports as changed.

How It Works

┌─────────────────────────────────────────────────────────────┐
│                  --changed Detection Flow                   │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. Query git for changed files (unstaged + staged +         │
│     untracked, or since a specific ref)                     │
│                                                             │
│  2. Build import graph of all test files                    │
│     - Scans imports without entering node_modules            │
│     - No linking or code emission                            │
│     - Minimal overhead                                      │
│                                                             │
│  3. Filter: keep only test files that transitively           │
│     depend on changed files                                 │
│                                                             │
│  4. Run filtered test subset                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Real-World Usage

# Run tests affected by uncommitted changes
bun test --changed

# Run tests affected by changes since main branch
bun test --changed=main

# Check against a specific commit
bun test --changed=a1b2c3d

# The killer combo: --changed + --watch
bun test --changed --watch
# Each file save re-queries git and only runs relevant tests

When combined with --watch, it creates an extremely efficient feedback loop. If you're editing src/auth/login.ts and only tests/auth/login.test.ts and tests/api/session.test.ts import it (directly or transitively), only those two test files run — even if your project has 500 test files.

Environment Variables in Parallel Mode

When running bun test --parallel, each worker receives:

  • JEST_WORKER_ID — for compatibility with Jest-aware libraries
  • BUN_TEST_WORKER_ID — Bun-specific worker identification

This makes it trivial to give each worker its own test database, port, or temp directory:

// tests/setup.ts
const workerId = process.env.BUN_TEST_WORKER_ID || "0";
process.env.TEST_DB_URL = `sqlite://test-worker-${workerId}.db`;

Environment Variables and Worker Configuration

When using --parallel, Bun passes environment variables to each worker so you can isolate resources:

// tests/setup.ts - Give each worker its own test database
const workerId = process.env.BUN_TEST_WORKER_ID || process.env.JEST_WORKER_ID || "0";
const port = 3000 + Number(workerId);

process.env.TEST_DB = `:memory:worker-${workerId}`;
process.env.TEST_PORT = String(port);

This pattern is common in the Node.js ecosystem and Bun maintains compatibility.

Node.js Comparison

This is where the contrast between Bun and the traditional Node.js testing ecosystem becomes most pronounced. Let's compare what it takes to achieve the same testing capabilities:

Setup and Dependencies

┌────────────────────────────────────────────────────────────────┐
│              Bun vs Node.js: Test Stack Setup                  │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│  Bun (zero dependencies):                                      │
│  ┌─────────────────────────────────┐                          │
│  │  bun test --parallel --changed  │  ← Everything built-in   │
│  └─────────────────────────────────┘                          │
│                                                                │
│  Node.js ecosystem (multiple packages):                        │
│  ┌─────────────┐  ┌─────────────┐  ┌──────────────────────┐   │
│  │ jest        │  │ jest-runner │  │ jest-runner-groups   │   │
│  │ (test core) │  │ (parallel)  │  │ (sharding-like)      │   │
│  └─────────────┘  └─────────────┘  └──────────────────────┘   │
│  ┌──────────────────┐  ┌─────────────────────────────────┐    │
│  │ jest-watch-changed│  │ jest-environment-jsdom          │    │
│  │ (changed files)  │  │ (isolation)                     │    │
│  └──────────────────┘  └─────────────────────────────────┘    │
│                                                                │
│  Result: 4-8 packages vs. zero packages                       │
└────────────────────────────────────────────────────────────────┘

Parallelization

// Bun: Just add a flag
// package.json: "test": "bun test --parallel"

// Node.js + Jest: Requires configuration
// package.json: "test": "jest --maxWorkers=4"
// jest.config.js:
module.exports = {
  maxWorkers: "50%",
  workerIdleMemoryLimit: "1GB", // prevent OOM
  // No work-stealing. No cache-locality partitioning.
  // Output can interleave without additional configuration.
};

Jest's --maxWorkers spawns independent processes without the intelligent work-stealing and cache-locality partitioning Bun implements. There's no --isolate equivalent that guarantees clean global state between files.

Sharding in CI

# Bun — GitHub Actions matrix:
# .github/workflows/test.yml
- name: Run tests
  run: bun test --shard=${{ matrix.shard }}/3

# Node.js + Jest — requires manual shard calculation:
- name: Run tests
  run: |
    # Jest doesn't have native sharding
    # You need to manually split test files:
    FILES=$(find tests -name "*.test.ts" | sort | \
      awk "NR % 3 == ${{ matrix.shard }}")
    npx jest $FILES
    # No balanced distribution guarantees
    # No --changed composition

Jest has no native --shard flag. You have to manually split test files across workers, with no guarantees about balanced distribution or deterministic ordering. The jest-runner-groups package exists but is a third-party solution that doesn't match Bun's round-robin approach.

Changed File Detection

// Bun: Import-graph aware
bun test --changed
// Traces: changed file → imports → imports → test file
// Minimal overhead, no instrumentation needed

// Node.js + Jest: File-watcher based
// jest --onlyChanged (part of jest-watch)
// Only looks at files directly modified
// Does NOT trace import relationships
// If you change src/utils.ts but only tests/indirect.test.ts
// imports it (not tests/utils.test.ts directly),
// --onlyChanged may miss indirect.test.ts

Jest's --onlyChanged is essentially a git diff on file paths. It does not build an import graph. If you modify a utility file that's imported transitively through several layers, Jest won't know that a distantly related test file needs to re-run. Bun's --changed performs full import-graph analysis without entering node_modules and without any code emission.

Complexity Comparison

CapabilityBunNode.js Ecosystem
Test runnerBuilt-injest (1 package)
Parallel execution--paralleljest --maxWorkers + config
Per-file isolation--isolateNo equivalent (test environments are process-level)
CI sharding--shard=M/NManual file splitting or jest-runner-groups
Changed file detection--changed (import graph)--onlyChanged (file diff only)
Watch + changed--changed --watchjest --watch --onlyChanged
Work-stealingAutomaticNot available
Cache-locality partitioningAutomaticNot available
Atomic output bufferingAutomaticRequires additional config
Total extra dependencies04-8 packages

The Node.js testing ecosystem has accumulated complexity over years of incremental additions. Each capability requires a separate package, configuration file, and maintenance burden. Bun consolidates everything into a single runtime with zero dependencies and better defaults.

Practical CI Pipeline Example

Here's a complete GitHub Actions workflow that uses all four features:

name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2

      - name: Install dependencies
        run: bun install

      # Run only tests affected by changes, split across 4 shards
      - name: Run tests (--changed only on PRs)
        if: github.event_name == 'pull_request'
        run: bun test --changed --shard=${{ matrix.shard }}/4

      # Run all tests on main branch pushes
      - name: Run tests (full suite)
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: bun test --parallel --shard=${{ matrix.shard }}/4

For PRs, only affected tests run — split across 4 shards. For main branch pushes, the full suite runs in parallel. Local development uses --changed --watch for instant feedback.

Performance in Practice

Consider a project with 500 test files and 15 minutes of total test time:

ScenarioWithout FeaturesWith --parallelWith --changedCombined
CI (4 shards)15 min4 minN/A1 min*
Developer (small change)15 min4 min30 sec30 sec
Developer (watch loop)15 min each4 min each15 sec15 sec

* --changed + --shard runs only the subset of tests that changed, divided across 4 runners — often only 5-10% of the full suite.

When Not to Use --parallel

Despite its benefits, --parallel isn't always the right choice:

  1. Shared external resources — If tests hit a real database, API, or file system, parallel execution can cause contention or conflicts. Use per-database worker isolation instead.

  2. Order-dependent tests — While --isolate handles most cases, tests that depend on execution order across files (an anti-pattern, but it exists) will break.

  3. Very small test suites — For 5-10 test files, the worker startup overhead may outweigh the benefit. Sequential execution might actually be faster.

  4. CI resource limits — If your CI runner is memory-constrained (e.g., free tier GitHub Actions), --parallel without a worker limit could exhaust RAM. Use --parallel=2 or --parallel=4 explicitly.

Tips and Best Practices

Combine flags strategically:

# Local development: fastest feedback for active changes
bun test --changed --watch

# Pre-commit check: run everything, fast
bun test --parallel

# CI (PR): only affected tests, distributed
bun test --changed --shard=$SHARD/$TOTAL

# CI (main): full suite, distributed
bun test --parallel --shard=$SHARD/$TOTAL

Use test-specific resource allocation per worker:

// tests/globalSetup.ts
const workerId = parseInt(process.env.BUN_TEST_WORKER_ID || "0", 10);
const testPort = 3456 + workerId;

// Each worker gets its own port for HTTP server tests
process.env.TEST_PORT = testPort.toString();

Leverage --bail in parallel mode for quick failures:

# Stop on first failure, even in parallel mode
bun test --parallel --bail

Use --randomize to detect hidden test dependencies:

# Run tests in random order to catch order-dependent flakiness
bun test --randomize
# Combine with --parallel for stress testing
bun test --parallel --randomize

Conclusion

Bun's test runner has evolved from a speed-focused experiment into a comprehensive, zero-dependency testing platform. The combination of --parallel, --isolate, --shard, and --changed addresses every major challenge that large JavaScript test suites face — and does so with an elegance that the fragmented Node.js ecosystem has never matched.

The import-graph-based --changed detection is particularly noteworthy. It's the kind of feature that developers don't know they're missing until they experience it: edit a deeply nested utility, run bun test --changed --watch, and instantly see only the tests that actually depend on that file. No manual filter specification, no test naming convention, no configuration. The test runner just knows.

For teams transitioning from the Node.js ecosystem, the appeal isn't just "faster tests." It's the elimination of an entire category of configuration, dependency management, and CI complexity. One command does what previously required five packages and a configuration file.

Resources