Jest is a test runner, an assertion library, a mocking framework, and a coverage tool in one package. The runner runs test files, the assertion library gives expect for checks, the mocking framework isolates dependencies, and coverage shows executed lines. Plain JavaScript needs only Jest, while the DOM, TypeScript, and React are added deliberately. The running example is an order service with a calculateDelivery function and, later, a cart with a button.
A beginner's natural reaction is to run the generator and accept the result as is. The command npm init jest@latest creates a working config, and tutorials suggest copying someone else's jest.config. The temptation is clear: tests run immediately. But the trouble surfaces later - when a test fails not because of the code but because of an option nobody on the team can explain. A config without understanding is a black box at the foundation of a tool of trust.
Start with the minimum: a couple of dev dependencies and three scripts cover the whole cycle. Locally you run npm test, for development a watch mode that rebuilds what changed is convenient, and CI needs a separate script without watch - with the --ci flag and coverage. The split is essential: watch monitors files and keeps the process alive - in CI that would hang the pipeline.
The first test is the smallest honest contract. The test function takes a name and a body; inside you call expect with the actual value and a matcher that describes the expectation. The toBe matcher compares values via Object.is and suits primitives - numbers, strings, booleans. The test name is an assertion about behavior, not a retelling of the code: 'adds two numbers' reads like a line of a specification.
Now a config you can explain line by line. Keep it in jest.config.ts and type it with the Config import - the editor will suggest fields. One caveat about a TypeScript config: Jest does not read it on its own, it needs a loader - ts-node by default, which is why it sits next to jest in the install. The alternative is a @jest-config-loader esbuild-register docblock on the first line of the file, in which case you install esbuild-register instead of ts-node. The first field is testEnvironment, the global environment in which the test executes. The value node gives bare JavaScript without a DOM, jsdom emulates browser APIs in memory. Note: jsdom is an emulation, not a real browser, there is no real rendering or layout there.
Let us walk the remaining fields. testMatch defines which files count as tests, and the <rootDir> token anchors the search to the project root. The pair clearMocks and restoreMocks keeps cleanliness between tests: clearMocks zeroes the call history before each test, restoreMocks returns substituted functions to their original implementation. collectCoverageFrom outlines the coverage denominator - files are counted even if no test touched them, and the exclamation mark excludes generated code and types.
| Option | Why | Do not confuse |
|---|---|---|
| testEnvironment | Global environment node/jsdom | Does not run a real browser |
| setupFiles | Before the test framework is installed | For env/polyfills |
| setupFilesAfterEnv | After expect/hooks | For jest-dom and shared hooks |
| moduleNameMapper | Aliases and non-JS imports | Regex order matters |
| transform | Compiles TS/JSX | Jest does not use your bundler automatically |
Three fields will be useful later, but their role is worth grasping now. setupFiles runs before the test framework is installed - a place for polyfills and environment variables. setupFilesAfterEnv runs once expect and the hooks exist - for jest-dom and shared beforeEach. moduleNameMapper rewrites imports: it resolves path aliases and substitutes non-JS imports with a stub, and the order of the regular expressions matters. transform is responsible for compiling TypeScript and JSX.
Here lies the core idea: Jest does not use your bundler automatically. Vite or webpack build the application, but Jest runs tests in Node and knows nothing about the bundler - without a transform line it meets TypeScript and fails with an unexpected token error. The price of an explicit config is a few lines you must understand; the payoff is predictability and the ability to fix the test build without guessing. A typical failure: a test is green locally and red in CI because moduleNameMapper matched in the wrong order. A config you can explain removes a whole class of such riddles.
npm i -D jest ts-node
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:ci": "jest --ci --coverage"
}
}import { sum } from './sum.js'
test('adds two numbers', () => {
expect(sum(2, 3)).toBe(5)
})import type { Config } from 'jest'
const config: Config = {
testEnvironment: 'node',
testMatch: ['<rootDir>/src/**/*.test.{ts,tsx}'],
clearMocks: true,
restoreMocks: true,
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/generated/**',
],
}
export default config