← All posts
·12 min read

Claude Code with Jest: Mocks, Coverage, ESM Workflow

Claude CodeJestTestingWorkflow
Claude Code with Jest: Mocks, Coverage, ESM Workflow

Why Jest needs a project-specific CLAUDE.md

Jest is the most-used JavaScript test runner in 2026, and that breadth is the problem. Half the Jest content on the public web is from the Babel-only era. A quarter assumes plain JavaScript. The remainder splits between ts-jest, swc, and the ESM workarounds that have shifted across Jest 28, 29, and 30. Claude Code has read all of it, so the default test code it writes is a Frankenstein of patterns from different stacks.

Without a project-specific CLAUDE.md, Claude mixes jest.mock and jest.spyOn interchangeably, reaches for done callbacks instead of await, generates snapshot tests for unstable React output, and hands you a jest.config.ts using a transformer combination your project does not actually run.

This guide covers the CLAUDE.md rules that make Claude Code reliable for Jest. If you are new to the broader workflow, the Claude Code setup guide covers installation, and the Claude Code testing guide covers test strategy across runners.

The Jest CLAUDE.md template

The CLAUDE.md at your project root is read before every Claude Code session. For Jest, it needs to answer: which version, which transformer, ESM or CJS, mocking conventions, coverage thresholds, and async patterns.

# Jest project rules

## Stack
- Jest: 30.x
- Transformer: @swc/jest (do not switch to ts-jest or babel-jest without approval)
- TypeScript: 5.6.x with strict mode and isolatedModules
- Module system: ESM (type: "module" in package.json)
- Node: 20.x with --experimental-vm-modules flag in test script
- Testing libraries: @testing-library/react 16.x, @testing-library/jest-dom 6.x
- Test environment: jsdom for component tests, node for unit/integration tests

## Project structure
- src/: application source
- src/__tests__/: integration tests (one folder per feature)
- src/**/__tests__/*.test.ts: co-located unit tests
- src/__mocks__/: manual module mocks (matched by jest.mock automatically)
- jest.config.ts: single config file, no projects array unless monorepo
- jest.setup.ts: global test setup (matchers, environment)

## Jest syntax rules
- Use describe/it, never describe/test, for consistency across the codebase
- Co-locate unit tests next to source files in __tests__/ subfolders
- One assertion per it block where possible. Multi-assertion blocks need a comment
- Always use async/await for async tests. NEVER use done callbacks
- NEVER use jest.useFakeTimers() without an explicit jest.useRealTimers() in afterEach

## Mocking rules
- jest.mock() for entire modules at the top of the file (hoisted)
- jest.spyOn() for individual methods on existing objects (preserves original)
- NEVER mix jest.mock and jest.requireActual without a clear reason in a comment
- Reset mocks between tests: clearMocks: true in jest.config.ts (already set)
- For React components, prefer @testing-library/react user events over jest.fn() handlers

## Coverage thresholds
- Statements: 80%
- Branches: 75%
- Functions: 80%
- Lines: 80%
- Excluded from coverage: src/types/, src/generated/, *.stories.tsx, *.config.ts

## Hard rules
- NEVER commit a snapshot file that contains unstable values (Date.now, random IDs)
- NEVER use --forceExit to fix open handles. Find and close the handle
- NEVER skip a failing test with .skip or xit without a linked issue in a comment
- NEVER mock React, react-dom, or @testing-library/* (they are runtime dependencies)
- NEVER write a test that asserts implementation details (private methods, internal state)

Three rules in this template prevent the most common Claude Code failures.

The mocking rule matters because jest.mock and jest.spyOn solve different problems. jest.mock replaces the entire module before imports run. jest.spyOn wraps a single method on an already-imported object. Without the rule, Claude reaches for jest.mock even when the goal is to observe a real method, producing tests that pass but no longer exercise the real code path.

The async rule kills the largest class of flaky tests. done callbacks predate async/await and are still everywhere in Stack Overflow answers. They allow a test to pass if the assertion runs before done is called and fail intermittently when timing shifts. async/await makes the test deterministic.

The forceExit rule is the most boring and most important. --forceExit makes Jest kill the process after the test run regardless of dangling timers, sockets, or database connections. It hides bugs. Once it is in package.json, every future test session inherits the same blindness.

For TypeScript-specific patterns, the Claude Code TypeScript guide covers tsconfig strictness rules that pair with Jest's isolatedModules requirement.

jest.config.ts patterns that work

Once CLAUDE.md is in place, give Claude a real config to extend. A reference config lets it pattern-match against your actual transformer setup instead of generating from scratch.

// jest.config.ts
import type { Config } from 'jest'

const config: Config = {
  preset: undefined,
  testEnvironment: 'jsdom',
  setupFilesAfterEach: ['<rootDir>/jest.setup.ts'],

  extensionsToTreatAsEsm: ['.ts', '.tsx'],
  moduleNameMapper: {
    '^(\\.{1,2}/.*)\\.js
    
    Featured on Dofollow.Tools
    AI Toolz Dir
  

: '$1',
    '^@/(.*)
    
    Featured on Dofollow.Tools
    AI Toolz Dir
  

: '<rootDir>/src/$1',
    '^\\$lib/(.*)
    
    Featured on Dofollow.Tools
    AI Toolz Dir
  

: '<rootDir>/src/lib/$1',
  },

  transform: {
    '^.+\\.(t|j)sx?
    
    Featured on Dofollow.Tools
    AI Toolz Dir
  

: ['@swc/jest', {
      jsc: {
        parser: { syntax: 'typescript', tsx: true, decorators: false },
        transform: { react: { runtime: 'automatic' } },
        target: 'es2022',
      },
      module: { type: 'es6' },
    }],
  },

  testMatch: [
    '<rootDir>/src/**/__tests__/**/*.test.{ts,tsx}',
    '<rootDir>/src/**/*.test.{ts,tsx}',
  ],

  clearMocks: true,
  resetMocks: false,
  restoreMocks: false,

  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{ts,tsx}',
    '!src/types/**',
    '!src/generated/**',
    '!src/**/index.ts',
  ],

  coverageThreshold: {
    global: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
  },

  coverageReporters: ['text', 'lcov', 'html'],

  testTimeout: 10_000,
}

export default config

A few elements are worth highlighting.

The extensionsToTreatAsEsm array plus the moduleNameMapper entry that strips .js extensions are the ESM workaround. Jest still has rough edges with native ESM in 2026, even though Jest 30 improved support. TypeScript projects that emit ESM use .js extensions in import statements. The mapper turns ./foo.js back into ./foo so Jest can resolve it. Without this pair, Claude generates a config that fails on the first ESM import.

clearMocks: true resets mock call history between tests. resetMocks: false keeps mock implementations intact. restoreMocks: false preserves spies set up with jest.spyOn. Call counts reset, but the mock setup does not, which is what most teams want.

The collectCoverageFrom list excludes generated files, story files, type-only files, and barrel index.ts re-exports. Barrels show up as zero-coverage because they have no executable logic, dragging the overall percentage down without signal.

testTimeout: 10_000 is more forgiving than the 5s default. Component tests that wait for state transitions sometimes need it, and a longer timeout is cheaper than intermittent CI failures.

For environment variables in tests, configure .env.test and load it in jest.setup.ts via dotenv/config. Tests that touch process.env should snapshot and restore the original value to avoid bleed between specs.

Mocking conventions: jest.mock vs jest.spyOn

The single largest source of confusing Jest behaviour is the difference between jest.mock and jest.spyOn. They look similar in test files. They are not the same.

jest.mock(moduleName) is hoisted to the top of the file before any imports execute. The entire module is replaced with an auto-generated mock (or a factory you provide). All imports of that module see the mock for the duration of the file.

// src/services/userService.test.ts
import { fetchUser } from './userService'
import { apiClient } from '../api/client'

jest.mock('../api/client', () => ({
  apiClient: {
    get: jest.fn(),
  },
}))

describe('fetchUser', () => {
  it('returns user data on success', async () => {
    (apiClient.get as jest.Mock).mockResolvedValue({
      data: { id: '1', name: 'Hugo' },
    })

    const user = await fetchUser('1')

    expect(apiClient.get).toHaveBeenCalledWith('/users/1')
    expect(user).toEqual({ id: '1', name: 'Hugo' })
  })
})

The factory inside jest.mock cannot reference variables defined later in the file. Jest hoists the call but not the surrounding scope. To vary mock behaviour per test, use jest.fn() placeholders and configure them inside beforeEach or it blocks.

jest.spyOn(object, methodName) wraps a single method on an existing object and preserves the original implementation by default. Use it when you want to observe a real call, not replace it.

// src/services/analytics.test.ts
import { analytics } from './analytics'
import { logger } from '../utils/logger'

describe('analytics', () => {
  let logSpy: jest.SpyInstance

  beforeEach(() => {
    logSpy = jest.spyOn(logger, 'info').mockImplementation()
  })

  afterEach(() => {
    logSpy.mockRestore()
  })

  it('logs the event with payload', () => {
    analytics.track('signup', { plan: 'pro' })

    expect(logSpy).toHaveBeenCalledWith(
      'analytics:track',
      expect.objectContaining({ event: 'signup', plan: 'pro' })
    )
  })
})

mockImplementation() with no argument silences the original logger.info. mockRestore() in afterEach puts the real implementation back, which is essential when other tests in the same file rely on real logging.

The decision rule for CLAUDE.md is straightforward. Use jest.mock when you want to replace a whole module before any code runs (network clients, file system, external services). Use jest.spyOn when you want to observe or selectively override a method on an existing object. Without the rule, Claude defaults to jest.mock for everything, and tests stop verifying the integration paths they should be exercising.

For React projects, prefer rendering real components and mocking only the network or storage layer beneath them. Mocking child components hides integration bugs that only surface in real composition.

Async test patterns: resolves, rejects, waitFor, fake timers

Async testing in Jest has four good patterns and several bad ones. Claude defaults to a mix unless CLAUDE.md narrows the choice.

Pattern 1: async/await with explicit assertions. The simplest and the default.

it('saves the draft', async () => {
  const draft = await saveDraft({ title: 'Hello' })

  expect(draft.id).toBeDefined()
  expect(draft.title).toBe('Hello')
})

Pattern 2: expect(...).resolves and expect(...).rejects. Compact for one-liners.

it('resolves with the draft', async () => {
  await expect(saveDraft({ title: 'Hello' })).resolves.toMatchObject({
    title: 'Hello',
  })
})

it('rejects when the title is empty', async () => {
  await expect(saveDraft({ title: '' })).rejects.toThrow('Title is required')
})

The leading await is mandatory. Without it, the test passes regardless of whether the promise resolves or rejects, because Jest never waits for the assertion. This is one of the most common Claude-generated test bugs and one of the most invisible. Make await expect(...).resolves and await expect(...).rejects a hard CLAUDE.md rule.

Pattern 3: waitFor for async UI state. From @testing-library/react.

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

it('shows the welcome message after submit', async () => {
  const user = userEvent.setup()
  render(<SignupForm />)

  await user.type(screen.getByLabelText(/email/i), 'hugo@example.com')
  await user.click(screen.getByRole('button', { name: /sign up/i }))

  await waitFor(() => {
    expect(screen.getByText(/welcome/i)).toBeInTheDocument()
  })
})

waitFor retries the callback until it passes or the timeout fires. The default is 1000ms in @testing-library/react 16.x. For slower assertions (network round trips), pass { timeout: 5000 }. Do not wrap synchronous expect calls in waitFor: it adds polling overhead and obscures intent.

Pattern 4: Fake timers for time-dependent code.

describe('debounce', () => {
  beforeEach(() => {
    jest.useFakeTimers()
  })

  afterEach(() => {
    jest.useRealTimers()
  })

  it('calls fn once after rapid invocations', () => {
    const fn = jest.fn()
    const debounced = debounce(fn, 200)

    debounced('a')
    debounced('b')
    debounced('c')

    jest.advanceTimersByTime(200)

    expect(fn).toHaveBeenCalledTimes(1)
    expect(fn).toHaveBeenCalledWith('c')
  })
})

The matching useRealTimers in afterEach prevents fake timers from leaking into other tests in the same file. Without it, an unrelated test that uses real setTimeout will hang. Claude regularly forgets the cleanup; make it a hard CLAUDE.md rule.

Anti-patterns to forbid: done callbacks (use async/await), arbitrary setTimeout waits (use waitFor), and await new Promise(r => setImmediate(r)) to flush microtasks (the test is racing the implementation, not waiting for it).

For debugging async test failures, run with --verbose to see the test tree and --detectOpenHandles to locate the timer or socket that is keeping the process alive.

Coverage and snapshot trade-offs

Coverage is useful as a directional signal, not a target. Snapshots are useful for stable output, dangerous for everything else. Both are easy to misconfigure with Claude generating code.

Coverage thresholds. The coverageThreshold block fails the Jest run when coverage drops below the configured percentages. Set thresholds at the level you meet today, not aspirational 100%. A threshold you cannot meet creates a culture of skipping tests to land changes.

coverageThreshold: {
  global: {
    statements: 80,
    branches: 75,
    functions: 80,
    lines: 80,
  },
  './src/payments/': {
    statements: 95,
    branches: 90,
    functions: 95,
    lines: 95,
  },
}

Per-directory overrides let you require higher coverage in critical paths (payments, auth, billing) than in the rest of the codebase. Claude generates the global block correctly and skips per-directory overrides unless you have an example.

The coverageReporters array controls output formats. text prints to stdout. lcov produces the file format that Codecov, Coveralls, and SonarQube ingest. html generates a browseable report in coverage/lcov-report/index.html.

Snapshot tests. A snapshot test serialises a value to a file and asserts future runs produce the same output. They are excellent for stable output (API contracts, error messages, generated SQL) and bad for unstable output (React components with timestamps, IDs, or randomised content).

The trap is that snapshots feel productive. One line of test code (expect(value).toMatchSnapshot()) covers what would otherwise be ten lines of explicit assertions. Claude leans on them because they look thorough. The hidden cost is review fatigue: every PR with snapshot changes gets a glance rather than a read.

The CLAUDE.md rule should be specific: snapshot tests are allowed only for output that is deterministic (no Date, no Math.random, no monotonic IDs). Component snapshots must use @testing-library/react's output, never react-test-renderer raw trees. Treat a snapshot diff like a code diff.

// Acceptable snapshot
it('serialises the API error', () => {
  const error = new ApiError('NOT_FOUND', { resource: 'user', id: '1' })
  expect(error.toJSON()).toMatchSnapshot()
})

// Bad snapshot (component output with hooks and timestamps)
it('renders the dashboard', () => {
  const { container } = render(<Dashboard />)
  expect(container).toMatchSnapshot()
})

For framework-specific testing, Claude Code with Next.js covers Jest setup with the Next.js test transformer.

Hard rules and permission hooks

Jest commands fall into two groups: safe commands and destructive commands. The destructive set updates snapshot files, regenerates coverage baselines, or wipes test caches. Gating them in .claude/settings.local.json prevents Claude from reaching for them as flailing fixes.

{
  "permissions": {
    "allow": [
      "Bash(pnpm test*)",
      "Bash(pnpm test:unit*)",
      "Bash(pnpm test:integration*)",
      "Bash(pnpm test:ci*)",
      "Bash(pnpm test -- --coverage*)"
    ],
    "deny": [
      "Bash(pnpm test -- -u*)",
      "Bash(pnpm test -- --updateSnapshot*)",
      "Bash(pnpm test -- --forceExit*)",
      "Bash(jest --clearCache*)",
      "Bash(rm -rf coverage*)"
    ]
  }
}

The --updateSnapshot deny is the load-bearing one. An automated pnpm test -u run rewrites every snapshot to match the current output, silently fixing broken snapshots that were the test, not the bug. Gating snapshot updates means Claude surfaces the change for human approval. The --forceExit deny matches the CLAUDE.md hard rule: any time --forceExit shows up in a script, the team has accepted that test cleanup is broken. The --clearCache deny is precautionary; clearing the cache is occasionally necessary (after a transformer upgrade) but rarely the right move during normal test debugging.

For permission hook patterns across project types, Claude Code best practices covers the principles regardless of test runner. The role of CLAUDE.md across all of these is covered in CLAUDE.md explained.

The patterns above reduce to a list of mandatory rules that belong at the top of every Jest CLAUDE.md:

  1. Use describe/it, never describe/test. Pick one and stay consistent.
  2. async/await for all async tests. Never done callbacks.
  3. Always await expect(...).resolves and expect(...).rejects.
  4. jest.mock for whole-module replacement. jest.spyOn for single-method observation.
  5. Match every jest.useFakeTimers() with jest.useRealTimers() in afterEach.
  6. Snapshot tests only for deterministic output. Never for components with timestamps or IDs.
  7. clearMocks: true in config. Reset call history, preserve implementations.
  8. Coverage thresholds set to the level you meet today, not aspirational. Per-directory overrides for critical paths.
  9. Never use --forceExit. Find and close the open handle.
  10. Deny --updateSnapshot, --forceExit, and snapshot cache wipes in permission hooks.

These rules prevent the most common Jest failures in Claude Code sessions. They produce tests that fail when the code is wrong and pass when it is right, snapshots that catch real changes, coverage that means something, and a config that does not break the moment you switch from CommonJS to ESM.

Jest is in a slow but steady transition. ESM support is usable but not seamless. Native TypeScript support via @swc/jest has effectively replaced ts-jest for new projects. Jest 30 brings the runtime closer to standards while preserving the API that has shipped to millions of repos. The CLAUDE.md template above is stable across that transition because it focuses on conventions, not on which transformer is current. Claudify includes a Jest-specific CLAUDE.md template pre-configured for Jest 30, the swc transformer, and the testing-library patterns above.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir