본문으로 건너뛰기
Previous
Next
Jest Testing Guide | Unit Tests· Mocks

Jest Testing Guide | Unit Tests· Mocks

Jest Testing Guide | Unit Tests· Mocks

이 글의 핵심

This guide walks through Jest from first test to coverage thresholds and React component tests. It also explains how the runner schedules work, how module mocking is hoisted, how Istanbul instruments code for coverage, and how teams combine unit tests with staging E2E checks before production.

Introduction

I treat Jest as my default batteries-included test runner for JavaScript and TypeScript. As of early 2025, I’ve been using it on new services and on codebases I inherited from the Mocha era—it still ships with assertions, mock utilities, Istanbul-backed coverage, snapshots, and a watch mode I lean on when I’m iterating. Meta originally built Jest for React; I use it the same way a lot of teams do: for Node services, CLIs, libraries, and full-stack applications.

Why I keep choosing it

  • Zero-config defaults: I get sensible Node and jsdom defaults without a week of setup when I spin up a package.
  • Parallel execution: Test files run in worker processes; on multi-core machines my larger suites actually finish in a tolerable time.
  • Isolated modules: Each test file gets a fresh module registry by default (I only tune resetModules when I have a reason), which keeps cross-test pollution in check as long as I’m disciplined with mocks.
  • Rich matcher API: I like readable expectations—toEqual, resolves, object snapshots—because reviewers (including me, three weeks later) can scan failures quickly.
  • Ecosystem: I wire TypeScript through Babel or ts-jest, reach for React Testing Library for components, and when I move a front end to Vite I still reach for Vitest sometimes—the APIs still feel like home.

What I use it for

  • Unit tests for pure functions, reducers, validators, and domain rules.
  • Integration tests that hit an in-memory database, an HTTP server in the same process, or a temp file system.
  • Component tests for React (or similar) with Testing Library; I line up user-centric queries with Jest’s async style.
  • Contract-style checks on module boundaries when I mock only the slow or non-deterministic edges—network, clock, randomness.

For a longer reference with alternative patterns, I still send people to the [Jest Complete Guide](/en/blog/jest-complete-guide/ when they want more than I cover here.


Installation and setup

When I bootstrap a test slice in a repo, I almost always add Jest the same way: install the runner, add scripts, then tighten jest.config once the first tests exist. I prefer not to over-configure before I know whether I’m on Node-only, jsdom, or a monorepo with multiple environments.

Method 1: npm with a minimal project

npm init -y
npm install -D jest @types/jest

I add scripts to package.json:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:ci": "jest --ci --coverage --maxWorkers=2"
  }
}

Method 2: jest.config.js at the repository root

// jest.config.js
/** @type {import('jest').Config} */
module.exports = {
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
  moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json'],
  collectCoverageFrom: ['src/**/*.{js,ts,tsx}', '!src/**/*.d.ts'],
  coverageDirectory: 'coverage',
  clearMocks: true,
  resetMocks: true,
};

Method 3: TypeScript with ts-jest

npm install -D jest ts-jest @types/jest typescript
npx ts-jest config:init

Generated jest.config (simplified):

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
};

Method 4: Babel transform (babel-jest)

I use this when my build already relies on Babel (Create React App–style), or I need custom plugins in tests.

npm install -D jest @babel/core @babel/preset-env @babel/preset-typescript babel-jest
// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' } }],
    '@babel/preset-typescript',
  ],
};
// jest.config.js
module.exports = {
  transform: {
    '^.+\\.[jt]sx?$': 'babel-jest',
  },
};

Method 5: ESM projects ("type": "module")

Node ESM requires explicit extensions in some setups and may need NODE_OPTIONS=--experimental-vm-modules depending on Jest major and Node version. I document whatever the team picked in package.json scripts so CI and laptops agree:

{
  "scripts": {
    "test": "NODE_OPTIONS=--experimental-vm-modules jest"
  }
}

I re-read the Jest ESM docs for the exact version I am on; this area has shifted between major releases and I do not trust my memory from last year.


First tests: describe, it, expect

Jest uses BDD-style blocks. describe groups related cases; it (alias: test) defines one behavior; expect asserts outcomes. I try to test behavior, not implementation so refactors do not require me to rewrite expectations every time; that mindset starts here, in how I name cases and what I assert.

// src/math.js
export function add(a, b) {
  return a + b;
}
// src/math.test.js
import { add } from './math.js';

describe('add', () => {
  it('sums two numbers', () => {
    expect(add(2, 3)).toBe(5);
  });

  test('is commutative', () => {
    expect(add(1, 9)).toEqual(add(9, 1));
  });
});

How I write these

  • One obvious behavior per it block; the name reads like a specification sentence. I also keep one primary concept per test when I can—clearer failures, faster review.
  • I prefer Arrange–Act–Assert: build inputs, call the unit, assert outputs or errors.
  • I use test.skip or describe.skip for work in progress; test.only / describe.only narrows the run locally—I never commit .only; I’ve learned that the hard way in CI.

Matchers

Matchers encode what I’m actually trying to prove. Jest extends expect with many built-ins; I use specific matchers (toBeNull, not a vague truthy check) so I do not paper over type bugs—again, I care about behavior, not the cleverest one-liner.

Identity and equality

MatcherUse when
toBe(x)Same reference for primitives; Object.is semantics.
toEqual(obj)Deep equality for plain objects and arrays (serializable structures).
toStrictEqual(obj)Like toEqual but stricter about undefined keys and array holes.
notInverts the matcher: expect(x).not.toBe(y).
expect(1 + 1).toBe(2);
expect({ a: 1 }).toEqual({ a: 1 });
expect([1, , 3]).not.toStrictEqual([1, undefined, 3]);

Truthiness

toBeNull, toBeUndefined, toBeDefined, toBeTruthy, toBeFalsy—use specific matchers when possible instead of broad truthy/falsy checks that hide type bugs.

Numbers

toBeGreaterThan, toBeGreaterThanOrEqual, toBeLessThan, toBeCloseTo (floats).

expect(0.1 + 0.2).toBeCloseTo(0.3, 5);

Strings

toMatch(regexpOrString), toHaveLength(n).

expect('[email protected]').toMatch(/^[^@]+@[^@]+$/);

Arrays and iterables

toContain(item), toContainEqual(obj) for partial deep matches in arrays.

Objects

toMatchObject(subset), toHaveProperty('key', value?).

expect({ name: 'Ada', id: 1 }).toMatchObject({ name: 'Ada' });

Exceptions

toThrow(error?) with a function invocation:

expect(() => parse('{')).toThrow(SyntaxError);

Promises (async matchers)

resolves / rejects:

await expect(Promise.resolve(42)).resolves.toBe(42);
await expect(Promise.reject(new Error('x'))).rejects.toThrow('x');

Mock-specific

toHaveBeenCalled(), toHaveBeenCalledTimes(n), toHaveBeenCalledWith(arg1, ...), toHaveReturnedWith(value), toHaveLastReturnedWith(value).

Snapshot

toMatchSnapshot() and toMatchInlineSnapshot()—covered later in depth.


Async testing

I have written tests all three ways below; I default to async/await in new work and keep the others when I am wrapping legacy callback APIs.

Promises with return

If I return a promise, it integrates with Jest’s async tracking:

it('resolves', () => {
  return fetchStatus().then((s) => {
    expect(s).toBe('ok');
  });
});

async / await

I use this for readability in almost every new test I write:

it('loads user', async () => {
  const user = await loadUser(1);
  expect(user.name).toBe('Ada');
});

Callbacks with done

I avoid done when async/await suffices. If I have to use a legacy callback API, I call done() on success and pass errors to done(e).

it('invokes callback', (done) => {
  readFile('x.txt', (err, data) => {
    if (err) return done(err);
    expect(data).toContain('hello');
    done();
  });
});

What bit me before: mixing done with returned promises can race; I pick one style per test.

Timer-driven code

For setTimeout / setInterval, I use fake timers (jest.useFakeTimers()) more often than real delays—the mocking section is where I go deep on that.


Mocking

I isolate side effects here: I restore spies, clear or reset mocks between cases when it matters, and I avoid hitting real network in what I still call “unit” tests. That discipline is what makes parallel runs and fresh module registries actually help me.

jest.fn() — spies and stubs

A jest.fn() creates a new function that records calls. I pass an implementation up front, or I set it later with mockReturnValue / mockImplementation when the test needs more steps.

const onSave = jest.fn();
saveForm({ onSave });
expect(onSave).toHaveBeenCalledWith({ id: 1 });
const random = jest.fn();
random.mockReturnValue(0.5);
random.mockReturnValueOnce(0.1).mockReturnValueOnce(0.9);

jest.mock('module') — module substitution

The behavior I had to learn the hard way: Jest hoists jest.mock calls to the top of the file, before imports are evaluated in the transformed output. That is why imported bindings see the mock, not the original module—also why my Mocha-era muscle memory used to break tests here.

jest.mock('./api.js', () => ({
  fetchUser: jest.fn(() => Promise.resolve({ id: 1, name: 'Ada' })),
}));

import { fetchUser } from './api.js';

it('uses mock', async () => {
  await expect(fetchUser(1)).resolves.toMatchObject({ name: 'Ada' });
});

Partial mocks with jest.requireActual

jest.mock('./utils', () => {
  const actual = jest.requireActual('./utils');
  return {
    ...actual,
    heavyWork: jest.fn(() => 'mocked'),
  };
});

Manual mocks (__mocks__)

I place __mocks__/lodash.js next to node_modules or colocate with the module. Then:

jest.mock('lodash');

Jest uses the manual mock implementation. In larger codebases I document where those replacements live; I’ve watched too many “why is this importing a fake lodash?” moments in review.

Mocking timers

beforeEach(() => {
  jest.useFakeTimers();
});

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

it('debounces', () => {
  const cb = jest.fn();
  const debounced = debounce(cb, 100);
  debounced();
  expect(cb).not.toHaveBeenCalled();
  jest.advanceTimersByTime(100);
  expect(cb).toHaveBeenCalledTimes(1);
});

Spies: jest.spyOn

I use jest.spyOn(object, 'methodName') to wrap a real method: I track calls, optionally replace behavior, then restore when I’m done so I do not leak stubs across tests.

import * as logger from './logger.js';

it('logs errors', () => {
  const spy = jest.spyOn(logger, 'error').mockImplementation(() => {});
  failingOperation();
  expect(spy).toHaveBeenCalled();
  spy.mockRestore();
});

How I use it

  • Observe only: I skip mockImplementation and only assert call count and arguments.
  • Stub temporarily: I use mockImplementation / mockReturnValue, then mockRestore() in afterEach so the next test does not inherit a stub.
  • Module default exports: spying can get tangled in Babel/TS interop; sometimes I reach for a full jest.mock because it is clearer in review.

Setup and teardown

HookScopeTypical use
beforeAllFileStart server, connect test DB once.
afterAllFileTeardown shared resources.
beforeEachTestReset mocks, seed data, truncate tables.
afterEachTestRestore spies, clean temp files.
describe('database', () => {
  let connection;

  beforeAll(async () => {
    connection = await createTestConnection();
  });

  afterAll(async () => {
    await connection.close();
  });

  beforeEach(async () => {
    await connection.query('TRUNCATE users');
  });

  it('inserts rows', async () => {
    await connection.query('INSERT INTO users (name) VALUES ($1)', ['Grace']);
    const { rows } = await connection.query('SELECT count(*) FROM users');
    expect(rows[0].count).toBe('1');
  });
});

I use describe.each / test.each for data-driven cases so I do not copy-paste setups; when the data is messy I reach for small builders or fixtures so the arrange section stays readable.


Test organization

File structure I have seen in production repos over and over:

src/
  components/
    Button.tsx
    Button.test.tsx
  lib/
    format.ts
    format.spec.ts
  server/
    app.ts
    app.integration.test.ts

Naming

  • I use *.test.js or *.spec.js beside source files, or under __tests__/ directories—whatever the repo already picked; I do not mix conventions in one tree.
  • I keep unit tests fast; I suffix integration tests (e.g. .integration.test.ts) when I need to filter in CI: jest --testPathIgnorePatterns=integration.

Barrel files

I avoid testing through index.ts re-exports unless that re-export is the public API; I import the concrete module so stack traces and failure lines stay honest.


Coverage: generating and interpreting reports

I wire coverage to src, not generated glue or type-only files—I set realistic thresholds for product risk, not to chase a number on boilerplate. Run:

jest --coverage

Istanbul instruments source (via Babel/ts-jest pipeline) and records statement, branch, function, and line hits.

Configuration

// jest.config.js
module.exports = {
  collectCoverageFrom: [
    'src/**/*.{js,ts,tsx}',
    '!src/**/*.test.{js,ts,tsx}',
    '!src/types/**',
    '!src/index.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

How I read the report

  • HTML report (coverage/lcov-report/index.html): I click into untested branches; that is more actionable for me than a single headline percentage.
  • LCOV for SonarQube or Codecov when the team uses those gates.
  • I still exclude generated code and pure type re-exports from thresholds so I am not gamifying the wrong files.

Coverage answers “what ran?”, not “is the behavior correct?”. I still pair it with review, integration tests, and whatever I can observe in real environments—especially after early 2025, when a green coverage bar still would not have caught half the regressions I have seen in staging.


Snapshot testing

Snapshots serialize a value to a string stored next to the test (or inline). I use them for React trees, CLI output, and large objects—and I treat snapshot updates like any other code change: I review the diff, I do not rubber-stamp -u on a busy Friday.

import renderer from 'react-test-renderer';
import { Welcome } from './Welcome';

it('matches snapshot', () => {
  const tree = renderer.create(<Welcome name="Ada" />).toJSON();
  expect(tree).toMatchSnapshot();
});

When I use them

  • Stable, intentionally designed output (error messages, CLI help text) with infrequent churn.
  • Component smoke renders when I have semantic checks elsewhere so the snapshot is not doing all the work.

When I skip them

  • Rapidly changing UI with no semantic benefit—snapshots become pure noise in my review queue.
  • Anything non-deterministic (timestamps, random IDs) unless I scrub it first.

Updating

jest -u
# or
jest --updateSnapshot

I review diffs in code review like any other change; snapshots are still source code to me.


Testing React with Testing Library

I pair Jest with Testing Library on most React work. Install:

npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event

jest.config.js:

module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};

jest.setup.js:

import '@testing-library/jest-dom';

Example: how I test a button

// Button.tsx
export function Button({ onClick, children }: Props) {
  return (
    <button type="button" onClick={onClick}>
      {children}
    </button>
  );
}
// Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

it('calls handler on click', async () => {
  const user = userEvent.setup();
  const onClick = jest.fn();
  render(<Button onClick={onClick}>Save</Button>);
  await user.click(screen.getByRole('button', { name: /save/i }));
  expect(onClick).toHaveBeenCalled();
});

I prefer roles and accessible names over CSS selectors—my tests track how users and assistive tech see the page, and they survive style refactors.


Testing APIs with Supertest

I use supertest to drive HTTP without opening a real port in many frameworks (Express, Fastify adapters exist). It is my default for “did the route return the right JSON?” when the app is already in-process.

npm install -D supertest @types/supertest
// app.js
import express from 'express';

export function createApp() {
  const app = express();
  app.get('/health', (_req, res) => {
    res.json({ status: 'ok' });
  });
  return app;
}
// app.test.js
import request from 'supertest';
import { createApp } from './app.js';

const app = createApp();

it('GET /health', async () => {
  const res = await request(app).get('/health').expect(200);
  expect(res.body).toEqual({ status: 'ok' });
});

I use a dedicated test database or transactions rolled back per test for routes that write data; I have debugged too many “works because order happens to be serial” integration tests to rely on shared state.


CI/CD: GitHub Actions example

I wire the same kind of jest --ci step into GitHub Actions that I would run before I merge. Example:

# .github/workflows/test.yml
name: test

on:
  push:
    branches: [main]
  pull_request:

jobs:
  unit:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20.x]
    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint --if-present

      - name: Unit tests
        run: npx jest --ci --coverage --maxWorkers=2

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage

--ci disables watch mode and fails on skipped-only tests in some configurations. I run test:ci on every PR when I can; I pair that with lockfile-driven npm ci so my CI matches my laptop less randomly.


Performance: faster test runs

I keep suites fast on purpose: slow suites stop getting run locally, and CI queues balloon. Concretely:

  • --maxWorkers: I cap workers in CI to avoid memory thrash on small runners (e.g. 2)—I have OOM’d enough shared runners to be conservative.
  • --watch locally: Jest re-runs only what it can tie to the graph; I live in this mode when I refactor.
  • --bail: I stop on first failure when I am triaging, not when I need a full picture.
  • Project split: I use projects in jest.config when the front end needs jsdom and the API needs node in one repo.
  • Lightweight setup: I defer heavy imports and avoid real services in what I call unit tests—keeps the feedback loop under a minute when I can manage it.
jest --maxWorkers=50%   # fraction of CPUs
jest --runInBand        # single process — debug or resource-constrained CI

Debugging with the Node debugger

When a test is lying to me, I run Jest under the Node debugger:

node --inspect-brk node_modules/jest/bin/jest.js --runInBand path/to/file.test.js

I attach from VS Code (JavaScript Debug Terminal) or Chrome DevTools (chrome://inspect). --runInBand keeps a single process so breakpoints feel predictable.

For quick printf debugging, console.log is fine; I use jest.spyOn(console, 'error') when I want to silence expected errors in negative tests without noise in the log.


Migration notes

When we migrated from Mocha, here’s what broke

The table below is the “clean room” view. My lived experience was messier. When I moved a service off Mocha + Chai + Sinon onto Jest, the describe/it skeleton mostly ported, but a few things bit me the first week:

  • Module mocks and import order — I was used to hoisting my mental model, not Jest’s. I had tests that imported a module, then “mocked” it the Mocha way; under Jest the mock has to lead the dance or I get the real implementation. That showed up as flaky “sometimes mocked, sometimes not” until I put jest.mock in the right place and learned to reach for jest.requireActual when I only needed a partial stub.
  • Global state in parallel — Mocha often ran our file in a process where shared singletons happened to be reset. Jest’s workers made latent order dependence obvious. I had to add proper teardown—afterAll closing HTTP servers, killing DB handles—and sometimes --detectOpenHandles to learn what I had leaked.
  • assertion style — I kept typing Chai’s expect(x).to.deep.equal in muscle memory. Porting to Jest’s expect was mechanical but noisy in a big suite; I scripted some find-replace, then fixed the stragglers by hand.
  • Timers — a couple of tests relied on real setTimeout and passed on a fast laptop; under CI or jest.useFakeTimers they failed until I made time explicit. I do not love fake timers, but I prefer them to flaky CI.

I would still port the same way: keep describe/it structure, swap assertions, then convert mocks; only then do I turn up parallelism with confidence that tests are actually isolated.

From Mocha / Jasmine (at a glance)

FeatureMocha/JasmineJest
AssertionsChai or expect.jsBuilt-in expect
SpiesSinon (often)jest.fn, jest.spyOn
MocksSinon or manualjest.mock, manual __mocks__
CLImochajest

Toward Vitest

As of early 2025, I still reach for Jest in Node and a lot of Next/CRA-style stacks, but I have also been moving Vite-first front ends to Vitest. Vitest reuses much of Jest’s API. The steps I follow:

  1. Add Vitest and @vitest/coverage-v8 (or istanbul).
  2. Map jest globals to vi if I opt into separate naming, or enable globals: true in Vitest config.
  3. Replace Jest-environment-specific hacks with Vite-aware patterns.
  4. Run both tools temporarily in CI while I migrate, because I do not like big-bang rewrites on a Friday.

I send people to the [Vitest Complete Guide](/en/blog/vitest-complete-guide/ for Vite-first projects when they want more than I keep in my head.


Test runner architecture (how Jest executes the suite)

I keep a rough picture of the runner in my head so I do not foot-gun with globals. At a high level, Jest:

  1. Discovers test files using testMatch / testRegex and optional projects.
  2. Builds a dependency graph (via jest-haste-map) so it knows which files to transform and cache.
  3. Spawns worker processes (jest-worker) to run test files in parallel (CPU-bound; I/O-bound work still benefits from parallelism up to a point).
  4. Applies transforms (Babel, ts-jest, or babel-jest) per file, then loads each test file in an isolated VM context (or jest-environment-jsdom / node).

Implications I actually care about:

  • Flaky order: Tests in different files run in parallel; tests in one file run serially by default. If I share global state across files, I will eventually lose—I reset mocks, and I do not mutate process.env casually without a setupFiles story.
  • Slow cold start: Large monorepos pay for haste-map and transform caches. I use --maxWorkers in CI to cap memory, and I wire cacheDirectory on CI when the team agrees it is safe.
  • --watch: File watchers re-run what the graph can tie to a change; that is why I live there during refactors I mentioned in the performance section.

Questions from My Team (and what I tell them)

These are the questions I get in Slack or stand-up, not an abstract support matrix. I answer them the way I would for my own team.

“Jest can’t find my module, but tsc and Node are fine.”
Usually path aliases. I add moduleNameMapper to mirror the paths in tsconfig (or the bundler) so Jest resolves the same graph my app does. I have lost an afternoon to @/ imports that worked everywhere except the test process.

“My mock is not the one running; I see the real implementation.”
Almost always import order and hoisting. I keep jest.mock at the top of the file, I remember the factory is hoisted, and if I am in an ESM experiment I look at jest.unstable_mockModule for that setup. I reread the section on jest.mock in this file when I am grumpy and something still imports real HTTP.

ReferenceError: React is not defined in tests but the app builds.”
My JSX transform is out of sync. I align Babel/TypeScript jsx settings with what the app uses (classic vs automatic). I have fixed that more than once after a minor React upgrade.

“The suite finishes locally but the runner hangs in CI or exits with open handles.”
Something did not close—DB pool, server, file handle. I add afterAll teardown, and I run with --detectOpenHandles when I need the stack trace to shame me.

“This test is flaky; it only fails sometimes in CI.”
If it involves time, I stop using real setTimeout in unit tests: jest.useFakeTimers() and I advance time on purpose. If it only fails in CI, I compare env: I pin TZ, locale, and any API keys or feature flags I forgot I relied on, often via setupFiles.

“CI ran out of memory on Jest.”
I lower --maxWorkers, split projects, or run the heaviest suite in a separate job. I treat OOM in CI as a config problem first, not a “buy a bigger box” problem.


Real project structure

A monorepo service layout I have shipped more than once:

repo/
  apps/
    api/
      package.json
      src/
        index.ts              # HTTP server bootstrap
        routes/
          users.ts
          users.test.ts       # supertest + in-memory DB
        lib/
          auth.ts
          auth.spec.ts        # pure unit tests
      jest.config.cjs
  packages/
    shared/
      src/
        validators.ts
        validators.test.ts
  package.json                # workspaces
  .github/workflows/test.yml

What I actually do

  • I colocate fast unit tests with lib/ and pure modules so the shortest path from code to test is one folder hop.
  • I put HTTP integration tests near routes/ or under test/integration/ depending on how many people need to find them—when the team is small, close to the route wins.
  • I run shared package tests from their package root or from root Jest projects when I want a single coverage report; both work, I pick based on who owns the CI bill.

Production-oriented testing patterns

Jest does not run inside my production servers for user traffic, and I would not want it to. When I say “production-oriented,” I mean the layers I use before and after code ships:

  • CI on every merge: I run jest --ci --coverage --maxWorkers=2 with deterministic seeds where the team cares (crypto, time).
  • Staging E2E: I use Playwright or Cypress against a deployed preview for user journeys and cross-service contracts—Jest is not a substitute for that.
  • Synthetic monitoring: I schedule probes from outside (ping, API checks, browser scripts); that is a different tool chain, but it is part of how I sleep at night.
  • Feature flags + canary: I only care about the slice of traffic if unit, integration, and E2E gates already passed; Jest is one gate, not the whole story.

I still treat unit tests as fast feedback on pure logic, integration tests on real boundaries (DB, HTTP with test containers), and E2E as the last line before customers. Each layer catches a different class of failure; I have never seen a suite that made all three redundant.


What I do before I call it “done”

  • I install Jest and a transform (ts-jest or Babel) for TypeScript if the repo is not plain JS.
  • I stabilize mocks (clearMocks / resetMocks) and I do not let mutable singletons leak across files in parallel.
  • I set realistic coverage thresholds on src only, excluding the generated junk.
  • I wire test:ci into GitHub Actions (or whatever CI I use) with caching for node_modules and the Jest cache so I am not waiting on cold installs every push.

  • [Jest Complete Guide](/en/blog/jest-complete-guide/ — where I send people for more patterns than I need day to day.
  • [GitHub Actions Complete Guide](/en/blog/github-actions-complete-guide/ — for workflow design past the one YAML block I showed here.
  • [Vitest Browser Mode](/en/blog/vitest-browser-mode-testing-guide/ — when a Vite project needs real browser tests.
  • [React Testing Library Guide](/en/blog/react-testing-library-complete-guide/ — deeper on queries and async than I have room for in this Jest-focused piece.