# 10 minutes to 33 seconds

> EvryStay's backend tests, and the things we tried that didn't work.

- Author: Luca Lowndes
- Published: 2026-08-30
- Topics: Bun, Testing, EvryStay
- Canonical: https://luca.lowndes.net/blog/bun-tests-10-minutes-to-33-seconds

I'm going to try writing up one thing I've optimised/done atleast once a week. This week it's EvryStay's backend tests. The LinkedIn post has the short version, this has the stuff that didn't fit, mostly the things we tried that didn't work.

![Backend test suite, ten minutes to 33 seconds](/blog/bun-tests-journey.png)

We started with Jest + ts-jest. 392 test files, around 4,000 tests, and the full suite took 10 minutes on an M5 Pro.

## Bun

We tried Jest with SWC first because that's the cheap option. It got the unit tests to about a minute, which is fine, but we wanted under 45 seconds and a lot less CPU (the Jest run was burning about 2,300 CPU seconds for 250 seconds of wall time). So Bun.

It wasn't a swap-the-command port. 346 files imported from `@jest/globals` and 160 used `jest.unstable_mockModule`, which Bun doesn't have, so those all had to be rewritten. 407 files in the PR.

The runner took the longest. We started with one long-lived Bun process running everything, and the moment we randomised the file order `mock.module` started leaking between files. So it became one Bun process per test file, which is what we shipped. We also had to give each file its own block of 64 ports, because two files calling `listen(0)` at the same time were getting handed the same port and responses were going to the wrong test.

That got the full suite to about 4.5 minutes. The integration tests were still running one at a time though, so most of that was still just sitting there.

## The tests themselves

Most of our tests hit the database and every file was starting its own MongoDB Memory Server. We moved to one shared Mongo for the run with a database per file and the unit lane went from 50 seconds to 29.

While we were in there we found the two worst files in the suite, 108 seconds and 59 seconds. Both were doing `toMatchObject` against a live Mongoose document, and Bun's matcher was recursing through the whole document's internals on every assertion. Changed them to compare against `.lean()` objects and they went to under a second each. 166 seconds from two one-line changes.

There were a handful more in the same vein. Real sleeps in a retry loop. A test hydrating live affiliate data it never read. A test building every index on a model when it needed one. None of them had ever mattered because everything was slow anyway.

## Not restarting the server

Then we noticed we were restarting the backend server way more than we needed to. 34 integration files were using SuperTest with a fresh listener every time, and because of that they'd been stuck in a serial lane. We moved them onto persistent servers the runner owns and gave integration 4 workers. Full suite 154 seconds to 106.

## Second Mongo, and Bun 1.4

By then the one shared Mongo was the bottleneck, so we added a second one and had the runner hand each file to whichever Mongo was less busy. 106 to 77.

Bun 1.4 (the Rust rewrite) shipped `bun test --parallel` right as we were doing this. Just upgrading did nothing, 1.3.14 to 1.4.0 with the old runner was slower and broke a cleanup hook. Turning `--parallel` on for everything was slower too, 98.7 seconds against 96.1. It only paid off once we changed how the databases worked with it, so each worker owns one database that gets cleared before every file with the indexes kept. That took the integration lane from 75 seconds to 34.

After that we ran the unit and integration lanes at the same time on separate database namespaces, and swapped pnpm's recursive workspace runner for one Bun coordinator, which is the hacky version we'd rigged up finally getting replaced. 33 seconds.

## What didn't work

![Rejected experiments](/blog/bun-tests-graveyard.png)

More than about 4 workers per Mongo server lost every time, and usually not by being slower. 5 integration workers was 8% slower. 6 was faster but a setup hook timed out at 30 seconds. 7 timed out in the Viator queue on every run. Same story with more Mongo servers, 3 was slower and 4 with 9 workers was 114 seconds against 96.

Batching unit files into fewer processes lost to one-process-per-file in every sample we took, 129 files into 17 processes and it was slower each time.

Assigning files to a Mongo based on their profiled cost was slower than just picking whichever server had fewer files on it right now, 89 seconds against 77.

Parallel collection cleanup was 0.6% slower than sequential, which is noise, but it was more code so it went.

Pretty much anything that put more load on the database lost, and changing the runtime by itself never did anything.

## CI

The M5 defaults completely oversubscribed the 2-core GitHub runner, runs took 28-42 minutes on it. Hosted runs are capped at 2 workers with `BUN_TEST_NATIVE_INTEGRATION` off, and at that setting the suite that takes 33 seconds on my laptop takes about 4.5 to 5 minutes.

## Now

`pnpm test` starts one Bun coordinator, two MongoDB Memory Servers, three unit workers and four integration workers, all at once. Each worker has a database that's wiped before each file, each file has its own port range, expensive files go first based on a saved timing profile, logs and telemetry are off. 4,403 tests, 244,701 assertions, in 33 seconds, testing all of the same stuff :)
