A 10ms Test That Takes 13.64 Seconds
The assertions were cheap. Getting one file ready to run was not.
I pushed a small backend change at MaintainX and did what everyone on the team did next. Stared at CI.
The backend lived in a monolith. Every change, including the tiny ones, entered the same test machinery. The full check could take around 30 minutes. If one test failed near the end, I got to press retry and enjoy the same show twice.
Everybody dreaded the tests. They were the tax attached to finishing backend work.
The easy explanation was that the MaintainX monolith had grown. Thousands of tests, years of business logic, plenty of database work. More tests take more time. Nobody expects a backend that size to run instantly.
So I stopped looking at the whole suite and opened one file.
It contained a pure unit test. No database. No network. The assertion finished in about 10 milliseconds.
The file took 13.64 seconds.
| Time | |
|---|---|
| Assertions | ~10 ms |
| Whole test file | 13.64 s |
I had blamed the number of tests. This file was slow before the test had enough time to do anything.
The test had 13 seconds of errands to run
Vitest doesn’t begin with the first assertion. It begins with every file the test imports, then every file those files import.
The monolith is written in TypeScript, so Vitest transforms those files on the way in. Then it loads them. Loading JavaScript means running its top-level code: constants get computed, registries fill up, and modules describe their connections to the rest of the application.
After that comes the shared test setup. It runs once per file, whether the file needs a database and an authenticated request or 2 numbers and an expect call.
Only then does the test start.
I spent my time blaming the crowd
My first model was linear. We had added more tests, so the suite got slower. The useful work seemed obvious: select fewer files, split the suite harder, or buy more CI capacity.
The 13.64-second file broke that model. Removing 100 unrelated test files would not make this one start any faster. The cost lived inside its own dependency graph.
That distinction matters because the 2 problems need different fixes. Parallelism helps when many cheap tests are waiting in a queue. It does much less when every worker spends its first few seconds rebuilding the same neighborhood.
The only way forward was to follow the imports.
The front desk knew everybody
The test got its subject from a central module registry:
const service = container.get('sessionService');
I think of that registry as the backend’s front desk. It knows every service and hands over the one you ask for. Application code gets one consistent place for construction and wiring. That’s useful.
The front desk has to open before it can answer. Opening it loads every registered module, plus whatever those modules import. The test asked for one service and Vitest found hundreds of files behind the request.
Picture asking a friend for one grey 2x2 LEGO brick. They carry over the sealed Death Star box, cut the tape, and tip every bag onto your kitchen table. The brick is in there. So is the instruction manual, a handful of tiny capes, and enough plastic to ruin your afternoon.
Nobody built this graph by making one ridiculous decision. A shared registry made construction easier, so one test used it. Then another. A helper imported the registry because that was already the standard path. Each choice was reasonable in the pull request where it happened. I’ve made that choice myself.
Rendered all at once, those small conveniences look like somebody dumped the Death Star on the table.
One direct import removed almost 10 seconds
For this file, the service could be imported directly:
import { sessionService } from './sessionService';
The service stayed the same. Only the route changed. Vitest no longer had to open the front desk and introduce itself to the whole backend before reaching the object under test.
| Before | After | |
|---|---|---|
| Transforming TypeScript | 8.0 s | 0.83 s |
| Loading files | 10.5 s | 0.2 s |
| Shared setup | ~2.8 s | ~2.8 s |
| Total | 13.64 s | 3.07 s |
Vitest’s phase counters overlap, so the rows above do not add up to the total. They show where work was observed, not a receipt whose line items can be summed.
The wall-clock result is the part I care about. 13.64 seconds became 3.07 seconds.
Almost 10 seconds, from one file.
Big deal, I fixed one file
Exactly.
One file, in the MaintainX monolith with roughly 2,188 test files. A direct import also works only when the service can stand on its own. Plenty of services arrive attached to configuration, request state, storage, and 3 other services that each bring friends.
I wouldn’t turn this into a rule that every test must bypass the application’s normal construction path. That would ship tests for a wiring model the application never uses. The direct import was valid here because the test cared about isolated behavior and the service did not need the registry to behave correctly.
The win mostly gave me a better question: why was the cheapest kind of test sharing an entrance fee with the most expensive kind?
Everybody pays for the heaviest setup
The repository had one broad setup path. It had to support tests that needed a database, an authenticated user, a complete request context, and the application graph.
A pure unit test used the same path.
That makes test authoring easy. A new file can assume the world is ready. The cost is spread thin enough that no single pull request looks responsible, then multiplied across thousands of files and every person waiting on them.
This is the same box of LEGO at a larger scale. Shared setup lays every piece on the table before it knows what anybody plans to build.
The useful split is based on required environment, not on whatever naming convention happens to be in the repository. Tests that need a database should pay for one. Tests that need application wiring should exercise it. A file with a pure function should have a path that stays small.
Some slow tests are perfectly fine. A database test should pay for a database. This is not an argument about whether the backend needs more unit tests or fewer integration tests. That’s a real debate and a different one.
Selective testing inherits the same graph
The tempting escape hatch is affected-test selection. Given a change, run only the files that could plausibly break.
That works as well as the dependency graph allows. Central registries, barrel exports, broad setup files, and shared configuration create high fan-out. Change one of them and most of the repository appears affected because, mechanically, it is.
The selector cannot know that a test only wanted one grey brick. It sees that the test imported the Death Star box.
Selective testing still matters. It just arrives after the graph has useful boundaries. Otherwise the clever selector returns a very accurate answer: run almost everything.
Vitest was only 30% of the wait
Fixing the test file also did nothing for most of the pipeline around it.
In the MaintainX monolith pipeline I measured, Vitest accounted for about 30% of total wall-clock time. The rest went to checking out the repository, provisioning resources, installing dependencies, rebuilding code, deciding which tests to run, and cleaning up afterward.
Even inside Vitest, assertions shared the clock with dependency resolution, transformation, top-level execution, and setup.
That gives me 2 separate bills to inspect. The pipeline has a fixed cost before the test runner starts. Each test file has another cost before its assertions start. Faster runners can hide part of both bills, but they don’t explain either one.
The 13.64-second file became a 3.07-second file. CI did not suddenly become fast. It did become easier to reason about, because one number had finally been split into the work we wanted and the work we had accumulated.
The only measurement that counts is the one taken where the cost is actually paid.