What the Atomizer does to a Jest or Vitest target

Atomization replaces one monolithic test target with many small ones. When an inferred-task plugin such as @nx/jest is given a CI target name option (in current versions this is ciTargetName), Nx scans the project for test files and infers one target per file, plus a parent target that depends on all of them. With ciTargetName set to test-ci, a project with 40 spec files gets 40 file-level targets and a test-ci target that runs them all. @nx/vite offers the same behavior for Vitest in versions that support it.

This matters because Nx schedules, caches, and distributes work at the target level. A single test target is one opaque unit: it runs on one machine, and editing any spec invalidates the whole cached result. Per-file targets give the scheduler small pieces it can spread across agents and cache independently.

Local workflows don't change. Developers keep running test, which still invokes Jest or Vitest once with watch mode and filtering intact. Only CI calls the parent target.

Before relying on this, check three things:

  • Plugin registration. The plugin must be listed in the plugins array of nx.json, with the CI target name in its options. The configuration example below shows this.
  • Inferred targets only. Hand-written, executor-based test targets in project.json are not atomized. Migrate them to inferred targets first.
  • Installed version. Confirm the option name and Vitest support against your installed plugin's documentation, not this article.

Then run nx show project <name> and confirm the per-file targets and the parent target appear.

# nx.json (excerpt): register the inferred Jest plugin and give it a CI target name.
# Hand-written executor targets in project.json are NOT atomized, so the project
# must get its test target from this plugin.
#
#   "plugins": [
#     {
#       "plugin": "@nx/jest/plugin",
#       "options": {
#         "targetName": "test",
#         "ciTargetName": "test-ci"
#       }
#     }
#   ]
#
# Check your installed plugin's docs for the exact option name. For Vitest,
# confirm which plugin and version supports ciTargetName before adding it.

# Confirm the plugin entry and its options are actually in nx.json
jq '.plugins' nx.json

# Show the project's full set of targets, including inferred ones
nx show project orders-api

# List only the atomized targets: the parent (test-ci) plus one per spec file
nx show project orders-api --json | jq -r '.targets | keys[]' | grep '^test-ci'

# Local work is unchanged: one Jest run, watch mode and filtering still work
nx test orders-api --watch

# CI calls the parent target, which depends on every per-file target
nx run orders-api:test-ci

Registers @nx/jest/plugin with a ciTargetName in nx.json, then uses nx show project to confirm the per-file targets and the parent test-ci target, while test stays the local entry point.

How per-file targets run across CI agents

When you run the parent CI target, Nx does not execute a test runner directly. The parent target depends on every per-file target, so Nx expands it into a task graph where each test file is its own task with its own cache entry. The parent completes only when all of those tasks have finished.

Monolithic target                 Atomized target, distributed

test (1 process, 1 machine)       test-ci (parent, no work of its own)
 ├─ users.spec.ts                  ├─ test-ci--users.spec.ts    ─┐
 ├─ orders.spec.ts                 ├─ test-ci--orders.spec.ts    ├─> Agent 1
 ├─ billing.spec.ts                ├─ test-ci--billing.spec.ts  ─┘
 ├─ invoices.spec.ts               ├─ test-ci--invoices.spec.ts ─┐
 ├─ auth.spec.ts                   ├─ test-ci--auth.spec.ts      ├─> Agent 2
 └─ webhooks.spec.ts               └─ test-ci--webhooks.spec.ts ─┘

Splitting a target does not make it faster on its own. Jest and Vitest already parallelize files across worker processes within one run. On a single machine, atomized tasks compete for the same cores, and each task adds overhead: Nx starts a separate runner process, which loads configuration, initializes transforms, and runs global setup again for every file. For a backend suite with heavy setup, such as database bootstrapping or a large module graph, that repeated startup cost can make the atomized version slower than one monolithic run.

The speedup comes from distribution. Because each file is an independent, cacheable task, the scheduler can spread tasks across several CI agents and balance them by recorded duration. Wall-clock time then depends on the slowest agent's share of the work. It no longer depends on the core count of one machine.

MONOLITHIC: one runner process on one machine
┌──────────────────────────────────────────────────────────────┐
│ Machine                                                      │
│ test: 1 runner process, workers share local cores            │
│  users  orders  billing  invoices  auth  webhooks            │
└──────────────────────────────────────────────────────────────┘
  wall clock ~ total work / local cores

ATOMIZED: per-file tasks distributed across CI agents
                   ┌─────────────────────────────┐
                   │ test-ci (parent, no work)   │
                   └──────────────┬──────────────┘
                                  │ depends on every per-file task
       ┌──────────┬──────────┬────┴─────┬──────────┬──────────┐
       ▼          ▼          ▼          ▼          ▼          ▼
   ┌───────┐  ┌───────┐  ┌───────┐  ┌───────┐  ┌───────┐  ┌───────┐
   │ users │  │orders │  │billing│  │invoice│  │ auth  │  │webhook│
   └───┬───┘  └───┬───┘  └───┬───┘  └───┬───┘  └───┬───┘  └───┬───┘
       └──────────┼──────────┘          └──────────┼──────────┘
                  ▼                                ▼
       ┌─────────────────────┐          ┌─────────────────────┐
       │ Agent 1             │          │ Agent 2             │
       │ 3 runner processes  │          │ 3 runner processes  │
       └─────────────────────┘          └─────────────────────┘
  wall clock ~ slowest agent's share + per-task startup cost

A monolithic test target runs all files in one process on one machine. The atomized parent target expands into per-file tasks that are spread across CI agents, so wall-clock time depends on the slowest agent's share, and each file pays its own startup cost.

Does it pay off? An illustrative estimate of CI wall-clock time

The payoff depends on the per-process startup cost. A monolithic run pays it once. Atomized targets pay it for every file.

Illustrative estimate (assumed numbers, not a benchmark)

Assumed inputs: N = 200 spec files, t = 3 s average test time per file, S = 4 s startup per process, C = 4 cores per agent, O = 60 s per-agent setup (checkout, install), A = 5 agents.

  • Monolithic: O + S + (N × t) / C
  • Atomized, one machine: O + N × (S + t) / C
  • Atomized, A agents: O + N × (S + t) / (C × A)
Run shape Wall-clock Total compute (agent-seconds)
Monolithic, 1 agent 60 + 4 + 150 = 214 s 214
Atomized, 1 agent 60 + 350 = 410 s 410
Atomized, 5 agents 60 + 70 = 130 s 650

On one machine, atomization is nearly twice as slow because the 4 s startup more than doubles each file's cost. Across five agents, wall-clock time drops by about 40%, but you pay roughly three times the CI minutes. With these inputs, the break-even point is three agents.

To get your real S, create a spec file with a single trivial assertion and run it alone through its per-file target. Subtract the reported test time from the total process time. That remainder covers TypeScript transforms, module graph loading, and globalSetup/globalTeardown, all of which now run once per file. If your global setup starts a database container, S can easily exceed t.

The trade is compute spend for feedback latency. That trade makes sense when engineers are waiting on the pipeline. It does not when the run happens overnight.

#!/usr/bin/env bash
# Estimate per-process startup cost S by timing a trivial spec run alone.
# The assertion takes ~0 ms, so process time is roughly S: TS transform,
# module graph loading, and globalSetup/globalTeardown.
set -euo pipefail

PROJECT_DIR="${1:-apps/orders-api}"   # placeholder project path
RUNNER="${2:-jest}"                   # jest | vitest
RUNS=5
SPEC="src/__startup_probe__.spec.ts"

cd "$PROJECT_DIR"
trap 'rm -f "$SPEC"' EXIT

if [ "$RUNNER" = "vitest" ]; then
  printf "import { it, expect } from 'vitest';\nit('probe', () => expect(1).toBe(1));\n" > "$SPEC"
  CMD=(npx vitest run "$SPEC")
else
  printf "it('probe', () => expect(1).toBe(1));\n" > "$SPEC"
  CMD=(npx jest --runTestsByPath "$SPEC")
fi

# The first run warms the transform cache. Discard it, because CI agents with
# a restored cache behave like the warm runs.
"${CMD[@]}" > /dev/null 2>&1

total_ms=0
for i in $(seq "$RUNS"); do
  start=$(date +%s%N)
  "${CMD[@]}" > /dev/null 2>&1
  end=$(date +%s%N)
  ms=$(( (end - start) / 1000000 ))
  echo "run $i: ${ms} ms"
  total_ms=$(( total_ms + ms ))
done

S_ms=$(( total_ms / RUNS ))
echo "Estimated S (per-file startup): ${S_ms} ms"
echo "Multiply by spec count to see the overhead atomization adds."

A shell script estimates per-process startup cost S. It runs a single trivial Jest or Vitest spec in isolation several times and averages the wall-clock time, which you can then use as S in the cost formulas above.

Auditing backend integration tests for isolation before you switch

Each atomized target is a separate Jest or Vitest process, so assumptions that held inside one long run stop holding. Audit for these before switching:

Hazard Symptom after atomizing Fix
Shared database or schema name Unique-constraint violations, rows from another file appearing in assertions Derive the database or schema name from an environment variable set per task; create it in setup, drop it in teardown
Fixed ports (3000, 5432) EADDRINUSE when two tasks land on the same agent Bind to port 0 and read back the assigned port, or ask the OS for a free one
Shared temp directory Files overwritten or deleted mid-test Create a unique directory per run under the OS temp path
Singleton container in globalSetup Slow starts, "too many connections" errors Start one shared service per agent, isolate by schema
Order dependency or leftover state New intermittent failures that pass locally Wrap each test in a transaction and roll it back

Two items need more explanation.

globalSetup now runs once per file, not once per suite. A setup that starts a database container will start forty containers for forty files. Several of those can share one agent, which means they compete for memory and can exceed the server's connection limit. Moving the container to the CI service level and giving each task its own schema avoids this.

Worker IDs are not unique per task. JEST_WORKER_ID and Vitest's pool IDs restart at 1 in every process, so two atomized tasks on the same agent will see the same value. Do not build database names from them alone. Combine them with the test file path or the process ID.

Order dependencies are the costliest finding. A file that passed only because an earlier file seeded data will now fail intermittently, depending on how tasks are scheduled. Atomization did not cause that bug. It exposed it. Fixing these tests is correct work, but it is real work, so budget migration time for it.

// test/setup-db.ts: registered via setupFiles so it runs inside each atomized process
import { Client } from 'pg';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import type { Server, AddressInfo } from 'node:net';
import { beforeAll, afterAll, beforeEach, afterEach } from 'vitest';

// Worker IDs restart at 1 in every process. Add the pid and a random suffix so the name is unique per task.
const prefix = process.env.TEST_SCHEMA_PREFIX ?? 'ci';
const schema = `t_${prefix}_${process.pid}_${randomUUID().slice(0, 8)}`
  .toLowerCase()
  .replace(/[^a-z0-9_]/g, '_');

// One shared Postgres per agent. Each task is isolated by its own schema.
export const db = new Client({ connectionString: process.env.DATABASE_URL });
export let tmpDir: string;

beforeAll(async () => {
  await db.connect();
  await db.query(`CREATE SCHEMA "${schema}"`);
  await db.query(`SET search_path TO "${schema}"`);
  // Run migrations here, against this schema only.
  tmpDir = await mkdtemp(join(tmpdir(), 'api-test-')); // Unique directory per process
});

afterAll(async () => {
  await db.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
  await db.end(); // Release the connection so tasks do not exhaust max_connections
  await rm(tmpDir, { recursive: true, force: true });
});

// Each test sees a clean state and leaves nothing behind for later files.
beforeEach(async () => { await db.query('BEGIN'); });
afterEach(async () => { await db.query('ROLLBACK'); });

// Port 0 lets the OS pick a free port. This avoids EADDRINUSE when tasks share an agent.
export async function listenEphemeral(server: Server): Promise<number> {
  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  return (server.address() as AddressInfo).port;
}

A per-file Vitest setup that gives each atomized task its own Postgres schema, a unique temp directory and an OS-assigned port, and wraps every test in a transaction that is rolled back. This applies the table's fixes without starting a container per file.

Wiring the CI target, caching, retries, and reports

Point CI at the atomized parent target with nx affected -t test-ci (or whatever name you configured), and keep test for local development. List only one of them in the pipeline. The parent target depends on every per-file task, so adding test to the same command runs each suite twice and doubles the agent time you were trying to save.

Failures now show up as individual tasks, for example test-ci--src/orders/refund.spec.ts. Engineers see the failing file directly instead of searching a single long log. Caching and retries work per file as well. A green file's result is restored from cache on the next run. If your setup retries failed tasks, a flaky file reruns alone instead of re-executing the whole project.

Check what invalidates a per-file task before you trust the cache. Run nx show project <name> and read that task's inputs. They usually include the project's source files and dependencies, not only the spec file. As a result, one edit to a shared module, config file, or test helper can invalidate every file-level task in the project. That outcome is correct behavior.

Reports need distinct output paths for each task. If every task writes coverage/ or junit.xml to the same location, tasks running on the same agent overwrite each other and the cache restores the wrong files. Derive the output path from the file or task name. Then, in a later CI step, merge the raw coverage JSON and combine the JUnit files. Expect the merged coverage to differ slightly from a single-run report, because module-level code runs once per process and per-file instrumentation does not always line up exactly.

Who should use it, when to apply it, and when to avoid it

Atomization pays off for teams with a handful of slow, integration-heavy backend projects and CI that already fans out across several agents. Start with those projects only. Leave fast unit-test projects as a single target: when a full run takes well under a minute, per-file splitting mostly adds scheduling and startup work.

Avoid it when:

  • CI runs on one machine with few cores. There is nowhere to distribute the work, and the runner's own workers already parallelize within that machine.
  • Projects have many small test files with expensive startup, such as heavy transpilation or large module graphs. Each file pays that cost on its own.
  • globalSetup starts shared infrastructure like databases or brokers. Per-file targets repeat that setup or need it moved out of the test runner.
  • Test targets are hand-written executors rather than inferred from the Jest or Vitest config. The Atomizer works on inferred targets.

Remaining trade-offs:

  • The task graph gets much larger, and logs fill with per-file output.
  • The cache holds more entries and performs more lookups per run.
  • Results can diverge between local and CI runs. A test that relies on state from an earlier file passes in a shared local process and fails in isolation. A test broken by shared state can fail locally and pass atomized.
  • Behavior depends on your Nx and plugin versions, so confirm the details against the docs for the version you run.

The Playwright and Cypress e2e split uses the same per-file pattern. Per-file setup there is usually heavier because of browser launch and app server startup, so check the per-target overhead before you split.

Alternatives to atomization

Atomization is one way to spread test work, not the only one. Each alternative below trades engineering time against CI cost differently, and some combine well with it.

Approach Setup effort Isolation requirements Cache granularity Multi-machine benefit Reporting complexity
Monolithic target, tuned runner workers Low Tests must tolerate concurrent workers on one host Whole project None Low: one report
Split into smaller Nx projects High: moves code and boundaries Per project only Per project Yes, via existing distribution Low to moderate
Runner sharding across CI jobs Moderate Tests must not depend on order across shards Whole project, or none Yes, fixed shard count Moderate: merge shard reports
nx affected alone Low None beyond current Unchanged None by itself Unchanged

Tuning the worker count of Jest or Vitest is the cheapest step. It is limited by the cores and memory of a single machine, and a shared database or port still serializes the suite. Splitting a large backend project into smaller Nx projects costs the most up front, but caching and affected detection then follow real code boundaries. That benefit lasts after the CI work is done. Runner sharding, if your installed version supports it, spreads files across jobs without any Nx changes. The shards are fixed slices, though, so one slow shard sets the total time, and a cache hit only happens when the entire target is unchanged. nx affected reduces how much work runs, not how fast it runs. A change that touches a shared library still triggers the full suite, so pair it with one of the other options.

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 atomizer 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.
  • 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.