Jest Best Practices
Jest tests in Nx Monorepo best practices.
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.
| Setting | What | Why |
|---|---|---|
jest.preset.ts | Shared root config | One source of truth for transform, moduleFileExtensions, testEnvironment |
| Per-project config | extends the preset | Override only what differs (testMatch, setupFiles) |
isolatedModules: true | ts-jest skips type checking | 2-5x faster transforms — run tsc separately |
moduleNameMapper | Must match bundler aliases exactly | Mismatches cause "Cannot find module" that only shows in test |
testMatch vs --testPathPattern | testMatch in config, testPathPattern CLI only | Mixing them creates silent test skipping |
transformIgnorePatterns | Allowlist untranspiled ESM packages | Default ignores all of node_modules — ESM packages need transforming |
jest --showConfig | Validate merged config | Run 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: usejest.restoreAllMocks()—clearMocksonly resets call history,resetMocksreplaces withjest.fn()but loses implementation,restoreAllMocksreturns the original- DB tests: wrap each test in a transaction, rollback in
afterEach— truncate if transactions are impractical - Nested transaction trap: transaction rollback breaks when app code calls
db.transaction()internally — Drizzle savepoints don't nest cleanly. UseDELETE FROMinbeforeEachinstead - Timer mocks:
jest.useFakeTimers()inbeforeEach,jest.useRealTimers()inafterEach - Never seed in
beforeAll— shared state means test order determines pass/fail workerIdleMemoryLimit: set to512MBto 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
| Method | Clears calls | Resets implementation | Restores original |
|---|---|---|---|
clearMocks | Yes | No | No |
resetMocks | Yes | Yes (to jest.fn()) | No |
restoreAllMocks | Yes | Yes | Yes |
Performance
Stop waiting.
--runInBandper project — Nx parallelizes across projects, Jest parallelizes within;runInBandavoids double-parallelism overheadmaxWorkers: '50%'local,25%in watch mode — full cores thrash the OSnx affected:test— only run what the changeset touches- Domain configs in
project.json—testPathPatternper domain for single-suite runs test:quicktargets — skipdependsOnfor 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
| Pattern | Config | Purpose |
|---|---|---|
| Domain isolation | configurations with testPathPattern | Run one domain without touching others |
| Infra dependencies | dependsOn: ["migrate", "seed"] | DB migrations run before test suite |
| Quick targets | test:quick without dependsOn | Skip infra for iterative red-green cycles |
| Cache inputs | nx.json targetDefaults with inputs | Only 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--detectOpenHandlesto find the leak, then fix it - Coverage: use
v8provider (faster thanbabel), set thresholds per critical module - Sharding:
--shard=1/4in 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
| Pitfall | Why it hurts | Fix |
|---|---|---|
--forceExit | Masks open DB connections, timers, server handles | --detectOpenHandles, fix the leak |
beforeAll DB seed | Shared mutable state, test order matters | Seed in beforeEach or transaction rollback |
Missing await in cleanup | Cleanup runs after next test starts | Always await in afterEach |
Top-level jest.mock() | Bleeds across all tests in the file | Mock inside describe or beforeEach |
| Snapshot overuse | Tests pass forever — you stop reading diffs | Test behavior and assertions, not markup |
clearMocks instead of restoreAllMocks | Spied implementations persist, cause phantom passes | Always restoreAllMocks |
| Hardcoded ports in test servers | Parallel workers collide | Use port 0 (OS assigns free port) |
setTimeout in tests | Flaky, slow, hides async bugs | jest.useFakeTimers() + jest.advanceTimersByTime() |
Integration Diagnosis
Drizzle + PostgreSQL integration tests produce cryptic errors. This table gets you from error to fix in 60 seconds.
| Error | Root Cause | Fix |
|---|---|---|
QUERY_FAILED | ESM/CJS mismatch — Drizzle ships ESM, Jest's CJS transform can't resolve drizzle-orm/pg-core | Add Drizzle packages to transformIgnorePatterns exceptions |
QUERY_FAILED | Connection pool exhaustion — parallel Jest workers each create pools, exceed max_connections | Use max: 1 on the postgres connection in test config |
CREATE_FAILED | Schema collision — migrate() fails when drizzle schema exists from a prior run | Add IF NOT EXISTS or teardown schema in globalSetup |
CREATE_FAILED | String parameter type casting — Drizzle binds differently than raw PostgreSQL on varchar columns | Explicit type casts in schema definitions |
ESM Resolution
The #1 source of cryptic Jest failures with modern packages. Jest's CJS transform pipeline cannot resolve packages that ship pure ESM (drizzle-orm, postgres, @neondatabase/serverless). The error surfaces as QUERY_FAILED or SyntaxError: Cannot use import statement outside a module — neither tells you the real cause.
The fix: exclude Drizzle packages from the ignore list so Jest transforms them:
// jest.config.ts
export default {
transformIgnorePatterns: ["node_modules/(?!(drizzle-orm|postgres|@neondatabase)/)"],
moduleNameMapper: {
"^drizzle-orm/pg-core$": "<rootDir>/node_modules/drizzle-orm/pg-core/index.cjs",
},
};
If transformIgnorePatterns grows beyond 5 entries, the project is fighting Jest's module system. Switch to Vitest — it runs native ESM with zero config.
DB Connection Lifecycle
globalSetup and globalTeardown run in a separate Node process. You cannot share a db connection object through them — the object serializes to undefined in worker processes.
| Lifecycle hook | Process | Use for |
|---|---|---|
globalSetup | Isolated process | Database creation, schema migration |
globalTeardown | Isolated process | Database teardown |
beforeAll / afterAll | Worker process | Connection open / close |
beforeEach / afterEach | Worker process | Data cleanup between tests |
// test/setup.ts — per-worker connection
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
// Single connection, not a pool — prevents max_connections exhaustion
const client = postgres(process.env.TEST_DATABASE_URL!, { max: 1 });
export const db = drizzle(client);
afterAll(async () => {
await client.end();
});
Migration Decision
When to stop fighting Jest's module system and switch:
| Signal | Action |
|---|---|
| ESM resolution pain exceeds 2 hours | Switch that project to Vitest |
transformIgnorePatterns growing beyond 5 entries | Switch |
| Need browser-mode component testing | Switch — Jest doesn't support this |
| Tests passing, team productive | Stay on Jest |
See the Vitest migration section for the switchover process.
Context
- Testing Stack — Testing tools and patterns overview
- React Testing Library — Component testing with RTL
- Flow Engineering — How tests fit the delivery pipeline
- Type-First Development — Types before tests before implementation
Links
- Jest Documentation — Official config reference
- goldbergyoni — JavaScript Testing Best Practices — 50+ patterns with examples
- Nx Jest Plugin — Executor options and cache configuration
- Kent C. Dodds — Testing Trophy — Integration tests give the best ROI
- Kent C. Dodds — Common Mistakes with React Testing Library — Patterns that look right but aren't
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
--forceExitdisappeared 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?
- When a
QUERY_FAILEDerror appears, how quickly can you distinguish an ESM resolution problem from a connection pool problem — and what does your diagnosis time reveal about your test infrastructure knowledge? - At what point does fighting
transformIgnorePatternscost more than migrating to native ESM — and how would you measure that threshold?