Jest Testing skill for Claude Code

Jest suites with hoisting-safe mocks and honest snapshots. Claude sets a single Jest config with per-project environments, a fast TypeScript transform and restoreMocks so state never leaks between tests. It writes module mocks that respect hoisting rules, uses async timer advancers when promises are involved, keeps snapshots small and reviewed, and walks a suite through the Jest 30 breaking changes such as removed matcher aliases and the renamed path flag.

  • namejest-testing
  • size242 lines · ≈2,750 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Configures and writes Jest 29 and 30 suites — jest.config with projects, transforms (babel-jest, ts-jest, @swc/jest), jsdom vs node environments, module mocking with jest.mock hoisting rules, requireActual partial mocks and spyOn, mock reset config, modern fake timers with async advancing, snapshot discipline, native ESM with unstable_mockModule, sharding and open-handle debugging, plus Jest 30 upgrade notes. Use when writing or fixing Jest tests, when mocks leak between tests, jest.mock does not apply, ESM or TypeScript imports fail under Jest, fake timers hang, snapshots churn, or when upgrading Jest.

Try asking

  • jest.mock isn't replacing my module in this test, figure out why
  • Upgrade our repo from Jest 29 to Jest 30 and fix what breaks
  • This test with fake timers and a retry loop never resolves. Fix it

Use it when

  • Writing or fixing tests in a project that runs Jest
  • Mocks, spies or env vars leak between tests
  • ESM-only dependencies or TypeScript paths fail under Jest
  • Fake timers hang or snapshots keep churning
  • Upgrading Jest to version 30

Skip it when

  • The project runs Vitest or you are moving to it: use vitest-testing
  • The question is querying and interacting with rendered components: use testing-library

How to check the result

jest --ci passes from a clean clone, jest --randomize passes with two seeds, and --detectOpenHandles reports nothing left open.

The common mistake it prevents

Referencing a normal variable inside a hoisted jest.mock factory, which throws or reads undefined because the mock runs before the variable is initialized.

~/.claude/skills/jest-testing/SKILL.md

--- name: jest-testing description: Configures and writes Jest 29 and 30 suites — jest.config with projects, transforms (babel-jest, ts-jest, @swc/jest), jsdom vs node… license: MIT ---

A healthy Jest suite has one config that states environment, transform and mock-reset policy explicitly; mocks are declared at module scope and restored automatically; time is faked only where needed; snapshots are small and reviewed; and jest --ci fails on anything unexpected. If a test passes alone but fails in the full run, state is leaking.

Ground rules

  • Set restoreMocks: true (implies reset + restore of spies) in config. Per-test beforeEach(jest.clearAllMocks) sprinkled around is a symptom, not a policy.
  • jest.mock() calls are hoisted above imports by babel-jest/ts-jest. Factories cannot reference outer variables unless the name starts with mock (and is initialized before use) — lazy-reference it inside a function.
  • Mock at module boundaries you own (an api/client module), or stub HTTP at the network layer (MSW); do not mock fetch call-by-call across hundreds of tests.
  • Prefer jest.spyOn(obj, 'method') for a single function over replacing a module.
  • Run CI with jest --ci so missing snapshots fail instead of being written.
  • New greenfield project on Vite/ESM-first stacks: consider Vitest (see vitest-testing). Do not migrate a large, working Jest suite without a reason.

Workflow

- [ ] 1. Check Jest version, config, transform, environment, test script
- [ ] 2. Normalize config (below): environment per project, restoreMocks, coverage
- [ ] 3. Write/fix tests: mocks at module scope, spies for single functions
- [ ] 4. Fake timers only in tests that need them; use async advancers with promises
- [ ] 5. Keep snapshots small; inline for short values
- [ ] 6. Run full suite, then --randomize, then --ci in a clean checkout

1. Inspect first

npx jest --version
ls jest.config.* babel.config.* .babelrc tsconfig*.json 2>/dev/null
grep -n '"jest"' -A20 package.json | head -40
npx jest --showConfig | grep -E '"(testEnvironment|transform|preset|restoreMocks|resetMocks|clearMocks|extensionsToTreatAsEsm)"' -A3

2. Config baseline (TypeScript, mixed node + DOM)

// jest.config.ts  (TS config files need ts-node, or Jest 30's TS config support)
import type { Config } from 'jest';

const shared = {
  transform: { '^.+\\.(t|j)sx?$': ['@swc/jest', {}] },   // or ts-jest / babel-jest
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss)$': 'identity-obj-proxy',
  },
};

const config: Config = {
  restoreMocks: true,
  collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/index.ts'],
  coverageReporters: ['text-summary', 'lcov'],
  projects: [
    { ...shared, displayName: 'node', testEnvironment: 'node',
      testMatch: ['<rootDir>/src/server/**/*.test.ts'] },
    { ...shared, displayName: 'dom', testEnvironment: 'jsdom',
      testMatch: ['<rootDir>/src/ui/**/*.test.tsx'],
      setupFilesAfterEnv: ['<rootDir>/test/setup-dom.ts'] },
  ],
};
export default config;
TransformChoose whenNote
@swc/jestDefault for TS speedNo type checking; run tsc --noEmit separately
ts-jestNeed isolatedModules: false behaviors or type errors in testsSlowest; set isolatedModules / transpilation options for speed
babel-jestProject already uses Babel (React Native, older CRA)@babel/preset-typescript strips types only
  • jest-environment-jsdom is a separate package since Jest 28; install the version matching Jest’s major.
  • moduleNameMapper must mirror tsconfig paths; missing entries give “Cannot find module ’@/…’”.
  • transformIgnorePatterns defaults to skipping node_modules. ESM-only dependencies then fail with SyntaxError: Cannot use import statement outside a module; allow-list them: transformIgnorePatterns: ['/node_modules/(?!(nanoid|uuid|@scope/pkg)/)'].

3. Mocking patterns

// Whole module, auto-mocked, typed
import { sendEmail } from '@/lib/mailer';
jest.mock('@/lib/mailer');
const mockedSend = jest.mocked(sendEmail);

test('sends welcome email', async () => {
  mockedSend.mockResolvedValue({ id: 'm1' });
  await registerUser({ email: '[email protected]' });
  expect(mockedSend).toHaveBeenCalledWith(expect.objectContaining({ to: '[email protected]' }));
});
// Partial mock: keep real exports, replace one
jest.mock('@/lib/date', () => ({
  ...jest.requireActual<typeof import('@/lib/date')>('@/lib/date'),
  now: jest.fn(() => new Date('2025-01-01T00:00:00Z')),
}));
// Factory needing a value: name must start with "mock"
const mockConfig = { region: 'eu' };
jest.mock('@/config', () => ({ get config() { return mockConfig; } }));
NeedUse
Replace one method, keep objectjest.spyOn(obj, 'm').mockReturnValue(x)
Replace a module everywhere in the filejest.mock(path) at top level
Different module mock per testjest.doMock + jest.isolateModules(() => require(...))
Shared manual mock__mocks__/<module>.ts next to the module (node_modules mocks at root __mocks__/)
Silence and assert consolejest.spyOn(console, 'error').mockImplementation(() => {})
Env varsSave/restore process.env in beforeEach/afterEach, or jest.replaceProperty(process, 'env', {...})

mockReset vs mockClear vs mockRestore: clear wipes call records; reset also removes implementations; restore puts back the original for spyOn. With restoreMocks: true Jest does the right one before every test, so module-level mockReturnValue set outside a test is wiped — set behavior inside beforeEach or the test.

4. Fake timers

beforeEach(() => jest.useFakeTimers({ now: new Date('2025-03-10T09:00:00Z') }));
afterEach(() => jest.useRealTimers());

test('retries after backoff', async () => {
  const p = fetchWithRetry('/x');
  await jest.advanceTimersByTimeAsync(1000);   // flushes promises between timers
  await expect(p).resolves.toEqual({ ok: true });
});
  • Modern timers (default since Jest 27) also fake Date. Use jest.setSystemTime() to move the clock without firing timers.
  • Code mixing promises and timers hangs with sync advanceTimersByTime / runAllTimers. Use the ...Async variants (Jest 29.5+).
  • runAllTimers on a recurring setInterval loops until the 100k limit; use runOnlyPendingTimers or advance by an explicit amount.
  • doNotFake: ['nextTick', 'queueMicrotask'] when libraries depend on them; advanceTimers: true lets time flow with the real clock for code that polls.

5. Snapshot discipline

  • Snapshot small, stable output: a serialized error message, a formatted string, a minimal render. Prefer toMatchInlineSnapshot() under ~15 lines so reviewers see it.
  • Never snapshot whole API responses or large component trees as the only assertion; assert the fields that matter.
  • Strip volatile values with property matchers: expect(user).toMatchSnapshot({ id: expect.any(String), createdAt: expect.any(Date) }).
  • jest -u only after reading the diff; never in CI. --ci makes missing snapshots fail.
  • Obsolete snapshots are listed in the run summary; remove them with a full-suite jest -u on a clean tree (a filtered run cannot tell which ones are obsolete).

6. Native ESM

Only when the project is "type": "module" without a transform that outputs CJS:

NODE_OPTIONS=--experimental-vm-modules npx jest
import { jest } from '@jest/globals';
jest.unstable_mockModule('./db.js', () => ({ query: jest.fn() }));
const { query } = await import('./db.js');        // import AFTER mocking
const { listUsers } = await import('./users.js');

jest.mock is not hoisted in native ESM; static imports load before any mock. Most teams keep transforming to CJS (swc/babel) to avoid this; if ESM friction dominates, that is the real reason to move to Vitest.

Running and debugging

npx jest path/to/file.test.ts -t 'retries after'   # one file, one test name
npx jest --watch                                   # changed files (needs git)
npx jest --findRelatedTests src/a.ts src/b.ts      # pre-commit selection
npx jest --randomize --seed=1234                   # surface order dependence
npx jest --runInBand --detectOpenHandles           # "Jest did not exit" hunting
npx jest --logHeapUsage --workerIdleMemoryLimit=512MB
npx jest --ci --shard=2/4 --coverage               # CI matrix shard
  • “A worker process has failed to exit gracefully” / “Jest did not exit”: an open DB connection, server, or interval. Close it in afterAll; do not paper over with --forceExit.
  • Memory growth across files: large module graphs re-required per file; set workerIdleMemoryLimit, and check for global listeners added at import time.
  • Slow suites: maxWorkers=50% locally; avoid ts-jest type checking per file; check --listTests | wc -l versus actual test files to catch over-broad testMatch.

Upgrading to Jest 30

  • Node 18+ required (14, 16, 19, 21 dropped); TypeScript 5.4+ for types.
  • --testPathPattern became --testPathPatterns; update scripts and CI.
  • Removed matcher aliases (toBeCalled, toBeCalledWith, lastCalledWith, toThrowError, etc.); run eslint-plugin-jest’s no-alias-methods autofix first.
  • jest-environment-jsdom moves to a newer jsdom; tests that reassign window.location break. Use history.pushState or inject location.
  • toEqual ignores non-enumerable properties; snapshot format changes need one reviewed -u run in a dedicated commit.
  • New: using spy = jest.spyOn(...) auto-restores at block end; expect.arrayOf; jest.advanceTimersToNextFrame(); test.each %$ case index; TS config files.
  • Upgrade all jest, jest-environment-*, babel-jest, ts-jest, @types/jest (or @jest/globals) together; mismatched majors give cryptic transform errors.

Gotchas

  • jest.mock('axios') with ESM-built axios needs transformIgnorePatterns or a manual mock; an auto-mock of an untransformed ESM package fails at parse time.
  • resetMocks: true also wipes jest.fn(() => x) implementations defined in __mocks__ factories each test; prefer restoreMocks and set values per test.
  • Async tests must await or return the promise; a forgotten await expect(...).rejects passes without checking.
  • expect.assertions(n) in tests with callbacks or try/catch guarantees assertions ran.
  • testEnvironment: 'jsdom' for server code hides Node APIs and slows tests; split projects.
  • Global setup (globalSetup) runs in a separate context; values set on global there are not visible in tests. Pass data via env vars or files.

Verification

  1. npx jest --ci passes from a clean clone (no local snapshots written).
  2. npx jest --randomize passes twice with different seeds.
  3. Each changed test fails when the behavior under test is broken (flip one line).
  4. npx jest --runInBand --detectOpenHandles reports no open handles.
  5. After an upgrade: zero deprecated alias usage (grep -rnE 'toBeCalled|toThrowError\(' src test).

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc