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 librariesBUN_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
| Capability | Bun | Node.js Ecosystem |
|---|---|---|
| Test runner | Built-in | jest (1 package) |
| Parallel execution | --parallel | jest --maxWorkers + config |
| Per-file isolation | --isolate | No equivalent (test environments are process-level) |
| CI sharding | --shard=M/N | Manual file splitting or jest-runner-groups |
| Changed file detection | --changed (import graph) | --onlyChanged (file diff only) |
| Watch + changed | --changed --watch | jest --watch --onlyChanged |
| Work-stealing | Automatic | Not available |
| Cache-locality partitioning | Automatic | Not available |
| Atomic output buffering | Automatic | Requires additional config |
| Total extra dependencies | 0 | 4-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:
| Scenario | Without Features | With --parallel | With --changed | Combined |
|---|---|---|---|---|
| CI (4 shards) | 15 min | 4 min | N/A | 1 min* |
| Developer (small change) | 15 min | 4 min | 30 sec | 30 sec |
| Developer (watch loop) | 15 min each | 4 min each | 15 sec | 15 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:
-
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.
-
Order-dependent tests — While
--isolatehandles most cases, tests that depend on execution order across files (an anti-pattern, but it exists) will break. -
Very small test suites — For 5-10 test files, the worker startup overhead may outweigh the benefit. Sequential execution might actually be faster.
-
CI resource limits — If your CI runner is memory-constrained (e.g., free tier GitHub Actions),
--parallelwithout a worker limit could exhaust RAM. Use--parallel=2or--parallel=4explicitly.
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
- Bun v1.3.13 Release Notes — Official release notes for
--parallel,--isolate,--shard, and--changed - Bun Test Documentation — Complete
bun testAPI reference - Jest Parallelism — Jest's approach to parallel test execution
- Vitest Shard Options — Vitest's CI sharding implementation
- Playwright Sharding — Playwright's sharding documentation