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
jsdomdefaults 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
resetModuleswhen 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
asyncstyle. - 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
itblock; 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.skipordescribe.skipfor work in progress;test.only/describe.onlynarrows 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
| Matcher | Use 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. |
not | Inverts 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
mockImplementationand only assert call count and arguments. - Stub temporarily: I use
mockImplementation/mockReturnValue, thenmockRestore()inafterEachso 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.mockbecause it is clearer in review.
Setup and teardown
| Hook | Scope | Typical use |
|---|---|---|
beforeAll | File | Start server, connect test DB once. |
afterAll | File | Teardown shared resources. |
beforeEach | Test | Reset mocks, seed data, truncate tables. |
afterEach | Test | Restore 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.jsor*.spec.jsbeside 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.--watchlocally: 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
projectsinjest.configwhen the front end needsjsdomand the API needsnodein 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.mockin the right place and learned to reach forjest.requireActualwhen 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—
afterAllclosing HTTP servers, killing DB handles—and sometimes--detectOpenHandlesto learn what I had leaked. - assertion style — I kept typing Chai’s
expect(x).to.deep.equalin muscle memory. Porting to Jest’sexpectwas 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
setTimeoutand passed on a fast laptop; under CI orjest.useFakeTimersthey 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)
| Feature | Mocha/Jasmine | Jest |
|---|---|---|
| Assertions | Chai or expect.js | Built-in expect |
| Spies | Sinon (often) | jest.fn, jest.spyOn |
| Mocks | Sinon or manual | jest.mock, manual __mocks__ |
| CLI | mocha | jest |
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:
- Add Vitest and
@vitest/coverage-v8(oristanbul). - Map
jestglobals toviif I opt into separate naming, or enableglobals: truein Vitest config. - Replace Jest-environment-specific hacks with Vite-aware patterns.
- 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:
- Discovers test files using
testMatch/testRegexand optionalprojects. - Builds a dependency graph (via
jest-haste-map) so it knows which files to transform and cache. - 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). - Applies transforms (Babel,
ts-jest, orbabel-jest) per file, then loads each test file in an isolated VM context (orjest-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.envcasually without asetupFilesstory. - Slow cold start: Large monorepos pay for haste-map and transform caches. I use
--maxWorkersin CI to cap memory, and I wirecacheDirectoryon 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 undertest/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
projectswhen 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=2with 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-jestor 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
srconly, excluding the generated junk. - I wire
test:ciinto GitHub Actions (or whatever CI I use) with caching fornode_modulesand the Jest cache so I am not waiting on cold installs every push.
Related reading
- [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.