What a separate integration-test target is and who needs it

A separate integration-test target means your Testcontainers-backed suites run under their own Nx target, such as integration-test, next to the existing test target that runs unit tests. Each target has its own executor options, its own inputs, its own cache flag, and its own dependsOn. Nx treats them as two independent tasks. Running nx test api never starts a container, and running nx integration-test api never reruns unit specs.

The main reason to split them is that unit tests and container tests change for different reasons. A unit test result depends on source files and test files. An integration test result also depends on things Nx cannot see by default: the database image tag, migration scripts, environment variables, and whether a Docker daemon is reachable. If both suites share one target, you have two bad options. You can cache the whole target with inputs too loose for the integration half, which risks false cache hits. Or you can disable caching and lose fast, cached unit runs. Separate targets let each one declare exactly what it depends on.

The pattern helps most for backend and platform engineers in monorepos where services talk to PostgreSQL, Redis, Kafka, RabbitMQ, or similar. It pays off in three situations:

  • Several services each start their own containers.
  • Integration suites take minutes rather than seconds.
  • Many pull requests compete for the same CI runners.

In project.json, define both targets under targets. Point test at the unit test config. Point integration-test at a separate config that matches only integration spec files, and give it its own inputs, which later sections fill in.

{
  "name": "api",
  "sourceRoot": "apps/api/src",
  "projectType": "application",
  "targets": {
    "test": {
      "executor": "@nx/jest:jest",
      "cache": true,
      "inputs": ["default", "^production"],
      "outputs": ["{workspaceRoot}/coverage/apps/api/unit"],
      "options": {
        "jestConfig": "apps/api/jest.config.ts"
      }
    },
    "integration-test": {
      "executor": "@nx/jest:jest",
      "cache": true,
      "inputs": [
        "default",
        "^production",
        "{projectRoot}/migrations/**/*",
        { "env": "POSTGRES_IMAGE" }
      ],
      "outputs": ["{workspaceRoot}/coverage/apps/api/integration"],
      "dependsOn": ["^build"],
      "options": {
        "jestConfig": "apps/api/jest.integration.config.ts",
        "runInBand": true
      }
    }
  }
}

A project.json with a cached unit 'test' target and a separate 'integration-test' target that has its own Jest config, inputs (migrations, image env var), and dependsOn.

How Nx computes the cache hash and why the default inputs fall short

Those inputs are the only thing Nx consults before deciding to skip a run. Before executing a target, Nx computes a hash from:

  • the files matched by the target's inputs, usually the project's own files via a named input
  • the corresponding inputs of the projects it depends on (^ inputs)
  • the target's command and options
  • any declared env values
  • the output of any declared runtime commands

If that hash matches a stored entry, Nx replays the recorded terminal output and restores the declared outputs without running anything. The test is not re-executed, so a stale hash replays a stale pass.

project files ─┐
dep inputs (^) ┼──► hash ──► match?  ─yes─► replay logs + outputs
env / runtime ─┤                     ─no──► run tests, store result
command/config ┘

Default inputs cover files inside the project and its dependency projects. Testcontainers behavior is often driven by things outside that boundary:

Factor affecting test behavior Captured by defaults? Input that covers it
Image tag hardcoded in a spec file Yes Project files
Image tag read from an env var No env input
Moving tag such as latest No (the tag string doesn't change) Pin by digest, or a runtime input that reports it
SQL migrations in a non-project directory No {workspaceRoot} glob in a named input
Seed data or fixtures outside the project No {workspaceRoot} glob
Compose file at the repo root No {workspaceRoot} glob
Docker engine or Node version No runtime input

In each "No" row, the hash can stay the same while the database your tests run against has changed.

 CAPTURED BY DEFAULT INPUTS          NOT CAPTURED UNLESS DECLARED
+-------------------------+          +--------------------------------+
| project files (specs,   |          | env vars (e.g. image tag)      |
|   hardcoded image tag)  |          | moving tag such as latest      |
| dep project inputs (^)  |          | migrations outside project     |
| target command/options  |          | seeds, fixtures, compose file  |
+------------+------------+          | Docker engine / Node version   |
             |                       +---------------+----------------+
             |                                       |  only if declared:
             |                                       |  env / runtime /
             |                                       |  {workspaceRoot} glob
             v                                       v
      +--------------------------------------------------------+
      |           Nx computation hash for the target           |
      +----------------------------+---------------------------+
                                   v
                     +----------------------------+
                     | matching entry in cache?   |
                     +-------------+--------------+
              +--------------------+-------------------+
              v yes                                    v no
+-----------------------------+        +-----------------------------+
| CACHE HIT                   |        | CACHE MISS                  |
| replay terminal output      |        | start containers, run tests |
| restore declared outputs    |        | store logs + outputs        |
| tests are NOT re-executed   |        | under the new hash          |
+-----------------------------+        +-----------------------------+
 Undeclared change on the right -> same hash -> HIT -> stale pass replayed

Factors on the left reach the Nx hash by default, while the Testcontainers factors on the right only reach it through env, runtime, or {workspaceRoot} inputs, so a change to an undeclared factor still produces a cache hit and replays a stale pass.

Implementing precise inputs: namedInputs, pinned images, env and runtime

The fix is to state those dependencies explicitly, in four steps.

1. Define an integration named input

In nx.json, add an integration entry under namedInputs. Include {projectRoot}/**/* and the paths your suites read at runtime: migration directories, SQL or JSON fixtures, and the integration test config. Then add negated patterns such as !{projectRoot}/**/*.md and !{projectRoot}/docs/**/* so documentation edits don't invalidate the cache. In the target's inputs, reference integration along with ^production, so changes in dependency projects still trigger a rerun.

2. Pin images in a file Nx can see

Put every image reference in one shared constants module, using an explicit tag or, better, a digest. Add that file to the integration input. A tag like latest breaks caching without any warning. The image the tag points to can change upstream while your source files stay the same, so the hash stays the same and Nx replays an old pass against a database you never tested. With a pinned image, an upgrade becomes a one-line change that Nx can detect.

3. Add env and runtime inputs carefully

Add { "env": "..." } entries only for variables that change test behavior, such as a feature flag or a database mode. Add a runtime input, such as a command that prints the CPU architecture, because arm64 and amd64 hosts can pull different image variants. Never hash secrets, hostnames, or user-specific paths. Including them breaks sharing in the remote cache, and the hash values may end up in logs.

4. Keep the inputs current

If inputs are too broad, almost every change causes a cache miss. If they are too narrow, you get false greens, which do more damage. Each time you add a fixture directory or a new container, update the input in the same pull request.

{
  "namedInputs": {
    "default": ["{projectRoot}/**/*"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts"],
    "integration": [
      "{projectRoot}/**/*",
      "{workspaceRoot}/db/migrations/**/*",
      "{workspaceRoot}/test/fixtures/**/*.sql",
      "{workspaceRoot}/test/fixtures/**/*.json",
      "{workspaceRoot}/libs/testing/src/container-images.ts",
      "{workspaceRoot}/jest.integration.config.ts",
      "!{projectRoot}/**/*.md",
      "!{projectRoot}/docs/**/*"
    ]
  },
  "targetDefaults": {
    "integration-test": {
      "cache": true,
      "inputs": [
        "integration",
        "^production",
        { "env": "DB_MODE" },
        { "env": "FEATURE_NEW_QUERY_PLANNER" },
        { "runtime": "uname -m" }
      ]
    }
  }
}

An nx.json that hashes migrations, fixtures, test config and the pinned-image constants file, excludes docs, and adds behavior-changing env vars plus CPU architecture while keeping secrets and host-specific values out of the hash.

Writing Testcontainers suites that deserve to be cached

A cached target is only as trustworthy as the run that produced the cache entry. With Testcontainers, two choices matter most: how often containers start, and how tests are kept from interfering with each other.

Start containers once, not per test

Starting a database container in beforeEach adds seconds to every test, which pushes teams toward skipping the suite. Start containers in your test runner's global setup instead. Jest and Vitest both support a globalSetup file. That file starts the container once per run, or once per worker if each worker needs its own instance. It then exposes the connection string through an environment variable and stops the container in teardown. The code example that accompanies this section shows this shared setup.

Isolate state per test

A shared container means shared state, so every test needs a clean starting point. There are three common options:

  • Transactions: open a transaction before each test and roll it back afterward. This is the fastest option, but it fails when the code under test commits or opens its own connections.
  • Per-test schemas: create a uniquely named schema for each test and point the connection at it. This isolates parallel workers cleanly.
  • Truncation: clear all tables after each test. This is simple and works with any code, but it gets slower as the schema grows.

The accompanying per-test hook uses one of these approaches.

Fix nondeterminism before enabling caching

Without caching, a flaky test fails occasionally and someone reruns it. With caching, a lucky pass is stored and replayed for every matching hash until an input changes. Order-dependent tests are the worst case, because they can pass reliably in one ordering and hide real bugs. Run the suite in random order and repeat it several times before you mark the target cacheable.

Container reuse across runs has the same trade-off as within a run. It reduces startup time, but leftover state from an earlier run can make a test pass that would fail on a fresh container.

// global-setup.ts: Vitest `globalSetup` starts one container for the whole run
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';

let container: StartedPostgreSqlContainer;

export default async function setup() {
  // Pin the image tag so the cached result maps to a known database version
  container = await new PostgreSqlContainer('postgres:16-alpine').start();
  // Workers start after this runs, so they inherit the variable
  process.env.DATABASE_URL = container.getConnectionUri();
  // Vitest calls the returned function as teardown
  return async () => { await container.stop(); };
}

// test-setup.ts: listed in `setupFiles`, runs in each worker
import { afterAll, afterEach } from 'vitest';
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

// Truncation works even when the code under test commits or opens its own connections
afterEach(async () => {
  const { rows } = await pool.query(
    "SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
  );
  if (rows.length === 0) return;
  const tables = rows.map((r) => `"public"."${r.tablename}"`).join(', ');
  // RESTART IDENTITY resets sequences so generated IDs do not depend on test order
  await pool.query(`TRUNCATE ${tables} RESTART IDENTITY CASCADE`);
});

afterAll(async () => { await pool.end(); });

A Vitest global setup starts one PostgreSQL container per run and shares its URL, and a per-worker afterEach hook truncates every table so each test starts from the same state.

Running Docker in CI for Testcontainers

Before any of that matters, the test process needs a Docker daemon it can reach. Testcontainers looks for one in roughly this order: the DOCKER_HOST environment variable, its own configuration file, and then the default Unix socket. If none of these resolves, the suite fails at startup. Testcontainers also starts a helper container (Ryuk) that removes leftover containers after the run. Ryuk needs access to the same daemon, so a setup that works for your test containers but blocks the socket for helpers will still break.

Option Setup effort Isolation Security risk Suitability
Runner with a Docker daemon Low Per-runner VM Low on ephemeral VMs Default choice for hosted Linux runners
Docker-in-Docker Medium Separate daemon per job Needs a privileged container Kubernetes or container-based runners
Mounted host socket Low None between jobs Root-equivalent access to the host Dedicated, single-tenant runners only

Mounting the host socket gives the job control of the host daemon. On shared runners, one job can then inspect or kill another job's containers. Docker-in-Docker avoids that problem, but the privileged flag it requires is a host-escape risk of its own.

Cost comes mostly from image pulls. Ephemeral runners start with an empty image cache, so every job pulls Postgres, Kafka and similar images again. That adds minutes to each run, and anonymous pulls from Docker Hub count against its rate limits. Point the suite at a registry mirror or pull-through cache, or bake the images into the runner image. Use the same pinned tags you declared as Nx inputs.

Using nx affected and verifying cache behavior

In CI, run the target with nx affected -t integration, setting --base to the last commit that passed on your main branch and --head to the current commit. Two mechanisms work together here. affected decides which projects get scheduled, based on which files changed. The task hash then decides whether a scheduled task actually executes or replays from cache. A project can be affected and still be a cache hit.

Verification exercise

Do this once on a branch before trusting the setup:

  1. Edit a migration file in a project that has an integration target. Run the affected command. The target should execute and start containers. If it replays from cache instead, the migrations directory is missing from that target's inputs.
  2. Edit only that project's README and run the command again. The project will likely show as affected, because the README lives inside it. The integration task should still be a cache hit, because your named input excludes documentation. If it reruns, the input pattern is too broad.

To see what feeds a hash, run nx show project <name>. It lists the resolved inputs for each target. Compare that list against what your suite actually reads at runtime.

Illustrative estimate

All inputs below are assumptions, not measurements:

  • 40 CI runs per day
  • 3 projects with integration suites
  • 6 minutes per suite, including image pulls and container startup
  • 60% of runs touch nothing in those inputs

Under these assumptions, avoiding the redundant runs saves about 40 × 3 × 0.6 × 6 = 432 runner-minutes per day. Developers also skip waiting on Docker for changes that cannot affect the result.

When to avoid caching, alternatives, and impact on cost

Even with precise inputs, the hash only covers what you declared. A nightly or pre-release pipeline that runs nx run-many -t integration --skip-nx-cache gives you a regular full execution that ignores cached results. It acts as a backstop for inputs you forgot to model, such as a migration script outside the project or a changed default in the database image. If a project's suite depends on external state that you cannot express as an input, set "cache": false on that project's integration target.

A remote cache adds another risk. Unless you add runtime inputs for them, the hash does not include CPU architecture, Docker engine version or host OS. A green result produced on an arm64 laptop can then be replayed on amd64 CI runners, even though the container images and native drivers differ between the two. One mitigation is to let only CI write to the remote cache. Another is to add architecture as a runtime input.

Not caching is also a reasonable choice:

  • Rely on nx affected alone, so tests run whenever the project graph says they might be impacted.
  • Run integration tests only on main or nightly, and accept slower feedback on pull requests.
  • Replace some layers with mocks or in-memory substitutes, and keep Testcontainers for the boundaries where real behavior matters, such as SQL dialects and message brokers.
Strategy CI minutes Input maintenance Risk of false greens
Cache with precise inputs Lowest High, inputs drift as code changes Medium, depends on unmodeled inputs
Cache plus nightly --skip-nx-cache Low, plus one full run per night High Low, caught within a day
nx affected, no cache Medium None Low
Main/nightly only Low on PRs None Low, but failures surface late
Mocks for some layers Lowest Mock upkeep Higher at the mocked boundaries

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 testcontainers integration tests, 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.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

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.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • 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.