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.
- name
jest-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.
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-testbeforeEach(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 withmock(and is initialized before use) — lazy-reference it inside a function.- Mock at module boundaries you own (an
api/clientmodule), or stub HTTP at the network layer (MSW); do not mockfetchcall-by-call across hundreds of tests. - Prefer
jest.spyOn(obj, 'method')for a single function over replacing a module. - Run CI with
jest --ciso 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;
| Transform | Choose when | Note |
|---|---|---|
@swc/jest | Default for TS speed | No type checking; run tsc --noEmit separately |
ts-jest | Need isolatedModules: false behaviors or type errors in tests | Slowest; set isolatedModules / transpilation options for speed |
babel-jest | Project already uses Babel (React Native, older CRA) | @babel/preset-typescript strips types only |
jest-environment-jsdomis a separate package since Jest 28; install the version matching Jest’s major.moduleNameMappermust mirrortsconfigpaths; missing entries give “Cannot find module ’@/…’”.transformIgnorePatternsdefaults to skippingnode_modules. ESM-only dependencies then fail withSyntaxError: 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; } }));
| Need | Use |
|---|---|
| Replace one method, keep object | jest.spyOn(obj, 'm').mockReturnValue(x) |
| Replace a module everywhere in the file | jest.mock(path) at top level |
| Different module mock per test | jest.doMock + jest.isolateModules(() => require(...)) |
| Shared manual mock | __mocks__/<module>.ts next to the module (node_modules mocks at root __mocks__/) |
| Silence and assert console | jest.spyOn(console, 'error').mockImplementation(() => {}) |
| Env vars | Save/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. Usejest.setSystemTime()to move the clock without firing timers. - Code mixing promises and timers hangs with sync
advanceTimersByTime/runAllTimers. Use the...Asyncvariants (Jest 29.5+). runAllTimerson a recurringsetIntervalloops until the 100k limit; userunOnlyPendingTimersor advance by an explicit amount.doNotFake: ['nextTick', 'queueMicrotask']when libraries depend on them;advanceTimers: truelets 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 -uonly after reading the diff; never in CI.--cimakes missing snapshots fail.- Obsolete snapshots are listed in the run summary; remove them with a full-suite
jest -uon 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; avoidts-jesttype checking per file; check--listTests | wc -lversus actual test files to catch over-broadtestMatch.
Upgrading to Jest 30
- Node 18+ required (14, 16, 19, 21 dropped); TypeScript 5.4+ for types.
--testPathPatternbecame--testPathPatterns; update scripts and CI.- Removed matcher aliases (
toBeCalled,toBeCalledWith,lastCalledWith,toThrowError, etc.); runeslint-plugin-jest’sno-alias-methodsautofix first. jest-environment-jsdommoves to a newer jsdom; tests that reassignwindow.locationbreak. Usehistory.pushStateor inject location.toEqualignores non-enumerable properties; snapshot format changes need one reviewed-urun 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 needstransformIgnorePatternsor a manual mock; an auto-mock of an untransformed ESM package fails at parse time.resetMocks: truealso wipesjest.fn(() => x)implementations defined in__mocks__factories each test; preferrestoreMocksand set values per test.- Async tests must
awaitorreturnthe promise; a forgottenawait expect(...).rejectspasses 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 onglobalthere are not visible in tests. Pass data via env vars or files.
Verification
npx jest --cipasses from a clean clone (no local snapshots written).npx jest --randomizepasses twice with different seeds.- Each changed test fails when the behavior under test is broken (flip one line).
npx jest --runInBand --detectOpenHandlesreports no open handles.- 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.