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.

How a small setup cost multiplies across a suite A hundred milliseconds of eager setup runs once per test file per worker, so on two hundred files it becomes twenty seconds of every run even though only a dozen files need the fixtures. Eager setup: 100ms, 200 files 20s of parsing and registering, every run 1.2s of it is actually used Lazy and memoised 1.2s — only the files that need it pay the saving is the difference between what is loaded and what is used
The cost is not the fixture; it is loading the fixture in the files that never read it.

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();
});
What belongs in a shared setup file Matchers, cleanup hooks and environment settings are cheap and needed everywhere, while fixtures, mock handler sets and derived indexes are expensive and needed rarely, so they belong behind lazy accessors. in the setup file custom matchers cleanup and restore hooks timezone and locale cheap, and every file needs it behind a lazy accessor large JSON fixtures mock handler sets derived indexes and clients expensive, and rarely needed
The dividing line is not importance but the ratio of cost to how many files genuinely need it.

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)
Three caching scopes and what each costs No cache parses on every use, a module-level cache parses once per file under isolation, and a worker-scoped fixture parses once per worker at the cost of holding the data in memory for the whole run. no cache parses on every call worse than eager module-level once per file with isolation the usual right answer worker-scoped once per worker costs memory all run
Laziness without a cache is the one option that is strictly worse than doing nothing.

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.