Unit Testing in JavaScript with Jest
You change one helper function, push, and a page you didn't even touch breaks in production. Or you want to refactor a messy module but you're scared to, because the only way to know it still works is clicking through the app by hand. Unit tests fix both problems, and in JavaScript the tool most teams reach for is Jest, the test runner originally built at Facebook (now Meta). It ships with the runner, assertions, mocks and coverage in one package, so most projects need little or no config to start.
This post walks through setting Jest up, writing tests, the matchers you'll use daily, mocking, async tests, snapshots, coverage and a bit of TDD, plus a few gotchas that tend to bite people the first time.
- Setting up Jest in JavaScript and TypeScript projects
- Writing effective test cases with describe and it blocks
- Mastering Jest matchers for all assertion types
- Mocking functions, modules, and API calls
- Testing asynchronous code with async/await
- Snapshot testing for UI components
- Code coverage configuration and best practices
- Test-Driven Development (TDD) workflow
Why Jest?
The main reason is that you install one thing. A classic Node testing setup meant wiring together Mocha, Chai, Sinon and Istanbul and keeping their versions happy with each other. Jest bundles the runner, assertions, mocking and coverage, and they all share the same config and CLI.
One dependency instead of four
Zero Config
Works out of the box for most JavaScript projects. Just install and run.
Fast Parallel Testing
Test files run in parallel across worker processes, so big suites finish faster.
Snapshot Testing
Capture component output and detect unexpected changes automatically.
Watch Mode
Reruns only the tests related to files you changed (uses git to figure that out).
Getting Started with Jest
Install it as a dev dependency. For a plain JavaScript project that's genuinely all you need.
Installation
# Create a new project
mkdir my-project && cd my-project
npm init -y
# Install Jest
npm install --save-dev jest
# For TypeScript projects
npm install --save-dev jest @types/jest ts-jest typescript
Configuration
Add a test script to your package.json:
{
"name": "my-project",
"version": "1.0.0",
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"devDependencies": {
"jest": "^29.7.0"
}
}
You can skip jest.config.js until you actually need it: TypeScript via ts-jest, a jsdom test environment for browser code, or path aliases like @/components. If your app uses aliases and Jest says Cannot find module '@/utils', you need a matching moduleNameMapper entry.
Writing Your First Test
Jest follows a simple naming convention: test files should be named *.test.js or *.spec.js, or placed in a __tests__ directory. Here's a small math module and its tests:
// A simple module to test
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
function multiply(a, b) {
return a * b;
}
function divide(a, b) {
if (b === 0) {
throw new Error('Cannot divide by zero');
}
return a / b;
}
module.exports = { add, subtract, multiply, divide };
const { add, subtract, multiply, divide } = require('./math');
describe('Math functions', () => {
describe('add', () => {
it('should add two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('should add negative numbers', () => {
expect(add(-1, -2)).toBe(-3);
});
it('should handle zero', () => {
expect(add(5, 0)).toBe(5);
});
});
describe('divide', () => {
it('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('should throw error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
});
});
});
Run your tests with:
PASS ./math.test.js
Math functions
add
✓ should add two positive numbers (2 ms)
✓ should add negative numbers
✓ should handle zero
divide
✓ should divide two numbers (1 ms)
✓ should throw error when dividing by zero
Test Suites: 1 passed, 1 total
Tests: 5 passed, 5 total
Time: 0.892 s
Jest Matchers
Every assertion is expect(value) followed by a matcher. There are a lot of them, but in day-to-day work you'll mostly use a dozen or so. Here's how they group:
Matchers grouped by what they check
Common Matchers Examples
// toBe uses Object.is (strict equality)
expect(2 + 2).toBe(4);
expect('hello').toBe('hello');
// toEqual recursively checks object equality
expect({ name: 'John', age: 30 }).toEqual({ name: 'John', age: 30 });
expect([1, 2, 3]).toEqual([1, 2, 3]);
// toStrictEqual also checks for undefined properties
expect({ a: undefined, b: 2 }).not.toStrictEqual({ b: 2 });
// Truthiness matchers
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect('hello').toBeDefined();
// Boolean-like checks
expect(1).toBeTruthy();
expect(0).toBeFalsy();
expect('').toBeFalsy();
expect([]).toBeTruthy(); // Arrays are truthy!
// Negation with .not
expect(5).not.toBeNull();
expect('test').not.toBeFalsy();
// Numeric comparisons
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(5).toBeLessThan(10);
expect(5).toBeLessThanOrEqual(5);
// Floating point numbers (avoid toBe due to precision)
expect(0.1 + 0.2).toBeCloseTo(0.3, 5);
// Check for NaN
expect(NaN).toBeNaN();
expect(Number('abc')).toBeNaN();
// Array matchers
const fruits = ['apple', 'banana', 'orange'];
expect(fruits).toContain('banana');
expect(fruits).toHaveLength(3);
expect(fruits).not.toContain('grape');
// Check for object in array
const users = [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }];
expect(users).toContainEqual({ id: 1, name: 'John' });
// Object property checks
const user = { name: 'John', address: { city: 'NYC' } };
expect(user).toHaveProperty('name');
expect(user).toHaveProperty('address.city', 'NYC');
The mix-up I see most is using toBe on objects or arrays. Two objects with identical contents are still different references, so the test fails with a confusing message: Received: serializes to the same string. When you see that, switch to toEqual.
Mocking with Jest
A unit test that hits a real API is slow, flaky, and fails whenever the network or the other service has a bad day. Mocks let you swap those dependencies for fakes you control. Jest can replace single functions, whole modules, and even timers.
Function Mocks
// Creating a mock function
const mockCallback = jest.fn(x => x + 10);
[1, 2, 3].forEach(mockCallback);
// Checking how the mock was called
expect(mockCallback).toHaveBeenCalledTimes(3);
expect(mockCallback).toHaveBeenCalledWith(1);
expect(mockCallback).toHaveBeenLastCalledWith(3);
// Checking return values
expect(mockCallback.mock.results[0].value).toBe(11);
expect(mockCallback.mock.results[1].value).toBe(12);
// Mock implementation
const mockFn = jest.fn()
.mockReturnValueOnce(10)
.mockReturnValueOnce(20)
.mockReturnValue(30);
console.log(mockFn()); // 10
console.log(mockFn()); // 20
console.log(mockFn()); // 30
console.log(mockFn()); // 30
Module Mocking
// userService.js
const axios = require('axios');
async function getUser(id) {
const response = await axios.get(`/api/users/${id}`);
return response.data;
}
// Mock axios module
jest.mock('axios');
describe('getUser', () => {
it('should fetch user by id', async () => {
const mockUser = { id: 1, name: 'John Doe' };
// Setup mock response
axios.get.mockResolvedValue({ data: mockUser });
const user = await getUser(1);
expect(axios.get).toHaveBeenCalledWith('/api/users/1');
expect(user).toEqual(mockUser);
});
it('should handle errors', async () => {
axios.get.mockRejectedValue(new Error('Network error'));
await expect(getUser(1)).rejects.toThrow('Network error');
});
});
Two things to know here. First, jest.mock() calls are hoisted to the top of the file, above your imports, so you can't reference normal variables from the outer scope inside a mock factory (Jest throws an error unless the variable name starts with mock). Second, mocks keep their call history between tests by default. If a toHaveBeenCalledTimes check passes alone but fails in the full file, add clearMocks: true to your config or call jest.clearAllMocks() in a beforeEach.
Testing Asynchronous Code
Most real code waits on something: a fetch, a database query, a file read. Jest supports async/await, returned Promises, and old style callbacks. The rule that matters for all three: Jest has to know when the test is finished. If you forget to await or return the promise, the test ends early and passes even when the assertion would have failed.
Async/Await Pattern (Recommended)
// Using async/await - cleanest syntax
describe('Async operations', () => {
it('should fetch data successfully', async () => {
const data = await fetchData();
expect(data).toBeDefined();
expect(data.status).toBe('success');
});
it('should handle async errors', async () => {
await expect(fetchBadData()).rejects.toThrow('Not found');
});
it('should resolve with expected value', async () => {
await expect(Promise.resolve('value')).resolves.toBe('value');
});
});
Promise Pattern
// Using Promises - return the promise
it('should resolve with data', () => {
return fetchData().then(data => {
expect(data.status).toBe('success');
});
});
// Using .resolves/.rejects
it('should resolve with value', () => {
return expect(fetchData()).resolves.toMatchObject({
status: 'success'
});
});
it('should reject with error', () => {
return expect(failingRequest()).rejects.toThrow();
});
Callback Pattern (Legacy)
// Using done callback - for legacy callback-based APIs
it('should call the callback with data', done => {
function callback(error, data) {
try {
expect(error).toBeNull();
expect(data).toBe('success');
done(); // Call done() when finished
} catch (e) {
done(e); // Pass error to done
}
}
fetchWithCallback(callback);
});
Snapshot Testing
A snapshot test saves the output of your code the first time it runs and fails if that output changes later. It's handy for rendered components and any other serializable output where writing assertions by hand would be tedious.
import { render } from '@testing-library/react';
import UserCard from './UserCard';
describe('UserCard', () => {
it('should match snapshot', () => {
const user = { name: 'John', role: 'Developer' };
const { container } = render(<UserCard user={user} />);
// Creates/compares snapshot file
expect(container).toMatchSnapshot();
});
it('should match inline snapshot', () => {
const user = { name: 'Jane', email: 'jane@test.com' };
// Snapshot stored inline in test file
expect(user).toMatchInlineSnapshot(`
Object {
"email": "jane@test.com",
"name": "Jane",
}
`);
});
});
- Actually read snapshot diffs before committing; blindly pressing
udefeats the point - Keep snapshots small and focused
- Use inline snapshots for small, stable outputs
- Don't snapshot large or frequently changing data
- Update snapshots with
jest --updateSnapshotwhen intentional
Code Coverage
Coverage tells you which lines and branches your tests never ran. Jest has it built in, and with coverageThreshold you can make CI fail when coverage drops below a number you pick.
module.exports = {
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.test.{js,jsx,ts,tsx}',
'!src/index.js'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
Run tests with coverage:
----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
All files | 92.5 | 88.2 | 95.0 | 92.1 |
math.js | 100 | 100 | 100 | 100 |
user.js | 85.0 | 76.5 | 90.0 | 84.2 |
----------|---------|----------|---------|---------|
Understanding Coverage Metrics
Statements: % of statements executed
Branches: % of if/else branches taken
Functions: % of functions called
Lines: % of lines executed
Test-Driven Development (TDD)
With TDD you flip the usual order and write the test first. It runs in a loop of three steps: Red (write a test that fails), Green (write just enough code to pass it), and Refactor (clean up while the tests stay green). It works best when you already know the expected inputs and outputs, like a parser or a pricing rule.
TDD in Practice
Write a Failing Test (Red)
Start by writing a test for the behavior you want to implement. Run it. It should fail, because the functionality doesn't exist yet.
Make It Pass (Green)
Write the minimum amount of code necessary to make the test pass. Don't worry about elegance or optimization at this stage.
Refactor
Now clean up while keeping every test green. Remove duplication, fix naming, tidy the logic. If you break something, the tests tell you right away.
Testing Best Practices
| Practice | Do | Don't |
|---|---|---|
| Test Names | Describe behavior: "should return empty array when no items" |
Vague names: "test1", "works" |
| Test Size | One assertion per concept, small focused tests | Giant tests with many unrelated assertions |
| Dependencies | Mock external dependencies (APIs, databases) | Test against real external services |
| Test Data | Use factories or fixtures for consistent data | Hardcode random data throughout tests |
| Isolation | Each test should be independent | Tests that depend on other tests' state |
| Coverage | Focus on critical paths and edge cases | Chase 100% coverage at all costs |
The AAA Pattern
Structure your tests using the Arrange-Act-Assert pattern:
- Arrange: Set up test data and conditions
- Act: Execute the code being tested
- Assert: Verify the results are as expected
describe('ShoppingCart', () => {
it('should calculate total with discount', () => {
// Arrange
const cart = new ShoppingCart();
cart.addItem({ name: 'Laptop', price: 1000 });
cart.addItem({ name: 'Mouse', price: 50 });
cart.applyDiscount(0.1); // 10% discount
// Act
const total = cart.calculateTotal();
// Assert
expect(total).toBe(945); // (1000 + 50) * 0.9
});
});
Useful Jest CLI Options
# Run tests in watch mode
jest --watch
# Run only changed tests
jest --onlyChanged
# Run specific test file
jest user.test.js
# Run tests matching pattern
jest --testNamePattern="should validate"
# Run with coverage
jest --coverage
# Update snapshots
jest --updateSnapshot
# Run tests in band (sequentially)
jest --runInBand
# Verbose output
jest --verbose
# Clear cache
jest --clearCache
When I'd use Jest (and when I wouldn't)
For a Node backend, a React app built with webpack, or a shared utility library, Jest is still my default. It's well documented, nearly every tutorial and Stack Overflow answer assumes it, and the mocking API covers most of what you need without extra packages.
There are cases where I'd pick something else:
- Vite projects: Vitest uses your existing Vite config and has a mostly Jest compatible API. Getting Jest to understand Vite's transforms and native ES modules usually means extra Babel or ts-jest setup, and ESM support in Jest is still marked experimental.
- Real browser behavior: jsdom is a simulation. Layout, scrolling and CSS don't really happen there, so for those use Playwright or Cypress instead of trying to fake it in a unit test.
- Heavily mocked code: if a test needs five mocks to run, it's mostly testing your mocks. An integration test against a real (test) database often catches more bugs for less effort.
And whatever runner you use, don't treat the coverage number as the goal. A file can be at 100% line coverage while none of its assertions check anything meaningful. I'd rather have solid tests around the risky parts (money, auth, data transforms) than a high percentage from tests that just call functions.
The Jest docs on custom matchers and fake timers are worth a read once the basics feel comfortable. For React, pair Jest with Testing Library so your tests query the page the way a user would (by label and role) instead of reaching into component internals.