Skip to main content

⚙️ 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.js file and know which options live there versus in package.json
  • Choose the right testEnvironmentnode for servers, jsdom for browser-like code
  • Run one-time and per-file setup with globalSetup, setupFiles, and setupFilesAfterEnv
  • Configure module resolution with moduleNameMapper for path aliases and asset stubs
  • Turn on coverage and enforce coverageThreshold gates 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:

  1. A "jest" key in package.json — fine for a couple of options.
  2. A jest.config.js file — the sweet spot: it's real JavaScript, so you can add comments and compute values.
  3. 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.

graph TD A["Which globals does the code under test need?"] --> B{"Touches document, window, or DOM APIs?"} B -->|No: server, utils, pure logic| C["testEnvironment: node"] B -->|Yes: components, browser code| D["testEnvironment: jsdom"] C --> E["Fastest, no browser globals"] D --> F["Simulated browser via jsdom"]
ValueProvidesUse for
'node'Node's globals only — no window, no documentExpress routes, utilities, business logic, anything server-side (the default, and the fastest)
'jsdom'A simulated browser: window, document, localStorageReact/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.

OptionRunsTypical use
globalSetupOnce, before the whole runStart a Docker test database or seed shared data
globalTeardownOnce, after the whole runTear that service back down
setupFilesBefore each test file, before the framework loadsPolyfills, loading .env variables
setupFilesAfterEnvBefore each test file, after the framework loadsExtend 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.

JestVitest
Setup with plain Node/ExpressZero-config, matureWorks, but shines most with Vite
ESM & TypeScriptNeeds Babel or ts-jestNative via Vite's pipeline
Config filejest.config.jsShares vite.config.ts
APIdescribe / test / expect / jest.fn()Near-identical (vi.fn() instead of jest.fn())
Watch-mode speedFastVery 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.js with the /** @type {import('jest').Config} */ comment for autocomplete
  • Default to testEnvironment: 'node'; opt into jsdom only where you touch the DOM
  • Put shared matcher extensions and global hooks in a setupFilesAfterEnv file
  • Scope coverage to your source with collectCoverageFrom, excluding barrels and generated files
  • Use coverageThreshold as a ratchet against regressions, not a number to game
  • Anchor every path with <rootDir>

❌ Don't

  • Ship jsdom in a pure backend project — it's a needless dependency and slowdown
  • Put matcher extensions in setupFiles (too early — expect doesn'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>
  • testEnvironment is the key choice: node for servers, jsdom for DOM code (override per file with a docblock)
  • Use setup files for matcher extensions and global hooks — setupFilesAfterEnv runs after the framework loads
  • moduleNameMapper rewrites imports for aliases and asset stubs; a transform/preset compiles TS and modern syntax
  • Turn on coverage and set a coverageThreshold to 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

🚀 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.