Loading Fixtures Lazily to Cut Test Startup Time
Setup files are the quietest performance problem in a test suite. A shared setup that imports every fixture, registers every mock handler and parses a few megabytes of JSON runs before every test file, in every worker, whether the file needs any of it or not. On a suite of two hundred files that is two hundred executions of work that perhaps a dozen files actually use. This guide covers finding that cost, moving eager imports behind lazy loads, caching parsed data per worker so laziness does not become repeated work, and verifying the saving. It sits under test data management.
Root Cause Analysis
The cost is invisible because it is distributed. Nobody watches a setup file grow — a handler set here, a fixture import there — and each addition is individually trivial. What makes it expensive is multiplication: setup runs once per test file per worker, so a hundred milliseconds of import cost becomes twenty seconds across two hundred files, on every run, forever.
Static imports are the specific mechanism. An import fixtures from './big.json' at the top of a setup file is evaluated when the module loads, not when something reads it, so the parse happens even in files that never touch it. The same is true of a module that constructs a client, compiles a schema or builds a lookup table at module scope.
The trap in fixing this is over-correction. Making everything lazy without caching means a fixture used by thirty tests is parsed thirty times, which is worse than parsing it once eagerly. The right shape is lazy and memoised: nothing is paid for until first use, and first use is paid for once per worker.
Reproducible Setup
Measure before changing anything, because the intuition about which import is expensive is usually wrong.
// vitest.config.ts — surface per-file setup cost
export default defineConfig({
test: {
setupFiles: ['./test/setup.ts'],
reporters: ['verbose'],
logHeapUsage: true,
},
});
npx vitest run --reporter=verbose 2>&1 | grep -E "setup|transform|collect" | head
# transform 4.21s setup 18.94s collect 3.02s tests 41.6s
# ← setup is a third of the run
// test/setup.ts — the version that costs 18 seconds
import catalogue from './fixtures/catalogue.json'; // 3.1 MB, parsed every file
import { server } from './msw/server'; // registers 140 handlers
import { buildSearchIndex } from '../src/search/index';
const index = buildSearchIndex(catalogue); // runs at module scope
globalThis.__catalogue = catalogue;
globalThis.__index = index;
Implementation
Step 1 — Move the heavy import behind a memoised accessor. Nothing is parsed until a test asks for it, and it is parsed once per worker rather than once per file.
// test/fixtures/catalogue.ts
import type { Product } from '../../src/domain/types';
let cache: Product[] | undefined;
export async function catalogue(): Promise<Product[]> {
cache ??= (await import('./catalogue.json', { with: { type: 'json' } })).default as Product[];
return cache;
}
// in a test that needs it
import { catalogue } from '../fixtures/catalogue';
test('filters by category', async () => {
const products = await catalogue();
expect(filterByCategory(products, 'mugs')).toHaveLength(12);
});
Step 2 — Do the same for derived structures. A search index or a lookup map built from a fixture is usually more expensive than the parse, and equally unused by most files.
// test/fixtures/search-index.ts
import { buildSearchIndex } from '../../src/search/index';
import { catalogue } from './catalogue';
let cache: SearchIndex | undefined;
export async function searchIndex(): Promise<SearchIndex> {
cache ??= buildSearchIndex(await catalogue());
return cache;
}
Step 3 — Register mock handlers lazily too. A mock server with a hundred handlers costs both time and memory; most files need a handful.
// test/msw/server.ts — start empty, add per file
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll } from 'vitest';
export const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// in the file that needs catalogue endpoints
import { server } from '../msw/server';
import { catalogueHandlers } from '../msw/handlers/catalogue';
beforeAll(() => server.use(...catalogueHandlers));
Step 4 — Keep the setup file to things every file genuinely needs. Matchers, cleanup hooks and a timezone are cheap and universal; fixtures and handlers are neither.
// test/setup.ts — what should remain
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
process.env.TZ = 'UTC';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
Step 5 — Scope the cache to the worker, deliberately. A module-level cache lives as long as the module registry does, which with file isolation means once per file rather than once per worker. If the parse is genuinely expensive, a worker-scoped fixture is the right tool.
// test/fixtures/worker-catalogue.ts
import { test as base } from 'vitest';
export const test = base.extend<{}, { catalogue: Product[] }>({
catalogue: [
async ({}, use) => {
const data = (await import('./catalogue.json', { with: { type: 'json' } })).default;
await use(data as Product[]);
},
{ scope: 'worker' },
],
});
Step 6 — Keep an eye on memory as well as time. A worker-scoped cache trades memory for speed, and a large fixture held by eight workers is eight copies. Where memory is the binding constraint, a smaller fixture beats a cleverer cache.
One ordering note that saves confusion: measure transform, setup and collect separately rather than treating everything before the first assertion as “startup”. They have different causes and different fixes — transform is compilation and is fixed by dependency configuration, setup is your own code, and collect is the runner discovering and parsing test files. Attacking setup when the cost is really transform produces a great deal of work and no improvement.
Verification
Verify the saving with the same measurement you started from, so the comparison is honest.
npx vitest run --reporter=verbose 2>&1 | grep -E "setup|tests"
# before: setup 18.94s tests 41.6s
# after: setup 1.31s tests 41.9s
Then verify the fixture is loaded once rather than repeatedly, which is the failure mode of naive laziness.
// test/fixtures/catalogue.ts — temporary instrumentation
let loads = 0;
export async function catalogue() {
if (!cache) { loads++; console.log(`catalogue parse #${loads}`); cache = …; }
return cache;
}
npx vitest run --silent 2>&1 | grep -c "catalogue parse"
# 4 ← one per worker, as intended (not 37, and not 1 per file)
Finally, verify nothing broke by depending on a global that setup used to define. The failure is usually an undefined value rather than a missing import, so a type-level check catches it better than a test run.
grep -rn "__catalogue\|__index" --include="*.ts" --include="*.tsx" .
# (no output — every consumer now uses the accessor)
Troubleshooting
Symptom: the suite got slower after making imports lazy. Diagnosis: no cache, so the fixture is parsed on every call rather than once. Fix: memoise as in Step 1 — the accessor pattern is the whole point, and a bare dynamic import inside a test is not an improvement.
Symptom: a test mutates the cached fixture and breaks its neighbours. Diagnosis: the cache hands out the same object to every caller, so a mutation is shared. Fix: return a structured clone from the accessor when callers mutate, or — better — keep fixtures immutable and build mutable objects with a factory instead.
Symptom: setup time is still high after moving fixtures out. Diagnosis: the remaining cost is transform rather than setup — a large dependency being compiled per worker. Fix: read the reporter’s breakdown, which separates transform from setup, and look at dependency pre-bundling rather than at your fixtures.
Symptom: memory use climbs and workers are recycled mid-run. Diagnosis: worker-scoped caches holding several large fixtures at once. Fix: cache only the expensive ones, and consider whether a smaller fixture would serve — a trimmed dataset removes both the time and the memory cost, as covered in keeping large fixture files out of the repo.
FAQ
Is lazy loading worth it for small fixtures?
No. Below a few hundred kilobytes the parse is a millisecond or two and the indirection costs more in readability than it saves in time. Apply this to the handful of genuinely expensive items the measurement identified, and leave the rest as ordinary imports.
Does this conflict with test isolation?
It coexists with it, provided the cached data is treated as immutable. Isolation is about state that tests can change; a parsed fixture nobody mutates is a pure value, and sharing it between tests is safe. The moment a test writes to it, it needs a copy.
Should the setup file be split per project instead?
In a workspace, yes — that is the cleaner fix, because each package’s setup contains only what that package needs. The techniques here still apply within a package, but per-project setup files remove most of the problem structurally, as described in wiring Vitest workspace projects in a pnpm monorepo.
How do I stop the setup file growing again?
Measure it on a schedule and treat the number as a budget. Setup time is one of the metrics worth putting on a scorecard, because it grows by accretion and nobody notices until it is a third of the run — exactly the kind of drift tracking test duration trends over time is meant to catch.
Related
- Back to Test Data Management
- Sharing test fixtures across a Vitest workspace — where shared accessors should live.
- Keeping large fixture files out of the repo — removing the cost rather than deferring it.
- Right-sizing CI runners for test throughput — the serial floor setup contributes to.