Keeping Large Fixture Files Out of the Repo

A repository that has been accumulating fixtures for a few years usually contains more fixture bytes than source bytes, and every one of them is in every clone, forever — git stores history, so deleting a twelve-megabyte JSON file does not make the repository smaller. The symptoms are familiar: slow clones, slow CI checkouts, unreviewable pull requests where a regenerated fixture produces a forty-thousand-line diff. This guide covers moving large fixtures out to content-addressed storage, fetching them reliably in CI, and — the more important half — reducing how many large fixtures you need at all. It sits under test data management.

Root Cause Analysis

Large fixtures accumulate because they are the path of least resistance at the moment they are created. A developer needs a realistic API response, saves the one they have, and commits it. Nothing at that moment signals a cost, and the cost arrives later, spread across everyone who clones the repository.

Git makes this worse than it first appears. Because every version of every file is retained, a fixture regenerated monthly for two years contributes twenty-four copies to the history. Deleting the file leaves all of them, so the repository never shrinks without a history rewrite that everybody has to cooperate with.

The second problem is review. A pull request that regenerates a fixture shows thousands of changed lines, none of which anyone reads. Real changes hide in that noise: a field that quietly disappeared, a value that changed meaning. Once fixtures are unreviewable, they stop being verified at all and start drifting from what the service actually returns.

Why deleting a committed fixture does not shrink the repository Each regeneration adds another full copy to git history, so a fixture regenerated many times contributes its size repeatedly, and removing the file today leaves every earlier copy in the history. One 12 MB fixture, regenerated quarterly v1 v2 v3 v4 v5 v6 deleted 72 MB What a fresh clone still downloads all six versions — the deletion changed nothing the only fix after the fact is a history rewrite everyone must cooperate with
The cost is paid by every clone forever, which is why the decision matters at commit time.

Reproducible Setup

Find out what you actually have before choosing a mechanism; the answer is often concentrated in a handful of files.

# largest files currently tracked
git ls-files -z | xargs -0 du -h 2>/dev/null | sort -rh | head -10
# 14M  test/fixtures/catalogue-snapshot.json
# 9.2M test/fixtures/orders-2025.json
# largest objects in history, which is what a clone downloads
git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
  | awk '$1=="blob" {print $3, $4}' | sort -rn | head -10

Implementation

Step 1 — Ask whether the fixture needs to be large at all. Most large fixtures are large by accident: a full catalogue where three products would do, a year of orders where a week demonstrates the same behaviour. Shrinking is free and removes the problem rather than relocating it.

// scripts/fixtures/trim.ts — keep the variety, drop the volume
import { readFileSync, writeFileSync } from 'node:fs';

const all = JSON.parse(readFileSync('test/fixtures/catalogue-snapshot.json', 'utf8')) as Product[];

// one representative per distinct shape, rather than all 40,000 rows
const seen = new Set<string>();
const trimmed = all.filter((p) => {
  const shape = `${p.type}|${p.variants.length > 0}|${p.discount != null}|${p.tags.length > 3}`;
  if (seen.has(shape)) return false;
  seen.add(shape);
  return true;
});

writeFileSync('test/fixtures/catalogue.json', JSON.stringify(trimmed, null, 2));
console.log(`${all.length}${trimmed.length} products`);
// 41208 → 47 products

Step 2 — Store what remains by content hash in object storage. The repository keeps a small manifest; the bytes live elsewhere and are immutable, so a given hash always means the same content.

// test/fixtures/manifest.json — this is what gets committed
{
  "orders-2025.json": {
    "sha256": "9f2c1ab4e7d3…",
    "bytes": 9646080,
    "url": "s3://acme-test-fixtures/9f2c1ab4e7d3.json"
  }
}
// scripts/fixtures/fetch.ts
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import manifest from '../../test/fixtures/manifest.json';

const CACHE = '.cache/fixtures';
mkdirSync(CACHE, { recursive: true });

for (const [name, entry] of Object.entries(manifest)) {
  const cached = `${CACHE}/${entry.sha256}`;
  if (!existsSync(cached)) {
    const res = await fetch(entry.url.replace('s3://', 'https://acme-test-fixtures.s3.amazonaws.com/'));
    writeFileSync(cached, Buffer.from(await res.arrayBuffer()));
  }
  const actual = createHash('sha256').update(readFileSync(cached)).digest('hex');
  if (actual !== entry.sha256) throw new Error(`Fixture ${name} failed its integrity check`);
  writeFileSync(`test/fixtures/${name}`, readFileSync(cached));
}

Step 3 — Cache the downloads in CI, keyed on the manifest. The fetch should happen once per manifest change, not once per run.

# .github/workflows/test.yml
      - uses: actions/cache@v4
        with:
          path: .cache/fixtures
          key: fixtures-${{ hashFiles('test/fixtures/manifest.json') }}
      - run: npx tsx scripts/fixtures/fetch.ts
      - run: npx vitest run

Step 4 — Make the fetch automatic for developers too. A manual step will be forgotten, and the resulting failure — a missing file — is confusing rather than instructive.

// package.json
{
  "scripts": {
    "postinstall": "tsx scripts/fixtures/fetch.ts",
    "test": "tsx scripts/fixtures/fetch.ts && vitest run"
  }
}
Where a fixture should live, by size and by role Small fixtures belong inline in the test, medium ones as committed files, and large ones in content-addressed storage referenced by a committed manifest. under a few KB inline in the test, or from a builder reviewable, obvious most fixtures belong here KB to a few hundred a committed file beside the test still diffable fine, in moderation megabytes object storage, with a committed manifest history stays small and ask why it is so large
Most fixtures should move left rather than right — shrinking beats relocating.

Step 5 — Guard the boundary with a check rather than a convention. A size limit in CI stops the next large fixture from being committed by someone who did not know the policy.

#!/usr/bin/env bash
# scripts/fixtures/check-size.sh
MAX=262144   # 256 KB
fail=0
while IFS= read -r f; do
  size=$(wc -c < "$f")
  if [ "$size" -gt "$MAX" ]; then
    echo "::error file=$f::Fixture is $((size/1024)) KB; the limit is $((MAX/1024)) KB — trim it or move it to storage"
    fail=1
  fi
done < <(git diff --name-only --diff-filter=AM origin/main...HEAD -- 'test/fixtures/*')
exit $fail

Step 6 — Decide what happens when storage is unavailable. A test suite that cannot run without a network call to object storage is fragile in a specific, annoying way. Cache aggressively, and fail with a message that names the manifest entry and the cache path rather than a bare fetch error.

Verification

Verify the manifest and the storage agree, since a fixture uploaded but not recorded — or recorded but not uploaded — fails only when someone clones fresh.

npx tsx scripts/fixtures/fetch.ts
# fetching orders-2025.json (9.2 MB)… ok, sha256 verified
# catalogue.json cached, sha256 verified

Then verify a cold clone works, which is the scenario the whole scheme exists to serve and the one nobody tests.

git clone --depth 1 "$REPO_URL" /tmp/cold && cd /tmp/cold
npm ci && npm test
# the postinstall hook should fetch everything with no manual step

Finally, verify the repository actually got smaller — or, more precisely, stopped growing. A count of bytes added to history per month tells you whether the policy is working better than a one-off measurement does.

git log --since="6 months ago" --numstat --format= -- 'test/fixtures/*' \
  | awk '{added+=$1} END {print "lines of fixture added in 6 months:", added}'
# lines of fixture added in 6 months: 412
Reviewability as the real reason to keep fixtures small A forty-thousand-line regenerated fixture is approved without being read, so a removed field or a changed value passes review unnoticed, while a fifty-line fixture is genuinely checked. 40,000-line diff approved without reading a dropped field slips through the fixture drifts from reality 50-line diff actually read a changed value is noticed the fixture stays trustworthy
Clone size is the visible cost; unreviewable fixtures are the expensive one.

Troubleshooting

Symptom: the repository is still huge after moving fixtures out. Diagnosis: history still contains every past version. Fix: a history rewrite is the only remedy, and it is disruptive — coordinate it, do it once, and make sure the size check is in place first so the problem does not recur.

Symptom: CI fails intermittently fetching fixtures. Diagnosis: the cache key does not cover the manifest, so every run downloads afresh and occasionally hits a transient error. Fix: key the cache on the manifest hash as in Step 3, and add a short retry around the fetch — with the cache working, the fetch happens rarely enough that a retry is cheap.

Symptom: developers hit a missing-file error. Diagnosis: the automatic fetch is only wired into postinstall, which does not run when switching branches. Fix: also run it as part of the test script, and make the error message name the command to run rather than reporting a missing path.

Symptom: a fixture is out of date and nobody noticed. Diagnosis: nothing verifies that the fixture still matches what the real service returns. Fix: add a scheduled check that fetches a live response and compares its shape — not its values — against the fixture, which is a contract check in miniature and closely related to contract testing.

FAQ

Why not use git LFS?

It works and is a reasonable choice when the files must stay conceptually in the repository. The reasons to prefer an explicit manifest are that LFS requires every clone to have the extension configured, it complicates shallow clones in CI, and it makes the fixture’s size invisible again — which removes the pressure to ask whether it should be large at all.

How small should a fixture be?

Small enough to read. If a reviewer cannot scan it and understand what case it represents, it is too big regardless of byte count. In practice this means most fixtures are a few dozen lines, built by a factory rather than stored at all — see factory functions vs fixtures in Vitest.

What about binary fixtures like images or PDFs?

The same rules apply, with more force, since binaries neither diff nor compress well in git. Content-addressed storage is the right home, and the integrity check matters more because a corrupted binary produces a baffling failure rather than a parse error.

Does this apply to snapshot files?

Snapshots are fixtures with a particular update workflow, and an oversized snapshot has the same reviewability problem. If a snapshot is too large to read, that is usually a signal to assert on specific properties instead, rather than a signal to move it to storage.