Skip to main content

Jest Best Practices

Why do tests pass locally but fail in CI?

Bad config is the root cause. State leaks between suites. --forceExit masks open handles. Slow runs kill feedback loops. This page is the checklist you scan when something breaks.

Configuration

The things that burn you.

SettingWhatWhy
jest.preset.tsShared root configOne source of truth for transform, moduleFileExtensions, testEnvironment
Per-project configextends the presetOverride only what differs (testMatch, setupFiles)
isolatedModules: truets-jest skips type checking2-5x faster transforms — run tsc separately
moduleNameMapperMust match bundler aliases exactlyMismatches cause "Cannot find module" that only shows in test
testMatch vs --testPathPatterntestMatch in config, testPathPattern CLI onlyMixing them creates silent test skipping
transformIgnorePatternsAllowlist untranspiled ESM packagesDefault ignores all of node_modules — ESM packages need transforming
jest --showConfigValidate merged configRun this first when debugging — shows exactly what Jest sees
// jest.preset.ts — shared root
export default {
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': ['ts-jest', { isolatedModules: true }],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
};
// libs/my-lib/jest.config.ts — per project
import preset from '../../jest.preset';

export default {
...preset,
displayName: 'my-lib',
testMatch: ['<rootDir>/src/**/*.spec.ts'],
};

Test Isolation

Stop state leaks.

  • afterEach: use jest.restoreAllMocks()clearMocks only resets call history, resetMocks replaces with jest.fn() but loses implementation, restoreAllMocks returns the original
  • DB tests: wrap each test in a transaction, rollback in afterEach — truncate if transactions are impractical
  • Timer mocks: jest.useFakeTimers() in beforeEach, jest.useRealTimers() in afterEach
  • Never seed in beforeAll — shared state means test order determines pass/fail
  • workerIdleMemoryLimit: set to 512MB to kill workers that leak memory between suites
// Correct mock restoration
afterEach(() => {
jest.restoreAllMocks();
});

// DB isolation via transaction rollback
let tx: Transaction;
beforeEach(async () => {
tx = await db.transaction();
});
afterEach(async () => {
await tx.rollback();
});

Mock Comparison

MethodClears callsResets implementationRestores original
clearMocksYesNoNo
resetMocksYesYes (to jest.fn())No
restoreAllMocksYesYesYes

Performance

Stop waiting.

  • --runInBand per project — Nx parallelizes across projects, Jest parallelizes within; runInBand avoids double-parallelism overhead
  • maxWorkers: '50%' local, 25% in watch mode — full cores thrash the OS
  • nx affected:test — only run what the changeset touches
  • Domain configs in project.jsontestPathPattern per domain for single-suite runs
  • test:quick targets — skip dependsOn for iterative dev, run full chain in CI
// project.json — domain isolation
{
"targets": {
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "libs/my-lib/jest.config.ts",
"runInBand": true
},
"configurations": {
"auth": { "testPathPattern": "auth/" },
"billing": { "testPathPattern": "billing/" }
}
}
}
}

Nx Integration

PatternConfigPurpose
Domain isolationconfigurations with testPathPatternRun one domain without touching others
Infra dependenciesdependsOn: ["migrate", "seed"]DB migrations run before test suite
Quick targetstest:quick without dependsOnSkip infra for iterative red-green cycles
Cache inputsnx.json targetDefaults with inputsOnly invalidate cache when source or config changes
// nx.json — cache correctly
{
"targetDefaults": {
"test": {
"inputs": [
"{projectRoot}/src/**/*",
"{projectRoot}/jest.config.ts",
"{workspaceRoot}/jest.preset.ts"
],
"cache": true
}
}
}

CI

Stop flaky builds.

  • Never ship --forceExit — use --detectOpenHandles to find the leak, then fix it
  • Coverage: use v8 provider (faster than babel), set thresholds per critical module
  • Sharding: --shard=1/4 in a CI matrix strategy — linear speedup
  • Remote caching: Nx Cloud skips suites that haven't changed since last green run
# GitHub Actions matrix sharding
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: npx nx affected:test -- --shard=${{ matrix.shard }}
// jest.config.ts — coverage
export default {
coverageProvider: 'v8',
coverageThreshold: {
'./src/core/': {
branches: 80,
functions: 80,
lines: 80,
},
},
};

Common Pitfalls

PitfallWhy it hurtsFix
--forceExitMasks open DB connections, timers, server handles--detectOpenHandles, fix the leak
beforeAll DB seedShared mutable state, test order mattersSeed in beforeEach or transaction rollback
Missing await in cleanupCleanup runs after next test startsAlways await in afterEach
Top-level jest.mock()Bleeds across all tests in the fileMock inside describe or beforeEach
Snapshot overuseTests pass forever — you stop reading diffsTest behavior and assertions, not markup
clearMocks instead of restoreAllMocksSpied implementations persist, cause phantom passesAlways restoreAllMocks
Hardcoded ports in test serversParallel workers collideUse port 0 (OS assigns free port)
setTimeout in testsFlaky, slow, hides async bugsjest.useFakeTimers() + jest.advanceTimersByTime()

Context

Questions

How do you know your test suite is catching real bugs versus just passing?

  • What's the cost of a test that passes with stale mocks — and how would you detect it?
  • If --forceExit disappeared tomorrow, how many suites would fail — and what does that number tell you about your cleanup discipline?
  • When does test isolation become test duplication — where's the line between safety and waste?
  • What should a test suite's execution time budget be, and what happens to team behavior when it's exceeded?