Unit Testing in JavaScript with Jest
Jest is a delightful JavaScript testing framework developed by Facebook (now Meta) that focuses on simplicity and developer experience. Whether you're testing React components, Node.js backends, or vanilla JavaScript libraries, Jest provides everything you need out of the box—zero configuration required for most projects.
Unit testing is essential for maintaining code quality, catching bugs early, and enabling confident refactoring. In this comprehensive guide, we'll explore Jest from the fundamentals to advanced techniques, complete with practical examples and best practices you can apply immediately.
- 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?
Jest has become the de facto standard for JavaScript testing, and for good reason. It offers a complete testing solution with built-in assertions, mocking, code coverage, and a powerful CLI. Unlike older testing setups that required combining multiple libraries, Jest provides everything in a single package.
Jest replaces an entire testing stack with a single, cohesive solution
Zero Config
Works out of the box for most JavaScript projects. Just install and run.
Fast Parallel Testing
Tests run in parallel in separate processes for maximum performance.
Snapshot Testing
Capture component output and detect unexpected changes automatically.
Watch Mode
Intelligently runs only tests related to changed files during development.
Getting Started with Jest
Let's set up Jest in a new project. The installation process is straightforward, and Jest works immediately with sensible defaults.
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"
}
}
For most projects, Jest's defaults work great. You only need a jest.config.js file for custom configurations like TypeScript support, custom test environments, or module aliases.
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. Let's write our first test:
// 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 Deep Dive
Matchers are the heart of Jest assertions. They let you test values in different ways. Jest provides a rich set of matchers for virtually any testing scenario.
Jest provides matchers for every testing scenario
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');
Mocking with Jest
Mocking is essential for isolating the code under test from external dependencies. Jest provides powerful mocking capabilities that let you replace functions, modules, and timers with controlled implementations.
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');
});
});
Testing Asynchronous Code
Modern JavaScript is inherently asynchronous. Jest provides several ways to test async code, including callbacks, Promises, and async/await syntax.
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
Snapshot testing captures the output of your code and compares it against a stored reference. It's particularly useful for testing UI components, but can also be used for any serializable output.
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",
}
`);
});
});
- Review snapshot changes carefully before committing
- 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
Code coverage shows how much of your code is executed during tests. Jest has built-in coverage reporting that identifies untested code paths.
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)
Test-Driven Development is a software development approach where you write tests before writing the actual code. The TDD cycle follows three steps: Red (write a failing test), Green (write minimal code to pass), and Refactor (improve the code).
TDD in Practice
Write a Failing Test (Red)
Start by writing a test for the behavior you want to implement. Run the test—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 improve your code while keeping all tests green. Remove duplication, improve naming, optimize performance. The tests ensure you don't break anything.
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
Conclusion
Jest has transformed JavaScript testing by providing a comprehensive, zero-configuration solution that scales from simple utility functions to complex React applications. Its powerful features—including mocking, snapshot testing, and built-in code coverage—make it the go-to choice for modern JavaScript development.
Key takeaways from this guide:
- Start Simple: Begin with basic tests and gradually add complexity
- Mock Strategically: Isolate your tests from external dependencies
- Embrace Async: Use async/await for cleaner asynchronous tests
- Coverage Matters: Aim for meaningful coverage, not just numbers
- Practice TDD: Write tests first when tackling new features
Testing isn't just about catching bugs—it's about building confidence in your code. With Jest in your toolkit, you have everything you need to write reliable, maintainable JavaScript applications. Start testing today, and your future self will thank you!
Ready to level up? Explore Jest's documentation for advanced features like custom matchers, testing timers, and integrating with TypeScript. Also check out Testing Library for testing React components with a user-centric approach.
