Testing in Nx Monorepos: Affected Tests, Task Pipelines & Caching

Testing in Nx Monorepos: Affected Tests, Task Pipelines & Caching

Running your full test suite on every commit stops being practical the moment your monorepo grows beyond a handful of packages. When you have 40 applications and 80 shared libraries, waiting 45 minutes for unrelated tests to pass is a tax on every engineer's time. Nx solves this through a combination of project graph analysis, affected command filtering, and distributed task caching. This guide walks through each of those mechanisms in detail, with real configuration examples you can drop into your own repo.

Understanding the Nx Project Graph

Before you can run only the tests that matter, Nx needs to understand which packages depend on which. That understanding lives in the project graph — a directed acyclic graph that maps every library and application in your workspace to its dependencies.

Nx builds this graph automatically by reading your tsconfig.json path aliases, package.json dependencies, and explicit declarations in project.json. You can inspect it at any time:

nx graph

This opens an interactive browser visualization. More useful in automation is the JSON output:

nx graph --file=graph.json

The graph is the foundation for everything else Nx does with testing. When you tell Nx to run affected tests, it walks this graph to determine the blast radius of your changes.

Running Affected Tests

The nx affected command compares your current working tree (or a specific commit range) against a base ref and identifies which projects have changed — either directly or because one of their dependencies changed.

# Compare against main branch
nx affected:test --base=main --head=HEAD

# Compare against a specific commit
nx affected:test --base=abc123 --head=def456

# In CI, compare against the last successful run
nx affected:test --base=origin/main

The --base flag is the key lever. In pull request CI, you typically set it to the target branch. In trunk-based development, you might set it to the last known good commit.

Configuring the Base in nx.json

Rather than passing --base every time, set a default in nx.json:

{
  "affected": {
    "defaultBase": "main"
  }
}

Now nx affected:test works without flags in local development. CI can still override with explicit flags when needed.

What Counts as "Affected"

Nx considers a project affected if:

  1. Any file in that project's source directory changed
  2. Any project it depends on (directly or transitively) is affected
  3. Global files listed in implicitDependencies changed

You can declare implicit dependencies in nx.json to catch configuration changes that touch everything:

{
  "implicitDependencies": {
    ".eslintrc.json": "*",
    "babel.config.js": "*",
    "jest.config.base.js": "*"
  }
}

When jest.config.base.js changes, every project becomes affected. This is the right behavior — a change to your base Jest config could break any test in the repo.

Configuring Jest in Nx

Nx generates Jest configuration for each project, but the defaults are worth understanding so you can tune them.

A typical generated jest.config.ts for a library looks like this:

import { getJestProjectsAsync } from '@nx/jest';

export default {
  projects: await getJestProjectsAsync(),
};

And the per-project config at libs/my-lib/jest.config.ts:

export default {
  displayName: 'my-lib',
  preset: '../../jest.preset.js',
  testEnvironment: 'node',
  transform: {
    '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
  },
  moduleFilePatterns: ['**/*.spec.ts', '**/*.test.ts'],
  coverageDirectory: '../../coverage/libs/my-lib',
};

The root jest.preset.js holds shared configuration that all projects inherit:

const nxPreset = require('@nx/jest/preset').default;

module.exports = {
  ...nxPreset,
  coverageReporters: ['lcov', 'text'],
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.spec.{ts,tsx}',
    '!src/index.ts',
  ],
};

Configuring Test Targets in project.json

Each project's project.json defines the test target that nx affected:test invokes:

{
  "targets": {
    "test": {
      "executor": "@nx/jest:jest",
      "outputs": ["{workspaceRoot}/coverage/libs/my-lib"],
      "options": {
        "jestConfig": "libs/my-lib/jest.config.ts",
        "passWithNoTests": true
      },
      "configurations": {
        "ci": {
          "ci": true,
          "codeCoverage": true,
          "coverageThreshold": {
            "global": {
              "branches": 80,
              "functions": 80,
              "lines": 80,
              "statements": 80
            }
          }
        }
      }
    }
  }
}

The ci configuration activates when you run nx affected:test --configuration=ci, enabling coverage and stricter thresholds without affecting local development flow.

Task Pipeline Caching

Nx caches the results of tasks based on the inputs that affect them. If nothing in a project or its dependencies has changed since the last run, Nx replays the cached result without executing the task.

Caching is configured per-target in nx.json:

{
  "targetDefaults": {
    "test": {
      "inputs": [
        "default",
        "^default",
        "{workspaceRoot}/jest.preset.js",
        "{workspaceRoot}/jest.config.ts"
      ],
      "outputs": ["{workspaceRoot}/coverage/libs/{projectName}"],
      "cache": true
    }
  }
}

The inputs array defines what can invalidate the cache:

  • "default" — source files in the project
  • "^default" — source files in dependencies (the ^ prefix means "and all dependencies")
  • Explicit file patterns for shared configuration

When the cache hits, you see something like:

> nx run my-lib:test  [local cache]

 PASS   my-lib  libs/my-lib/src/my-lib.spec.ts (cached)

———————————————————————————————————————————————

 NX   Successfully ran target test for project my-lib (12ms)

Nx read the output from the cache instead of running the command for 1 out of 1 tasks.

Twelve milliseconds instead of several seconds. Across a large repo, this compounds quickly.

Nx Cloud for Distributed Caching

Local caching only helps the machine that ran the task. Nx Cloud extends this to share cache across your entire team and CI infrastructure. When one CI run caches a test result, subsequent runs — on different machines — replay from cache.

Setup is straightforward:

nx connect-to-nx-cloud

This adds nxCloudAccessToken to nx.json and enables remote caching. From that point, cache hits are shared across all runs authenticated with the same token.

For self-hosted options, Nx Cloud also supports custom remote cache backends via the @nx/nx-cloud package or community-maintained alternatives like nx-remotecache-s3.

E2E Testing in Nx

E2E tests are expensive to run, so Nx's affected detection is even more valuable here than for unit tests.

A typical Nx E2E application configured with Playwright:

{
  "name": "my-app-e2e",
  "targets": {
    "e2e": {
      "executor": "@nx/playwright:playwright",
      "outputs": ["{workspaceRoot}/dist/.playwright/apps/my-app-e2e"],
      "options": {
        "config": "apps/my-app-e2e/playwright.config.ts"
      },
      "configurations": {
        "production": {
          "baseUrl": "https://your-production-url.com"
        }
      }
    }
  },
  "implicitDependencies": ["my-app"]
}

The implicitDependencies declaration ensures that when my-app changes, my-app-e2e becomes affected. Without it, Nx has no way to know the E2E suite is related to the application.

CI Integration

A complete GitHub Actions workflow using Nx affected:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Required for nx affected to compare branches

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      - uses: nrwl/nx-set-shas@v4
        # Sets NX_BASE and NX_HEAD environment variables
        # NX_BASE = last successful commit on the base branch
        # NX_HEAD = current HEAD

      - run: npx nx affected:test --base=$NX_BASE --head=$NX_HEAD --configuration=ci --parallel=3

      - run: npx nx affected:lint --base=$NX_BASE --head=$NX_HEAD --parallel=5

      - run: npx nx affected:build --base=$NX_BASE --head=$NX_HEAD --parallel=3

The nrwl/nx-set-shas action is important. It sets NX_BASE to the last successful commit rather than just origin/main, preventing a broken base commit from making every subsequent run look like everything is affected.

Parallelization

The --parallel flag controls how many tasks Nx runs concurrently. The right value depends on your runner's CPU count and memory:

# Run up to 5 test suites in parallel
nx affected:test --parallel=5

# Unlimited parallelism (use carefully)
nx affected:test --parallel=0

For memory-intensive tests (anything involving databases, heavy mocks, or browser automation), conservative parallelism prevents OOM crashes in CI.

Built-in Test Targets and Generators

When you generate a library or application with Nx, it scaffolds the test target automatically:

nx generate @nx/node:library my-lib --unitTestRunner=jest
nx generate @nx/react:application my-app --unitTestRunner=jest --e2eTestRunner=playwright

These generators wire up the executor, create the jest config, and add the target to project.json. For existing projects without a test target, you can add one with the add-jest generator:

nx generate @nx/jest:configuration --project=my-lib

This retrofits Jest onto an existing project — useful when you're migrating an older monorepo to Nx incrementally.

Monitoring Test Reliability Over Time

Nx's affected detection is powerful, but it surfaces a new problem: flaky tests that only appear for certain packages. When nx affected:test runs a subset of your suite on every PR, a flaky test in a rarely-changed package can go unnoticed for weeks.

Tools like HelpMeTest can monitor your test runs over time, tracking which tests flake, which packages have degrading coverage, and whether your cache hit rates are actually saving the time you expect. This kind of observability is especially valuable in monorepos where the test surface is large and partially hidden by smart filtering.

Practical Patterns

Skipping Affected for Critical Paths

Some packages are so foundational that you want to always test them, regardless of affected detection. You can declare this with a separate always-test step in CI:

- name: Always test core packages
  run: npx nx run-many --target=test --projects=core-lib,shared-utils --configuration=ci

- name: Test affected packages
  run: npx nx affected:test --base=$NX_BASE --head=$NX_HEAD --configuration=ci

Tag-Based Test Grouping

Nx's tagging system lets you group projects and run tests by group rather than by project name:

{
  "tags": ["scope:auth", "type:library"]
}
# Run all tests for the auth scope
nx run-many --target=test --projects=tag:scope:auth

This is useful for domain-scoped test runs — running all auth-related tests before deploying the auth service, for example.

Enforcing Module Boundaries

Nx's module boundary lint rules prevent cross-domain imports that would create unwanted graph edges and expand the blast radius of changes:

{
  "rules": {
    "@nx/enforce-module-boundaries": [
      "error",
      {
        "depConstraints": [
          {
            "sourceTag": "scope:checkout",
            "onlyDependOnLibsWithTags": ["scope:checkout", "scope:shared"]
          }
        ]
      }
    ]
  }
}

Tighter module boundaries mean smaller affected sets, which means faster test runs.

Summary

Nx's testing infrastructure is built around one core idea: only do the work that changes require. The project graph makes this safe — Nx knows exactly which packages a change can affect, and it propagates that knowledge transitively. Caching makes it fast — results from unchanged work are never recomputed. And the affected command ties it together in a CLI interface that works the same locally and in CI.

The investment to set this up correctly — tuning implicitDependencies, configuring inputs for cache correctness, wiring up nrwl/nx-set-shas in CI — pays off every day in the form of faster feedback loops and lower CI costs.

Start with nx affected:test --base=main locally and see what it catches. Then layer in remote caching and CI integration. By the time your monorepo hits 100 packages, the infrastructure will already be doing the hard work for you.

Read more

Start now free