What a cached fixture target is and who should use it

A cached fixture target is an Nx task whose job is to produce seed data, and whose output Nx stores in its cache. That output might be a SQL dump, a database snapshot file or a set of JSON fixtures. The task declares its inputs explicitly: schema definitions, migration files and the seed scripts themselves. Nx hashes those inputs. If none of them have changed since the last run, Nx restores the stored artifact instead of running the task. If any of them change, the hash changes and the fixtures are rebuilt.

Teams usually reach for this because the two simpler options both fail. The first is to regenerate fixtures on every test run. That works, but it can take longer than the tests themselves. This is especially true when migrations replay from scratch or the seed scripts insert large relational datasets. The second is to commit fixtures once and reuse them indefinitely. That fails the other way: a column gets renamed or a constraint gets added, and tests either run against data that no longer matches the schema or break with errors that look unrelated to the change. Tying regeneration to the files that define the data's shape avoids both problems.

The pattern suits backend and platform engineers in Nx monorepos whose test suites depend on a real or embedded database. It fits best when two conditions hold:

  • Generation is expensive. Fixture generation takes long enough that skipping it is noticeable.
  • Schemas change less often than code. Schema files change less often than the application code that triggers most test runs.

If either condition is false, the extra configuration may not be worth it.

How it works: schema project, fixture target and test target in the task graph

The setup has three pieces, and each one handles a different part of the job.

The schema project. Migrations, schema definitions and seed scripts live in their own Nx project, such as db-schema. Nx maps every changed file to the project whose root contains it. Because the schema files have their own project, Nx can tell a schema change apart from an application change.

The consumer link. Services that read the database often don't import anything from the schema project. They connect at runtime. Without an import, Nx's project graph shows no relationship between them. You add implicitDependencies to each consumer's project configuration to create that edge. Without it, a migration change won't mark the API service as affected.

The two targets. The seed-fixtures target belongs to the schema project. Its inputs list only the files that can change the resulting data: migrations, seed scripts and the database image tag. Nx hashes those inputs. On a cache miss, Nx runs the target and stores the dump written to the target's outputs directory. Each consumer's test target uses dependsOn to point at seed-fixtures, so the dump always exists before tests start.

When you run nx affected -t test, Nx works through the following steps:

  1. It diffs the base and head commits.
  2. It maps the changed files to projects.
  3. It follows graph edges to collect the dependent projects.
  4. It expands the test targets of those projects, plus their dependsOn prerequisites, into a task graph.

For seed-fixtures, Nx computes the hash before running anything. If the hash matches a cached entry, Nx copies the stored dump back into the outputs directory and replays the original logs. The database is never started. Seed data is regenerated only when a hashed input changes.

  nx affected -t test  (diff base..head -> changed files)
           |
           v
+---------------------+   implicitDependencies   +---------------------+
| db-schema project   |<-------------------------| api service         |
| migrations, seeds   |                          | (no schema import)  |
+---------------------+                          +---------------------+
           |  changed files -> project                      |
           v                                                v
+---------------------+        dependsOn         +---------------------+
| seed-fixtures       |<-------------------------| test target         |
| inputs: migrations, |                          | dependsOn ensures   |
| seeds, db image tag |                          | dump exists first   |
| outputs: dump dir   |                          |                     |
+---------------------+                          +---------------------+
           |
           v
  hash inputs before running
           |
           +------------------------+
           |                        |
       hit v                   miss v
+---------------------+  +---------------------+
| restore dump into   |  | start DB, migrate,  |
| outputs dir, replay |  | seed, write dump,   |
| logs; DB not started|  | store in cache      |
+---------------------+  +---------------------+

Changed files map to the db-schema project, implicitDependencies mark the API service as affected, and its test target depends on seed-fixtures, which restores a cached dump on a hash hit and regenerates it only on a miss.

Implementing the seed-fixtures target and its inputs

Start in the project that owns the tests, for example api. Add a seed-fixtures target that uses the nx:run-commands executor to call your seed script, or wraps an existing package script so local and CI runs share one entry point. Declare outputs as a single directory such as {projectRoot}/fixtures/generated. Nx only restores what is listed there, so anything the script writes elsewhere is lost on a cache hit.

Declare inputs precisely

In nx.json, define a named input called dbSchema. It should list the schema files (for example {workspaceRoot}/libs/db-schema/src/**/*.sql), the migrations directory, and the seed script itself. If you leave out the seed script, edits to the generator will keep returning stale dumps. Defining these inputs once lets the fixture target and any future consumers share the same definition.

Then set the target's inputs to dbSchema plus two more kinds of entries:

  • Env inputs, such as { "env": "SEED_RANDOM_SEED" }, for every variable that changes the generated rows. Changing the faker seed or row count must produce a new hash.
  • A runtime input, such as { "runtime": "postgres --version" }. Nx hashes the command's output, so upgrading the engine invalidates dumps whose format or default collation may differ.

Leave out broad defaults like default. If you include them, unrelated application edits will trigger reseeding.

Wire the graph

If no code import links api to db-schema, add db-schema to api's implicitDependencies. This makes nx affected treat a schema change as affecting api. Finally, give the test target dependsOn: ["seed-fixtures"] so fixtures always exist, freshly built or restored from cache, before tests run.

// nx.json: one shared definition of what makes seed data stale
{
  "namedInputs": {
    "dbSchema": [
      "{workspaceRoot}/libs/db-schema/src/**/*.sql",
      "{workspaceRoot}/libs/db-schema/migrations/**/*",
      "{projectRoot}/scripts/seed.js"
    ]
  }
}

// apps/api/project.json
{
  "name": "api",
  "implicitDependencies": ["db-schema"],
  "targets": {
    "seed-fixtures": {
      "executor": "nx:run-commands",
      "options": {
        "command": "node scripts/seed.js --out fixtures/generated",
        "cwd": "apps/api"
      },
      "cache": true,
      "outputs": ["{projectRoot}/fixtures/generated"],
      "inputs": [
        "dbSchema",
        { "env": "SEED_RANDOM_SEED" },
        { "env": "SEED_ROW_COUNT" },
        { "runtime": "postgres --version" }
      ]
    },
    "test": {
      "dependsOn": ["seed-fixtures"]
    }
  }
}

A shared dbSchema named input in nx.json and an api project.json where seed-fixtures is cached on schema, migration, seed-script, env and Postgres-version inputs, depends implicitly on db-schema, and runs before test; the two snippets are labeled with // comments, which Nx's config parser accepts.

Caching the right artifact and keeping it deterministic

Nx caches files, not processes. When seed-fixtures hits the cache, Nx restores whatever paths are listed in the target's outputs and replays its terminal output. It cannot restore a running Postgres instance or a Docker volume's internal state. So the target should produce a portable artifact: a plain SQL dump, or a directory of CSV or JSON files, written to a path such as dist/fixtures/api. The test target then creates a fresh database or starts a throwaway container, loads the artifact with the database's standard import tool, and runs migrations only if the dump omits schema. Each test run starts from a known state, and nothing leaks between runs.

A cache hit is only trustworthy if identical inputs always produce byte-identical outputs. Otherwise a restored fixture and a freshly built one can differ, and failures appear depending on which machine built the cache entry. To keep output deterministic:

  • Fixed random seeds. Seed every faker or random generator from a constant.
  • Stable ordering. Sort rows by primary key before writing them, and emit files in sorted name order.
  • Fixed timestamps. Use a pinned reference date instead of now(), and avoid default columns that capture the clock.
  • No external calls. Do not fetch data from external APIs or shared services. Those responses are not declared as inputs, so Nx cannot see when they change.

Cache only synthetic data. Local and remote caches are shared across developers and CI. Anything written to outputs, including copies of production records, becomes readable by everyone with cache access.

// tools/seed-fixtures/generate.ts
// Writes a portable SQL file to the path listed in the target's `outputs`.
// The same inputs must always produce a byte-identical file.
import { mkdirSync, writeFileSync } from 'node:fs';
import { faker } from '@faker-js/faker';

const OUT_DIR = 'dist/fixtures/api';
const REF_DATE = new Date('2024-01-01T00:00:00Z'); // pinned clock, never now()

faker.seed(42);                  // fixed seed: same sequence on every machine
faker.setDefaultRefDate(REF_DATE); // relative date helpers use the pinned date

const sql = (s: string) => `'${s.replace(/'/g, "''")}'`;

// Synthetic rows only. Never copy production data into a cached output.
const users = Array.from({ length: 50 }, (_, i) => ({
  id: i + 1,
  name: faker.person.fullName(),
  email: faker.internet.email().toLowerCase(),
  createdAt: faker.date.past({ years: 1 }).toISOString(),
}));

// Stable ordering: sort by primary key before writing.
users.sort((a, b) => a.id - b.id);

const lines = users.map(
  (u) =>
    `INSERT INTO users (id, name, email, created_at) VALUES ` +
    `(${u.id}, ${sql(u.name)}, ${sql(u.email)}, ${sql(u.createdAt)});`,
);

// Explicit created_at values, so no column default captures the clock.
// No network calls: everything comes from declared inputs.
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(`${OUT_DIR}/seed.sql`, lines.join('\n') + '\n');

// Test setup restores into a fresh database, for example:
//   createdb api_test && psql -v ON_ERROR_STOP=1 -d api_test -f dist/fixtures/api/seed.sql

A seed generator that uses a fixed faker seed, a pinned reference date, primary-key ordering and no network calls, so it writes the same SQL file to the cached output path on every run. A comment at the end shows how test setup loads that file into a fresh Postgres database.

Verifying affected detection and cache behaviour before CI

Before any pipeline depends on this, confirm locally that the task graph matches your intent. Make two throwaway commits on a branch: one that edits only a schema file, and one that edits an unrelated application source file. After each, run nx show projects --affected --base=main and check which projects appear.

Change Expected in affected list Expected for seed-fixtures
Schema file only Schema project, fixture target's project, dependent test projects Cache miss, rebuild
Unrelated app file That app and its dependents only Not run, or cache hit
Env var or runtime input changed Same projects as before Cache miss

If the unrelated change still pulls in the fixture project, an input glob is probably too broad, or an implicit dependency is too coarse.

Next, run nx run <project>:seed-fixtures twice with no changes in between. The first run should execute. The second should report that Nx read the output from cache. If it doesn't, something nondeterministic is leaking into the hash or the outputs. Then change one declared env or runtime input, such as the database engine version string, and run again. A miss confirms that Nx is actually tracking the input. A hit means a fixture built for the wrong engine would be silently reused.

In CI, --base must point at the merge-base with your target branch, not just the previous commit. Shallow clones break this: if the base commit isn't in local history, Nx can't compute the diff and the affected detection fails. Fetch full history, or at least enough history to include the merge-base.

#!/usr/bin/env bash
# Local check of affected detection and cache behaviour for the fixture target.
# Assumes the project is named db-fixtures and its seed-fixtures target
# declares { "env": "DB_ENGINE_VERSION" } in its inputs.
set -euo pipefail

# CI safety: affected detection needs the base commit in local history.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
  git fetch --unshallow origin
fi
git fetch origin main

# Diff against the merge-base with main, not just the previous commit.
BASE=$(git merge-base origin/main HEAD)
echo "Affected since ${BASE}:"
npx nx show projects --affected --base="$BASE" --head=HEAD

export DB_ENGINE_VERSION="16.4"

# Run 1 should execute the target.
npx nx run db-fixtures:seed-fixtures

# Run 2 with no changes should be served from cache.
# If it executes again, something nondeterministic is in the hash or outputs.
npx nx run db-fixtures:seed-fixtures

# Change a declared env input: this must be a cache miss.
# A hit here means a fixture built for the wrong engine would be reused.
export DB_ENGINE_VERSION="17.0"
npx nx run db-fixtures:seed-fixtures

A script that computes the merge-base after un-shallowing the clone, lists the projects Nx reports as affected, and runs the fixture target three times to show a cache miss, then a cache hit, then a miss after a declared env input changes.

Failure modes and when to avoid this approach

Every cache hit is a claim that nothing relevant changed since the last build. The approach is only as safe as that claim.

Failure mode What you see Mitigation
Under-specified inputs (a shared enum, SQL function or ORM config outside the schema project) Cache hit, stale fixtures, tests failing against a schema shape that no longer exists List those files explicitly as inputs; review inputs whenever a shared library joins the schema
Over-broad inputs (whole workspace, every source file) Fixtures rebuild on almost every commit, so the cache adds overhead and saves no time Narrow inputs to schema, migrations and the seed script
Non-deterministic seed script (random IDs, current timestamps, unordered dump output) Same inputs, different artifacts; flaky tests that appear to come from nowhere Fixed random seeds, a frozen clock, sorted output
Large dumps Cache upload and download take longer than regenerating Trim rows, compress, compare transfer time against seed time
Wrong CI base reference Skipped rebuilds or full rebuilds on every run Point the base at the merge-base or last successful main build
Production-derived data Personal data copied into cache storage and CI artifacts Use synthetic data only

A cached dump does not remove the need to start a database. Restore still requires a running database, and a dump produced by a newer engine may not load into an older one. Treat the engine version as an input so a version change invalidates the cache. Inputs also drift as the codebase changes. When someone moves the schema into a new directory, the old glob matches nothing and the cache never invalidates. Nx raises no error when this happens.

Avoid this approach when fixtures already seed in a few seconds, when the schema changes on most commits, or when the team does not already use Nx. In the last case, adopting a build system only to cache seed data costs more than it saves.

Alternatives and impact on cost and engineering time

Each alternative below trades rebuild cost against a different kind of maintenance. Pick by where your pain is: CI time, stale data, or setup effort.

Approach When fixtures rebuild Main cost
Regenerate every run Always CI minutes on every pipeline
Commit fixture files Manually Stale fixtures, noisy diffs
Prebuilt database image On image rebuild Image pipeline, registry storage
Transaction rollback / template database Once per test session Still pays full seed cost each session
Seed via migrations at test start Always Slow startup, same as regenerating
Nx cached fixture target When declared inputs change Input and graph maintenance, cache storage

Transaction rollback and template databases complement caching rather than replace it. They make per-test isolation cheap, but they do not avoid the initial seed.

The setup cost for the Nx target is front-loaded. Writing the target takes a day or so. The recurring cost is keeping inputs accurate whenever someone adds a schema file or seed script outside the declared globs. Cache storage grows with each schema version, so set a retention policy for the remote cache.

Illustrative estimate (assumed numbers, not a benchmark). The formula is:

  • Minutes saved = R × G − (S × G + (R − S) × C)

The assumed inputs are:

  • G = 4 min to generate fixtures
  • C = 0.5 min to restore from cache
  • R = 300 CI runs per month
  • S = 15 runs per month that change the schema

That gives 1,200 − (60 + 142.5) ≈ 1,000 minutes saved per month.

The harder-to-quantify saving is fewer debugging sessions spent chasing tests that pass locally against fixtures that no longer match the schema.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx affected database fixtures, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.