Jest Mocking in Depth: Module Mocks vs spyOn, Async Pitfalls, Snapshots and Test Isolation
Key takeaways
This is a mocking- and configuration-focused Jest reference: why module mocks, spies, and manual mocks behave differently, when snapshots help versus hurt, the async and fake-timer mistakes that produce flaky suites, how test isolation actually breaks, and how to reason about coverage thresholds instead of copy-pasting them.
Jest ships four different ways to fake a dependency — jest.fn(), jest.mock(), jest.spyOn(), and manual __mocks__ files — and picking the wrong one is the single most common source of confusing test failures. This guide is deliberately narrower and deeper than a typical “getting started” walkthrough: it assumes you already know how to write a describe/it block, and instead focuses on the decisions that separate a test suite that stays green for the right reasons from one that’s green because it’s accidentally testing nothing. If you want a guided, narrative tour from zero to a working CI pipeline first, see the Jest Testing Guide; come back here when you need to reason about why a mock isn’t behaving the way you expect, or why a threshold config keeps rejecting a PR that looks fine.
The throughline across every section below is the same question: does this technique verify behavior, or does it just verify that the code ran? Mocks that are too permissive, snapshots that are too broad, and coverage numbers pursued for their own sake all fail that test in the same way — they make the suite pass without making the suite trustworthy.
Setup
Configuration choices here are not cosmetic — testEnvironment, clearMocks, and collectCoverageFrom each change what a green checkmark actually means. testEnvironment: 'node' runs tests in a plain V8 context with no DOM; switch to 'jsdom' only when a test actually touches document, window, or a library that assumes a browser global exists, because jsdom is measurably slower to boot per test file and pulling it in for pure-logic tests just taxes CI for nothing. clearMocks: true resets call history (toHaveBeenCalled, mock.calls) before every test but leaves mockImplementation/mockReturnValue intact — that distinction matters the moment two tests in the same file configure a mock differently, which is covered in the isolation section further down.
npm install -D jest @types/jest ts-jest
# Or for projects with Babel
npm install -D jest babel-jest @babel/preset-env
# For React testing
npm install -D jest jsdom @testing-library/react @testing-library/jest-dom
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node', // 'jsdom' for browser/React tests
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts', '**/*.spec.ts'],
clearMocks: true, // Clear mock call history between tests
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/index.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
export default config;
// package.json scripts
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage"
}
}
Test Structure
// src/math.test.ts
import { add, multiply, divide } from './math';
// describe groups related tests
describe('Math utilities', () => {
// it (alias: test) defines individual test cases
it('adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('multiplies two numbers', () => {
expect(multiply(4, 5)).toBe(20);
});
describe('divide', () => {
it('divides two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('throws on division by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
});
// Lifecycle hooks
describe('with setup', () => {
let db: Database;
beforeAll(async () => {
db = await Database.connect(); // Once before all tests
});
afterAll(async () => {
await db.disconnect(); // Once after all tests
});
beforeEach(() => {
db.clear(); // Before each test
});
afterEach(() => {
// cleanup after each test
});
});
Matchers
// Equality
expect(value).toBe(42); // === (primitives)
expect(obj).toEqual({ a: 1 }); // deep equality (objects/arrays)
expect(obj).toStrictEqual({ a: 1 }); // deep + checks undefined properties
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(0.1 + 0.2).toBeCloseTo(0.3, 5); // floating point
expect(value).toBeGreaterThan(3);
expect(value).toBeLessThanOrEqual(10);
// Strings
expect(str).toMatch(/pattern/);
expect(str).toContain('substring');
expect(str).toHaveLength(5);
// Arrays
expect(arr).toContain('item');
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining(['a', 'b']));
// Objects
expect(obj).toHaveProperty('key');
expect(obj).toHaveProperty('nested.key', 'value');
expect(obj).toMatchObject({ name: 'Alice' }); // partial match
// Errors
expect(() => fn()).toThrow();
expect(() => fn()).toThrow(Error);
expect(() => fn()).toThrow('specific message');
expect(() => fn()).toThrow(/pattern/);
// Negation
expect(value).not.toBe(0);
expect(arr).not.toContain('x');
Async Testing
// async/await (preferred)
it('fetches user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});
// Promise return
it('resolves correctly', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('Alice');
});
});
// Async errors
it('rejects on invalid ID', async () => {
await expect(fetchUser(-1)).rejects.toThrow('User not found');
await expect(fetchUser(-1)).rejects.toMatchObject({ code: 404 });
});
// Assertion count — ensures async assertions actually run
it('multiple async assertions', async () => {
expect.assertions(2); // Fail if not exactly 2 assertions run
const user = await fetchUser(1);
expect(user.id).toBe(1);
expect(user.name).toBeDefined();
});
// Timers
it('calls callback after delay', () => {
jest.useFakeTimers();
const callback = jest.fn();
setTimeout(callback, 1000);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});
Async Pitfalls That Produce False Positives
The most dangerous Jest failure mode isn’t a red test — it’s a test that should fail but doesn’t, because the assertion never actually ran. This happens constantly with promises: if you call an async function inside it() without await-ing it or return-ing the promise, Jest considers the test function synchronous, marks it “done” the instant it returns, and any rejection or failed expect() inside that dangling promise fires after the test has already reported green. The fix is mechanical — always await or return — but the reason it matters is that this bug is invisible in a diff review; the test file looks correct, and it only shows up as “expect was never called” in a rare CI log nobody reads. That’s exactly why expect.assertions(n) exists: it makes Jest fail the test if the exact number of assertions you expect doesn’t run, which turns a silent false positive into a loud, explicit failure. Reach for it any time a test has more than one code path (a try/catch around an async call, a .catch() handler) where an assertion could be skipped without anyone noticing.
Fake timers have their own gotcha: jest.useFakeTimers() replaces the global timer functions, so any code that already captured a reference to the real setTimeout before the mock was installed — a common pattern in libraries that cache globalThis.setTimeout at module load — keeps running on real time regardless of advanceTimersByTime. Modern Jest’s 'modern' fake timer implementation (the default since Jest 27) also mocks performance.now() and Date, which is usually what you want but occasionally breaks code that measures elapsed wall-clock time for logging; if a suite starts hanging or a duration-based assertion becomes flaky right after adopting fake timers, that’s the first thing to check. Always pair useFakeTimers() with useRealTimers() in an afterEach, not just at the end of the test body — if an earlier assertion throws, the real-timer restoration never runs, and every test after it in the file silently inherits fake time.
Mock Functions
// Create a mock function
const mockFn = jest.fn();
mockFn(1, 'hello');
mockFn(2, 'world');
// Inspect calls
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith(1, 'hello');
expect(mockFn).toHaveBeenLastCalledWith(2, 'world');
expect(mockFn).toHaveBeenNthCalledWith(1, 1, 'hello');
// Return values
const mockGet = jest.fn()
.mockReturnValue('default') // Always returns this
.mockReturnValueOnce('first call') // First call
.mockReturnValueOnce('second call'); // Second call
// Async return
const mockFetch = jest.fn()
.mockResolvedValue({ data: 'ok' }) // Always resolves
.mockRejectedValueOnce(new Error('fail')); // First call rejects
// Implementation
const mockCalc = jest.fn().mockImplementation((a, b) => a + b);
// Access call data
console.log(mockFn.mock.calls); // [[1, 'hello'], [2, 'world']]
console.log(mockFn.mock.results); // [{ type: 'return', value: ... }]
console.log(mockFn.mock.instances); // 'this' for each call
Module Mocking
// Auto-mock entire module
jest.mock('./database');
// Factory function mock — control implementation
jest.mock('./email-service', () => ({
sendEmail: jest.fn().mockResolvedValue({ success: true }),
sendBulk: jest.fn().mockResolvedValue({ sent: 100 }),
}));
// Partial mock — keep some real implementations
jest.mock('./utils', () => ({
...jest.requireActual('./utils'), // Keep real implementations
formatDate: jest.fn().mockReturnValue('2026-01-01'), // Override this one
}));
// Example: testing a service that depends on a mocked module
import { sendEmail } from './email-service';
import { UserService } from './user-service';
describe('UserService', () => {
it('sends welcome email on registration', async () => {
const service = new UserService();
await service.register({ email: '[email protected]', name: 'Alice' });
expect(sendEmail).toHaveBeenCalledWith({
to: '[email protected]',
subject: 'Welcome!',
body: expect.stringContaining('Alice'),
});
});
});
Mocking Node.js Built-ins
// Mock fs
jest.mock('fs/promises');
import { readFile, writeFile } from 'fs/promises';
const mockReadFile = readFile as jest.MockedFunction<typeof readFile>;
mockReadFile.mockResolvedValue('file content' as any);
// Mock path (usually not needed, but possible)
jest.mock('path', () => ({
...jest.requireActual('path'),
join: jest.fn().mockReturnValue('/mocked/path'),
}));
Manual Mocks (__mocks__ Directory)
// src/services/__mocks__/email-service.ts
// Jest auto-resolves this file whenever a test calls jest.mock('./email-service')
// with no factory argument — no need to inline the fake implementation per test file.
export const sendEmail = jest.fn().mockResolvedValue({ success: true });
export const sendBulk = jest.fn().mockResolvedValue({ sent: 0 });
A manual mock is the right tool when several test files need the same fake behavior for a module — a factory function repeated in ten files is ten places that drift out of sync the next time the real module’s shape changes. The trade-off is discoverability: a developer reading user-service.test.ts sees jest.mock('./email-service') with no arguments and has to know to go looking in __mocks__/ to find out what actually happens when sendEmail is called. For node_modules packages, Jest requires the __mocks__ directory to sit adjacent to node_modules at the project root (not next to the package itself), which is a common source of “why isn’t my mock being picked up” confusion — the auto-mock resolution rules differ between user modules and packages specifically for this reason.
Choosing Between jest.fn(), jest.mock(), and jest.spyOn()
These three exist for genuinely different situations, and reaching for the wrong one is why so many Jest suites end up over-mocked or under-mocked. jest.fn() creates a mock with no backing implementation at all — use it for callbacks and injected dependencies where there’s no “real” version to preserve, like a component’s onClick prop in a render test. jest.mock() replaces an entire module in the require/import cache, which is appropriate when the dependency is something you never want to execute for real in a unit test — a network client, an email sender, a payment gateway — because even one accidental real call during a test run is a production incident waiting to happen. jest.spyOn() sits in between: it wraps a method on a real object while still executing the original implementation unless you explicitly call .mockImplementation() or .mockResolvedValue() on it, which makes it the right choice when you want to observe a call (was console.error invoked? was analytics.track called with the right payload?) without giving up the module’s real behavior for everything else. A good heuristic: if a full module replacement would still leave 90% of the module’s real code path untested, that’s a signal spyOn on the one method you care about is the better fit — it keeps the rest of the module honest.
jest.spyOn
import * as emailModule from './email-service';
describe('UserService', () => {
it('sends email without mocking the whole module', async () => {
// Spy on a specific method — original implementation runs unless overridden
const spy = jest.spyOn(emailModule, 'sendEmail')
.mockResolvedValue({ success: true });
const service = new UserService();
await service.register({ email: '[email protected]', name: 'Alice' });
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
to: '[email protected]',
}));
spy.mockRestore(); // Restore original implementation
});
});
// Spy on class methods
class Calculator {
add(a: number, b: number) { return a + b; }
}
const calc = new Calculator();
const spy = jest.spyOn(calc, 'add');
calc.add(1, 2);
expect(spy).toHaveBeenCalledWith(1, 2);
Snapshot Testing
// Snapshot captures serializable output and fails if it changes
it('renders user profile correctly', () => {
const profile = generateUserProfile({ name: 'Alice', role: 'admin' });
expect(profile).toMatchSnapshot();
// On first run: creates __snapshots__/user.test.ts.snap
// On subsequent runs: compares against saved snapshot
});
// Inline snapshot — snapshot stored in the test file
it('formats price correctly', () => {
expect(formatPrice(1234.56, 'USD')).toMatchInlineSnapshot(
`"$1,234.56"`
);
});
// Update snapshots when intentional changes occur
// jest --updateSnapshot (or jest -u)
When Snapshots Help and When They Hurt
Snapshot testing trades assertion precision for authoring speed: instead of writing out every field you expect, you capture whatever the code currently produces and let Jest diff future runs against it. That speed is genuine and worth having for output that’s tedious to hand-assert — a formatted price string, a small serialized config object, a CLI’s help text. The cost shows up later, and it’s a specific failure mode: a snapshot doesn’t tell you whether a change is correct, only whether it’s different. On a component or object with a lot of surface area, an unrelated change three files away regenerates a sprawling snapshot diff, and the habit that forms under deadline pressure is running jest -u without actually reading what changed — at which point the snapshot has stopped verifying anything and become a formality that happens to pass.
The practical rule that keeps snapshots useful: keep them small and specific. toMatchInlineSnapshot() is better than toMatchSnapshot() for exactly this reason — the expected value sits in the test file itself, so a reviewer sees the actual diff in the PR instead of a separate .snap file that’s easy to approve unread. For a full React component tree, snapshotting container.firstChild on every render test accumulates hundreds of lines of brittle markup that breaks on any styling or attribute change unrelated to the behavior under test; prefer targeted RTL assertions (getByRole, toBeInTheDocument) for behavior, and reserve snapshots for genuinely presentational output you want to be notified about changing, not output you’re confident is correct today.
Test Isolation and Shared State
// A classic isolation bug: module-level state survives across tests in the same file
let requestCount = 0;
function trackRequest() {
requestCount += 1;
return requestCount;
}
describe('trackRequest', () => {
it('returns 1 on first call', () => {
expect(trackRequest()).toBe(1);
});
it('returns 1 again if state were reset', () => {
// Fails as written — requestCount persisted from the previous test.
// Fix: reset shared state explicitly, don't rely on module re-evaluation.
requestCount = 0;
expect(trackRequest()).toBe(1);
});
});
Jest isolates modules per test file, not per test case within a file — a module-level variable, an in-memory cache, or a mock’s configured return value all persist from one it() block to the next unless something explicitly resets them. This is the source of the classic “tests pass individually but fail when run together” bug report: each test looks correct in isolation, but the second one is silently depending on state the first one left behind, and reordering tests (or running with --randomize, which Jest supports specifically to surface this) breaks the suite.
beforeEach versus beforeAll is the lever for controlling this, and the choice should track cost versus safety rather than habit. beforeAll runs once and is the right call for expensive, read-only setup — opening a database connection, starting an in-memory server — because paying that cost per-test would make the suite unusably slow. beforeEach runs before every test and is the right call for anything a test might mutate — clearing a table, resetting a mock’s call history, reassigning a fresh object — because leaving mutation to beforeAll means test order becomes load-bearing. The clearMocks/resetMocks/restoreMocks config options from the Setup section are really this same trade-off applied to mocks specifically: clearMocks (cheap, resets call history only) is usually enough, but if a test configures mockReturnValueOnce or mockImplementation and a later test in the same file doesn’t expect that override to still be active, resetMocks: true is what actually fixes it — clearMocks alone won’t, because it doesn’t touch implementations, only recorded calls.
// React component snapshots with Testing Library
import { render } from '@testing-library/react';
import { Button } from './Button';
it('matches snapshot', () => {
const { container } = render(<Button variant="primary">Click me</Button>);
expect(container.firstChild).toMatchSnapshot();
});
Testing React Components
// jest.config.ts for React
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterFramework: ['<rootDir>/src/setupTests.ts'],
};
// src/setupTests.ts
import '@testing-library/jest-dom'; // Adds custom matchers
// src/components/Button.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when loading', () => {
render(<Button loading>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
Testing Async Components
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
jest.mock('../api/users', () => ({
fetchUser: jest.fn().mockResolvedValue({
id: 1,
name: 'Alice',
email: '[email protected]',
}),
}));
it('loads and displays user data', async () => {
render(<UserProfile userId={1} />);
// Initially shows loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for async update
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
it('shows error on fetch failure', async () => {
const { fetchUser } = require('../api/users');
fetchUser.mockRejectedValueOnce(new Error('Network error'));
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('Failed to load user')).toBeInTheDocument();
});
});
Custom Matchers
// src/matchers/index.ts
expect.extend({
toBeWithinRange(received: number, floor: number, ceiling: number) {
const pass = received >= floor && received <= ceiling;
if (pass) {
return {
message: () =>
`expected ${received} not to be within range ${floor} - ${ceiling}`,
pass: true,
};
} else {
return {
message: () =>
`expected ${received} to be within range ${floor} - ${ceiling}`,
pass: false,
};
}
},
});
// Usage
expect(50).toBeWithinRange(1, 100); // passes
expect(150).toBeWithinRange(1, 100); // fails
// TypeScript: declare the custom matcher
declare global {
namespace jest {
interface Matchers<R> {
toBeWithinRange(floor: number, ceiling: number): R;
}
}
}
Code Coverage
# Run coverage
jest --coverage
# Output:
# ----------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# ----------|---------|----------|---------|---------|
# All files | 87.5 | 75.0 | 90.0 | 87.5 |
# math.ts | 100.0 | 100.0 | 100.0 | 100.0 |
# user.ts | 75.0 | 50.0 | 80.0 | 75.0 |
// jest.config.ts — coverage configuration
const config: Config = {
collectCoverage: false, // Don't collect by default (use --coverage flag)
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{ts,tsx}',
'!src/index.ts',
],
coverageReporters: ['text', 'lcov', 'html'],
coverageDirectory: 'coverage',
coverageThreshold: {
global: {
statements: 80,
branches: 70,
functions: 80,
lines: 80,
},
// Per-file threshold
'./src/critical/': {
statements: 95,
},
},
};
Reasoning About Thresholds Instead of Copying Them
An 80 across the board is a default people copy from blog posts, not a number derived from anything about the codebase it’s applied to — and a global threshold that’s too aggressive produces a predictable failure mode: contributors write low-value tests (asserting a getter returns what was just set, calling a function purely so the line is “covered”) specifically to satisfy Istanbul’s instrumentation rather than to verify behavior. That’s worse than no threshold, because it burns effort on tests that would never catch a real regression while making the coverage number look healthy. The branches threshold is usually the one worth taking seriously and statements/lines the ones worth relaxing — a function can execute every line while only ever hitting its happy-path branch, so branch coverage is a much better proxy for “the failure paths are actually exercised” than statement coverage is.
Per-file overrides ('./src/critical/' above) exist because a single global number is the wrong shape for most real codebases — a payment calculation or an auth check deserves a stricter bar than a logging utility or a CLI argument parser, and forcing both to the same threshold either under-tests the critical path or wastes effort over-testing the trivial one. collectCoverageFrom’s exclusion globs matter for the same reason: generated code, type-only .d.ts files, and Storybook stories inflate the denominator with lines nobody intends to test, which quietly drags the percentage down and pressures people to write filler tests just to keep the number where it was. Coverage is also worth treating as a floor, not a target — it proves lines executed, not that the assertions checking them are meaningful, which is why it pairs best with code review attention to what a new test actually asserts rather than trust placed in the percentage alone.
Running Tests in CI
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Run tests
run: npm run test:ci
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
// package.json — CI test script
{
"scripts": {
"test:ci": "jest --ci --coverage --maxWorkers=2"
}
}
--ci flag: fails on new snapshots (don’t create them in CI), disables interactive mode.
Common Patterns
Testing Pure Functions
import { calculateTax, formatCurrency } from './finance';
describe('calculateTax', () => {
test.each([
[100, 0.1, 10],
[200, 0.2, 40],
[0, 0.1, 0],
])('calculateTax(%d, %d) = %d', (amount, rate, expected) => {
expect(calculateTax(amount, rate)).toBe(expected);
});
});
Testing with Environment Variables
describe('config', () => {
const originalEnv = process.env;
beforeEach(() => {
jest.resetModules(); // Clear module cache (important for env-dependent modules)
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('uses production DB in prod env', () => {
process.env.NODE_ENV = 'production';
process.env.DB_URL = 'postgres://prod-host/db';
const { config } = require('./config'); // Fresh require after resetModules
expect(config.dbUrl).toBe('postgres://prod-host/db');
});
});
Database Integration Tests
// Use a real test database — don't mock the DB layer
import { db } from '../lib/db';
import { UserRepository } from './user-repository';
describe('UserRepository', () => {
beforeEach(async () => {
await db.user.deleteMany(); // Clean state
});
afterAll(async () => {
await db.$disconnect();
});
it('creates and retrieves a user', async () => {
const repo = new UserRepository(db);
const created = await repo.create({ email: '[email protected]', name: 'Alice' });
expect(created.id).toBeDefined();
expect(created.email).toBe('[email protected]');
const found = await repo.findById(created.id);
expect(found).toEqual(created);
});
});
Internals Snapshot (Runner, Mocks, Coverage)
The behavior documented in every section above traces back to a few implementation details worth knowing explicitly, since they explain why the mechanics work the way they do rather than just that they do:
- Runner: Jest maintains its own haste map (a cache of module locations and dependency edges) for fast incremental test discovery, spawns worker child processes via
jest-workerfor parallelism, and runs each test file in a sandboxed environment (node,jsdom, or a custom one). This is why one test file’s global mutations never leak into another file’s run — but also why the isolation stops at the file boundary, not the test boundary, which is the root cause behind the shared-state bugs covered above. - Mocks:
jest.mock()calls are hoisted above imports by a Babel transform before the file even runs, which is why a mock factory can referencejest.fn()before the import statement that uses it appears to execute — and also why referencing an outer-scope variable inside a factory throws a “cannot access before initialization” error unless the variable name starts withmock. - Coverage: Istanbul instruments source files at transform time, inserting counters around every statement and branch before the test runs. This is purely a static rewrite — it has no awareness of what an assertion actually checks, which is the mechanical reason coverage percentage and test quality are two different numbers.
For browser-driven end-to-end coverage that Jest doesn’t attempt to provide, see the Playwright E2E Testing Guide. For a guided, narrative walkthrough of Jest from a first test to a working CI pipeline, see the Jest Testing Guide.
Further Reading
- Jest Testing Guide — a narrative, first-person walkthrough covering setup and CI from scratch, complementary to this reference.
- Vitest in Practice — a largely API-compatible alternative built on Vite, worth it for projects already on Vite tooling.
- Testing React Components Like a User — the user-centric query patterns (
getByRole,findBy*) referenced in the component-testing sections above. - GitHub Actions CI/CD Guide — for running the
--ci --coverageworkflow shown above as part of a full pipeline. - Playwright E2E Testing Guide — for the browser-level checks that unit and component tests intentionally don’t cover.