⚙️ Jest Configuration
In Week 3 you ran Jest with zero configuration and it just worked. That's Jest's superpower — until the day you need to test browser code, load environment variables before every suite, import with path aliases, or fail the build when coverage drops. Today you learn the handful of jest.config.js options that unlock all of it, without the config sprawl.
Week 12 · Day 1 (Monday: Unit Testing) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Create a
jest.config.jsfile and know which options live there versus inpackage.json - Choose the right
testEnvironment—nodefor servers,jsdomfor browser-like code - Run one-time and per-file setup with
globalSetup,setupFiles, andsetupFilesAfterEnv - Configure module resolution with
moduleNameMapperfor path aliases and asset stubs - Turn on coverage and enforce
coverageThresholdgates in CI — while understanding coverage's limits - Explain when the Vite-native Vitest is a better fit than Jest
Estimated Time: 55 minutes
Practice: Write a complete config with a jsdom environment, a setup file, and a coverage gate.
In This Lesson
Where Config Lives
Jest reads its configuration from one of three places, in order of what teams usually prefer as a project grows:
- A
"jest"key in package.json — fine for a couple of options. - A jest.config.js file — the sweet spot: it's real JavaScript, so you can add comments and compute values.
- A jest.config.ts file — the same, with TypeScript type-checking on the config itself.
Generate a documented starter with the built-in wizard, then trim it to what you use:
npx jest --init
A modern CommonJS config exports a plain object. The @type comment gives you editor autocomplete without any extra tooling:
// jest.config.js
/** @type {import('jest').Config} */
const config = {
testEnvironment: 'node', // where tests run (see next section)
roots: ['<rootDir>/src'], // where to look for tests
testMatch: ['**/*.test.js'], // which files are tests
clearMocks: true, // reset mock state before every test
verbose: true, // print each test name as it runs
};
module.exports = config;
💡 <rootDir> is a token, not a folder
Jest expands the literal string <rootDir> to the directory containing your config. Always anchor paths to it — <rootDir>/src — so the config works no matter where the test runner is invoked from.
The Test Environment
The single most important option is testEnvironment. It decides which global APIs exist inside your tests. Pick the wrong one and you'll hit confusing errors like document is not defined or slow tests that load a fake DOM you never use.
| Value | Provides | Use for |
|---|---|---|
'node' | Node's globals only — no window, no document | Express routes, utilities, business logic, anything server-side (the default, and the fastest) |
'jsdom' | A simulated browser: window, document, localStorage | React/DOM components, code that reads the DOM |
Since Jest 28, jsdom ships as a separate package so server projects don't pay for it. Install it only when you need browser globals:
npm install --save-dev jest-environment-jsdom
// jest.config.js — for front-end code
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'jsdom',
};
You don't have to pick one globally. A magic comment at the top of a single test file overrides the environment just for that file — handy in a mostly-server project with a few DOM tests:
/**
* @jest-environment jsdom
*/
test('renders a greeting into the DOM', () => {
document.body.innerHTML = '<div id="app"></div>';
mount(document.getElementById('app'));
expect(document.querySelector('#app').textContent).toContain('Hello');
});
⚠️ jsdom is a simulation, not a browser
jsdom implements a large slice of the DOM in pure JavaScript, but it has no layout engine and no real rendering. Things like getBoundingClientRect() return zeros, and CSS doesn't actually apply. For behavior that truly depends on a rendering engine, that's a signal to move up to an end-to-end tool like Playwright — not to fight jsdom.
Setup & Teardown Files
Some preparation needs to happen outside individual tests — extending the assertion library, loading environment variables, or starting a shared service once. Jest gives you four hooks, and choosing correctly matters for speed and isolation.
| Option | Runs | Typical use |
|---|---|---|
globalSetup | Once, before the whole run | Start a Docker test database or seed shared data |
globalTeardown | Once, after the whole run | Tear that service back down |
setupFiles | Before each test file, before the framework loads | Polyfills, loading .env variables |
setupFilesAfterEnv | Before each test file, after the framework loads | Extend expect, register global beforeEach/afterEach |
The one you'll use constantly is setupFilesAfterEnv — it runs at the point where Jest globals like expect already exist, so you can add custom matchers. Configure it like so:
// jest.config.js
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};
// jest.setup.js — runs before every test file
// Add the extra DOM matchers used across the whole suite:
require('@testing-library/jest-dom');
// A global cleanup that applies to every test:
afterEach(() => {
jest.clearAllMocks();
});
📖 setupFiles vs setupFilesAfterEnv
Think of it as before the game vs. courtside. setupFiles runs so early that Jest's test functions don't exist yet — perfect for polyfills and environment variables. setupFilesAfterEnv runs after the framework is wired up, so expect, beforeEach, and friends are available — that's where matcher extensions and global hooks belong.
Module Resolution & Transforms
Real projects import things Node can't natively run in a test: path aliases like @/utils, CSS files, images, or modern/TypeScript syntax. Two options bridge the gap.
moduleNameMapper — rewrite imports
It maps import paths (via regex) to something Jest can load. The two everyday uses are aliases and non-JS asset stubs:
// jest.config.js
/** @type {import('jest').Config} */
module.exports = {
moduleNameMapper: {
// 1. Path alias: "@/utils/math" -> "<rootDir>/src/utils/math"
'^@/(.*)$': '<rootDir>/src/$1',
// 2. Stub out styles & images so importing them doesn't crash
'\\.(css|scss|less)$': '<rootDir>/test/styleMock.js',
'\\.(png|jpg|svg)$': '<rootDir>/test/fileMock.js',
},
};
// test/styleMock.js → an import like `import './Button.css'` becomes {}
module.exports = {};
transform — compile before running
Jest runs on CommonJS. To test TypeScript or the newest ESM syntax, a transformer compiles files first. The lightest modern choice is Babel (Jest wires it up automatically if a babel.config.js is present) or ts-jest for TypeScript:
// jest.config.js — TypeScript via ts-jest preset
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
💡 A preset is a config shortcut
Presets like ts-jest or jest-expo bundle a whole set of options (transforms, module mapping, environment) behind one preset: line. Start from a preset when one exists for your stack, then override only the specific keys you need.
Coverage & Thresholds
Code coverage measures how much of your source ran while the tests executed — reported as four numbers: statements, branches, functions, and lines. Jest has it built in; no extra tools required.
npx jest --coverage
// jest.config.js
/** @type {import('jest').Config} */
module.exports = {
collectCoverage: true,
// Only measure YOUR source, and skip barrel/index files:
collectCoverageFrom: [
'src/**/*.js',
'!src/**/index.js',
'!src/**/*.stories.js',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'], // console summary + HTML report
};
The output is a per-file table with a summary row:
Output
-----------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------------|---------|----------|---------|---------|
All files | 88.4 | 76.2 | 90.0 | 88.1 |
cart.js | 95.0 | 88.9 | 100.0 | 95.0 |
discount.js | 72.7 | 50.0 | 66.7 | 72.7 |
-----------------|---------|----------|---------|---------|
Enforcing a floor with coverageThreshold
To stop coverage from silently sliding over time, set minimums. If any number falls below, Jest exits non-zero — which fails the CI build. You can set a global floor and stricter rules for critical files:
// jest.config.js
/** @type {import('jest').Config} */
module.exports = {
coverageThreshold: {
global: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
// Hold the money-handling module to a higher bar:
'./src/billing/': {
branches: 90,
},
},
};
⚠️ Coverage is a hint, not a goal
100% coverage only means every line ran — not that it was checked. A test with no assertions can cover a whole file and prove nothing. Chasing a perfect number pushes people to write brittle, assertion-free tests just to color the report green. Use a threshold as a ratchet to prevent regressions, not as a target to max out. We'll dig into what a genuinely valuable test looks like in the next lesson.
Jest or Vitest?
Jest is the long-standing default and everything above is battle-tested. But if your app is built with Vite (as most new React/Vue projects are), there's a compelling alternative: Vitest. It reuses your existing Vite config and transform pipeline, so ESM and TypeScript work out of the box with no separate Babel/ts-jest setup.
| Jest | Vitest | |
|---|---|---|
| Setup with plain Node/Express | Zero-config, mature | Works, but shines most with Vite |
| ESM & TypeScript | Needs Babel or ts-jest | Native via Vite's pipeline |
| Config file | jest.config.js | Shares vite.config.ts |
| API | describe / test / expect / jest.fn() | Near-identical (vi.fn() instead of jest.fn()) |
| Watch-mode speed | Fast | Very fast (Vite HMR-style re-runs) |
The good news: the concepts transfer almost verbatim. A Vitest config looks like this, and the test block accepts the same environment and coverage ideas you just learned:
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom', // same idea as Jest's testEnvironment
setupFiles: ['./vitest.setup.js'],
globals: true, // use describe/test without importing
coverage: {
provider: 'v8',
thresholds: { lines: 80, functions: 80, branches: 75 },
},
},
});
💡 Rule of thumb: New Vite-based front-end project? Start with Vitest. Node/Express backend or an established Jest codebase? Stay with Jest. Everything you know about environments, setup files, and coverage carries across either way.
Practice & Quiz
🏋️ Exercise 1: A front-end test config
Goal: Write a jest.config.js for a React app that (a) runs tests in a browser-like environment, (b) loads a setup file that registers @testing-library/jest-dom, (c) stubs CSS imports, and (d) fails the build below 80% line coverage.
💡 Hint
You need four keys: testEnvironment, setupFilesAfterEnv, moduleNameMapper, and coverageThreshold.
✅ Solution
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|scss)$': '<rootDir>/test/styleMock.js',
},
collectCoverageFrom: ['src/**/*.{js,jsx}', '!src/**/index.js'],
coverageThreshold: {
global: { lines: 80 },
},
};
// jest.setup.js
require('@testing-library/jest-dom');
🏋️ Exercise 2: Fix the wrong environment
Goal: A teammate's test fails with ReferenceError: document is not defined, but the code clearly uses document.querySelector. The config sets testEnvironment: 'node'. What are two ways to fix just this one test file without changing the global config?
✅ Solution
Option A — add a per-file docblock at the very top of the test file:
/**
* @jest-environment jsdom
*/
Option B — pass it on the command line for that run: npx jest dom.test.js --env=jsdom. Either gives that file the document global it needs while the rest of the suite keeps the fast node environment.
🎯 Quick Quiz
Question 1: You're testing pure server-side utility functions with no DOM. Which testEnvironment should you use?
Question 2: What happens when a coverage number falls below its coverageThreshold?
Question 3: Which testing tool reuses an existing Vite configuration instead of needing its own transform setup?
Best Practices & Pitfalls
✅ Do
- Keep config in a
jest.config.jswith the/** @type {import('jest').Config} */comment for autocomplete - Default to
testEnvironment: 'node'; opt intojsdomonly where you touch the DOM - Put shared matcher extensions and global hooks in a
setupFilesAfterEnvfile - Scope coverage to your source with
collectCoverageFrom, excluding barrels and generated files - Use
coverageThresholdas a ratchet against regressions, not a number to game - Anchor every path with
<rootDir>
❌ Don't
- Ship
jsdomin a pure backend project — it's a needless dependency and slowdown - Put matcher extensions in
setupFiles(too early —expectdoesn't exist yet) - Set a 100% threshold and reward assertion-free tests that only chase the number
- Measure coverage on
node_modules, mocks, or config files
⚠️ ESM gotcha
If you see SyntaxError: Cannot use import statement outside a module, your files use ESM but Jest is running them as CommonJS. The fix is a transform (Babel with @babel/preset-env, or ts-jest) — or switch to Vitest, which handles ESM natively. Don't reach for the experimental --experimental-vm-modules flag unless you know you need it.
Summary
🎉 Key Takeaways
- Configure Jest in
jest.config.js; anchor paths with<rootDir> testEnvironmentis the key choice:nodefor servers,jsdomfor DOM code (override per file with a docblock)- Use setup files for matcher extensions and global hooks —
setupFilesAfterEnvruns after the framework loads moduleNameMapperrewrites imports for aliases and asset stubs; atransform/preset compiles TS and modern syntax- Turn on coverage and set a
coverageThresholdto stop regressions — but coverage is a hint, not a goal - Vitest is the Vite-native alternative; the same environment/setup/coverage ideas carry over
📚 Additional Resources
- Jest — Configuring Jest (all options)
- Jest — testEnvironment
- Vitest — Getting Started
- Vitest — Migrating from Jest
🚀 What's Next?
Your runner is dialed in. Now the real craft: Writing Effective Unit Tests — the Arrange-Act-Assert structure, testing behavior instead of implementation, test.each for table-driven cases, and how to spot a brittle test before it wastes your team's time.
⚙️ Configured and ready
A clean config makes good testing habits cheap. Next we turn to the tests themselves and what makes one worth keeping.