Jest Unit Testing Mastery: The Complete Guide for React & Angular
Master Jest from setup to advanced patterns. Covers matchers, mocking (jest.fn, jest.mock, jest.spyOn), async testing, timers, snapshot testing, code coverage, and component testing for both React (Testing Library) and Angular (TestBed) with full interview preparation.
- 1. Jest Unit Testing Mastery: The Complete Guide for React & Angular
- 2. Angular Testing Mastery: The Complete Guide to TestBed, Mocking & Beyond
Introduction
Every developer writes code. But great developers write code they can prove works — and they do it with unit tests.
Unit testing is the practice of verifying that individual pieces of your code (functions, classes, components) work exactly as expected in isolation. Among the many testing frameworks available, Jest has become the undisputed standard. Created by Facebook, Jest powers the test suites at Facebook, Instagram, Twitter, Spotify, and thousands of other companies. It's the default testing framework for React (Create React App, Vite, Next.js) and is widely used in Angular projects alongside Karma.
Why Jest specifically? Because it's an all-in-one solution. Other testing setups require you to wire together a test runner (Mocha), an assertion library (Chai), a mocking library (Sinon), and a coverage tool (Istanbul) — each with its own API and quirks. Jest bundles everything into a single, cohesive package that just works out of the box. Zero configuration for most projects, blazing-fast parallel test execution, powerful mocking capabilities, built-in code coverage, and snapshot testing — it's all there.
This guide is your complete, intensive reference to Jest. We'll cover the fundamentals, progressive matchers, mocking strategies, async patterns, component testing for both React and Angular, and close with interview preparation. By the end, you'll be writing tests with confidence and speed.
What is Unit Testing?
Imagine you're building a house. Before the walls go up, an inspector checks each individual brick — is it the right size? Does it hold weight? Is it level? That's unit testing in software. You test each small piece (a function, a class, a component) in isolation before combining them into a full application.
A unit test is a small, automated piece of code that:
- Calls a specific function or component with known inputs
- Checks that the output matches what you expect
- Runs in milliseconds, giving you instant feedback
// You write a function
function add(a: number, b: number): number {
return a + b;
}
// You write a test that PROVES it works
test('add(2, 3) should return 5', () => {
expect(add(2, 3)).toBe(5); // ✅ Pass!
});
Why bother? Because manual testing is slow, unreliable, and doesn't scale. When your codebase has 100 functions, you can't manually re-test all of them every time you change one line. Unit tests run all 100 checks in seconds.
How This Guide is Structured
This guide progresses from simple to advanced. If you're a complete beginner, start from the top and work through each section. If you're already familiar with the basics, jump to the section you need:
| Section | Difficulty | What You'll Learn |
|---|---|---|
| Setting Up Jest | 🟢 Beginner | Installation and configuration |
| Your First Test | 🟢 Beginner | AAA pattern, describe/it/expect |
| Matchers Deep Dive | 🟢 Beginner | All assertion methods |
| Mocking Fundamentals | 🟡 Intermediate | jest.fn, jest.mock, jest.spyOn |
| Mocking Modules & APIs | 🟡 Intermediate | Axios, fetch, partial mocks |
| Testing Async Code | 🟡 Intermediate | Promises, async/await, timers |
| Setup and Teardown | 🟡 Intermediate | beforeEach, afterEach, lifecycle |
| Code Coverage | 🟡 Intermediate | Coverage reports and thresholds |
| Snapshot Testing | 🟡 Intermediate | When and how to use snapshots |
| Testing React Components | 🟡 Intermediate | React Testing Library |
| Testing Angular Components | 🟡 Intermediate | TestBed with Jest |
| Common Mistakes | 🔴 Advanced | Pitfalls that trip everyone up |
| Interview Preparation | 🔴 Advanced | Real interview Q&A |
Setting Up Jest
React Projects (Vite)
# Install Jest with React Testing Library
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event
# For TypeScript support
npm install --save-dev ts-jest @types/jest
Create jest.config.ts:
export default {
testEnvironment: 'jsdom',
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
moduleNameMapper: {
'\\.(css|less|scss)$': 'identity-obj-proxy',
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFilesAfterSetup: ['@testing-library/jest-dom'], // Adds custom matchers like toBeInTheDocument()
};
Angular Projects
Angular CLI projects come with Karma + Jasmine by default, but you can switch to Jest for faster execution and a better developer experience:
# Install Jest for Angular
npm install --save-dev jest @types/jest jest-preset-angular
# Remove Karma dependencies
npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter
Create jest.config.ts:
export default {
preset: 'jest-preset-angular',
setupFilesAfterSetup: ['<rootDir>/setup-jest.ts'], // Initializes Angular testing environment
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
moduleNameMapper: {
'^@app/(.*)$': '<rootDir>/src/app/$1',
'^@env/(.*)$': '<rootDir>/src/environments/$1',
},
};
Create setup-jest.ts:
import 'jest-preset-angular/setup-jest';
Update tsconfig.spec.json:
{
"compilerOptions": {
"types": ["jest"]
}
}
Running Tests
# Run all tests
npx jest
# Run tests in watch mode (re-runs on file changes)
npx jest --watch
# Run a specific test file
npx jest src/utils/math.test.ts
# Run with coverage
npx jest --coverage
Your First Test
The AAA Pattern
Every good test follows the Arrange-Act-Assert pattern — the three phases of a unit test:
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
// math.test.ts
import { add } from './math';
describe('add', () => {
it('should add two positive numbers correctly', () => {
// Arrange — set up the inputs
const a = 3;
const b = 5;
// Act — execute the function under test
const result = add(a, b);
// Assert — verify the output
expect(result).toBe(8);
});
it('should handle negative numbers', () => {
expect(add(-3, -5)).toBe(-8);
});
it('should handle adding zero', () => {
expect(add(0, 5)).toBe(5);
});
});
Breaking this down:
describegroups related tests together under a label. You can nestdescribeblocks for sub-grouping (e.g.,describe('add')→describe('with negative numbers')).it(ortest— they're identical) defines a single test case. The string should read like a sentence: "it should add two positive numbers correctly."expectcreates an assertion. You pass in the actual value and chain a matcher (like.toBe()) to declare what you expect.
The Test File Convention
Jest automatically finds test files matching these patterns:
| Pattern | Example |
|---|---|
*.test.ts / *.test.tsx | math.test.ts |
*.spec.ts / *.spec.tsx | math.spec.ts |
Files inside __tests__/ | __tests__/math.ts |
Recommendation: Place test files next to the source file they test. This makes it trivial to find the tests for any given file and keeps related code co-located.
Matchers Deep Dive
Matchers are the assertion methods you chain after expect(). Jest ships with a rich set of matchers that cover virtually every testing scenario.
Equality Matchers
// toBe — strict equality (===), use for primitives
expect(2 + 2).toBe(4);
expect('hello').toBe('hello');
// toEqual — deep equality, use for objects and arrays
expect({ name: 'Alice', age: 30 }).toEqual({ name: 'Alice', age: 30 });
expect([1, 2, 3]).toEqual([1, 2, 3]);
// ⚠️ Common mistake: toBe fails for objects even with identical content
expect({ a: 1 }).toBe({ a: 1 }); // ❌ FAILS — different references
expect({ a: 1 }).toEqual({ a: 1 }); // ✅ PASSES — same structure
The critical distinction: toBe checks reference identity (===), while toEqual recursively checks every property. For objects and arrays, always use toEqual. For primitives (numbers, strings, booleans), either works, but toBe is conventional.
Truthiness Matchers
// Checking for null, undefined, and truthiness
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect('hello').toBeDefined();
expect(1).toBeTruthy();
expect(0).toBeFalsy();
These matchers are invaluable when testing functions that return optional values or when you need to verify that a variable was properly initialized. toBeTruthy() matches any value that JavaScript treats as truthy in a boolean context, while toBeFalsy() matches false, 0, '', null, undefined, and NaN.
Number Matchers
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(5).toBeLessThan(10);
expect(5).toBeLessThanOrEqual(5);
// For floating-point numbers — NEVER use toBe
expect(0.1 + 0.2).toBe(0.3); // ❌ FAILS (floating-point precision)
expect(0.1 + 0.2).toBeCloseTo(0.3); // ✅ PASSES (within rounding tolerance)
Floating-point arithmetic is notoriously imprecise in all programming languages. toBeCloseTo accounts for this by checking if the values are within a small epsilon. Always use it when comparing decimal results.
String Matchers
expect('Hello, World!').toContain('World');
expect('Hello, World!').toMatch(/hello/i); // regex
expect('Jest is awesome').toMatch('awesome');
// Negation — any matcher can be inverted with .not
expect('Hello').not.toContain('Goodbye');
Array and Iterable Matchers
const shoppingList = ['milk', 'eggs', 'bread', 'butter'];
expect(shoppingList).toContain('eggs');
expect(shoppingList).toHaveLength(4);
expect(shoppingList).toEqual(expect.arrayContaining(['eggs', 'bread']));
// Object matching within arrays
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
expect(users).toContainEqual({ id: 1, name: 'Alice' });
expect.arrayContaining is particularly useful when you want to assert that an array contains certain elements without caring about order or other elements. It pairs perfectly with API response testing.
Exception Matchers
function validateAge(age: number): void {
if (age < 0) throw new Error('Age cannot be negative');
if (age > 150) throw new RangeError('Age is unrealistic');
}
// Must wrap in a function to catch the throw
expect(() => validateAge(-1)).toThrow();
expect(() => validateAge(-1)).toThrow('Age cannot be negative');
expect(() => validateAge(-1)).toThrow(Error);
expect(() => validateAge(200)).toThrow(RangeError);
Important: When testing thrown errors, you must wrap the call in an arrow function. If you write expect(validateAge(-1)).toThrow(), the error fires before Jest can catch it, and your test crashes.
Mocking Fundamentals
Mocking is where unit testing gets powerful — and where most developers struggle. A mock replaces a real dependency with a controlled substitute, so you can test your code in isolation without worrying about databases, APIs, or file systems.
Why Mock?
Consider this function:
import { fetchUserFromAPI } from './api';
export async function getGreeting(userId: string): Promise<string> {
const user = await fetchUserFromAPI(userId);
return `Hello, ${user.name}!`;
}
To test getGreeting, you don't want to make a real API call. You want to mock fetchUserFromAPI so it returns a predictable response instantly.
jest.fn() — Creating Mock Functions
jest.fn() creates a mock function that tracks how it's been called:
const mockFn = jest.fn();
// Call the mock
mockFn('hello', 42);
mockFn('world');
// Assert how it was called
expect(mockFn).toHaveBeenCalled(); // Was it called at all?
expect(mockFn).toHaveBeenCalledTimes(2); // How many times?
expect(mockFn).toHaveBeenCalledWith('hello', 42); // With these exact arguments?
expect(mockFn).toHaveBeenLastCalledWith('world'); // Last call arguments?
Mock Return Values
const mockFn = jest.fn();
// Return a specific value
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);
// Return different values on consecutive calls
mockFn
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default');
expect(mockFn()).toBe('first');
expect(mockFn()).toBe('second');
expect(mockFn()).toBe('default');
expect(mockFn()).toBe('default'); // subsequent calls return default
// For async functions
const mockAsync = jest.fn().mockResolvedValue({ name: 'Alice' });
const result = await mockAsync();
expect(result).toEqual({ name: 'Alice' });
jest.mock() — Mocking Entire Modules
This is one of Jest's most powerful features. jest.mock() replaces an entire module with mock implementations:
// user-service.ts
import { fetchUser } from './api';
export async function getUserDisplayName(id: string): Promise<string> {
const user = await fetchUser(id);
return `${user.firstName} ${user.lastName}`;
}
// user-service.test.ts
import { getUserDisplayName } from './user-service';
import { fetchUser } from './api';
// Mock the entire './api' module
jest.mock('./api');
// TypeScript: cast to mock type
const mockFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>;
describe('getUserDisplayName', () => {
it('should return the full display name', async () => {
// Arrange — configure the mock's return value
mockFetchUser.mockResolvedValue({
id: '1',
firstName: 'Alice',
lastName: 'Johnson',
});
// Act
const name = await getUserDisplayName('1');
// Assert
expect(name).toBe('Alice Johnson');
expect(mockFetchUser).toHaveBeenCalledWith('1');
});
});
When you call jest.mock('./api'), Jest automatically replaces every export from ./api with jest.fn(). You then configure each mock individually in your tests.
jest.spyOn() — Spying on Existing Methods
Sometimes you don't want to replace a function entirely — you just want to observe it and optionally override its behavior:
const calculator = {
add(a: number, b: number) {
return a + b;
},
multiply(a: number, b: number) {
return a * b;
},
};
// Spy on the 'add' method
const spy = jest.spyOn(calculator, 'add');
calculator.add(2, 3);
expect(spy).toHaveBeenCalledWith(2, 3);
expect(spy).toHaveReturnedWith(5);
// Override the implementation temporarily
spy.mockReturnValue(999);
expect(calculator.add(2, 3)).toBe(999);
// Restore the original
spy.mockRestore();
expect(calculator.add(2, 3)).toBe(5);
When to use spyOn vs jest.mock():
| Scenario | Use |
|---|---|
| Replace an entire module | jest.mock() |
| Override one method on an object | jest.spyOn() |
| Track calls to a real method | jest.spyOn() without override |
| Test a function that calls another in the same file | jest.spyOn() on the module |
Clearing, Resetting, and Restoring Mocks
This is a source of confusion. Here's the definitive guide:
const mockFn = jest.fn().mockReturnValue(42);
mockFn('test');
// mockClear — clears call history only (keeps implementation)
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // ✅ history cleared
expect(mockFn()).toBe(42); // ✅ still returns 42
// mockReset — clears history AND implementation
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // ✅ returns undefined
// mockRestore — restores the original (only works with spyOn)
const spy = jest.spyOn(console, 'log');
spy.mockRestore(); // console.log is back to normal
| Method | Clears History | Clears Implementation | Restores Original |
|---|---|---|---|
mockClear() | ✅ | ❌ | ❌ |
mockReset() | ✅ | ✅ | ❌ |
mockRestore() | ✅ | ✅ | ✅ |
Best practice: Use jest.clearAllMocks() in a beforeEach block to ensure every test starts with a clean call history:
beforeEach(() => {
jest.clearAllMocks();
});
Mocking Modules and API Calls
Mocking Axios/Fetch
API calls are the most common thing you'll mock. Here's how to handle both axios and fetch:
// api.ts
import axios from 'axios';
export async function getUsers(): Promise<User[]> {
const response = await axios.get('/api/users');
return response.data;
}
// api.test.ts
import axios from 'axios';
import { getUsers } from './api';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('getUsers', () => {
it('should fetch users from the API', async () => {
const mockUsers = [{ id: 1, name: 'Alice' }];
mockedAxios.get.mockResolvedValue({ data: mockUsers });
const users = await getUsers();
expect(users).toEqual(mockUsers);
expect(mockedAxios.get).toHaveBeenCalledWith('/api/users');
});
it('should handle API errors gracefully', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network Error'));
await expect(getUsers()).rejects.toThrow('Network Error');
});
});
Mocking fetch (No Library)
// Without a library, mock the global fetch
global.fetch = jest.fn();
describe('fetchData', () => {
it('should parse JSON response', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'Alice' }),
});
const response = await fetch('/api/users/1');
const data = await response.json();
expect(data).toEqual({ id: 1, name: 'Alice' });
});
});
Partial Module Mocks
Sometimes you want to mock one export from a module while keeping the rest real:
// Only mock 'fetchUser' but keep 'formatUser' real
jest.mock('./user-utils', () => ({
...jest.requireActual('./user-utils'),
fetchUser: jest.fn(),
}));
jest.requireActual() imports the real module, and you override only the specific exports you need to mock. This is essential when a module has both utility functions and API functions — you want real utilities but mocked API calls.
Testing Async Code
Modern applications are heavily asynchronous. Jest handles every async pattern:
Async/Await (Recommended)
// The cleanest approach for most async tests
it('should fetch user data', async () => {
const user = await fetchUser('123');
expect(user.name).toBe('Alice');
});
// Testing rejections
it('should throw on invalid ID', async () => {
await expect(fetchUser('')).rejects.toThrow('ID required');
});
Promises
it('should resolve with user data', () => {
return expect(fetchUser('123')).resolves.toEqual({
id: '123',
name: 'Alice',
});
});
it('should reject with error', () => {
return expect(fetchUser('')).rejects.toThrow('ID required');
});
Note: When using .resolves or .rejects without async/await, you must return the promise. If you forget, the test will pass before the assertion runs.
Timers
Testing setTimeout, setInterval, and Date.now():
// debounce.ts
export function debounce<T extends (...args: any[]) => void>(
fn: T,
delay: number
): T {
let timer: ReturnType<typeof setTimeout>;
return ((...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
}) as T;
}
// debounce.test.ts
import { debounce } from './debounce';
describe('debounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should call the function after the delay', () => {
const mockFn = jest.fn();
const debounced = debounce(mockFn, 500);
debounced('hello');
// Function hasn't been called yet
expect(mockFn).not.toHaveBeenCalled();
// Fast-forward 500ms
jest.advanceTimersByTime(500);
// Now it should have been called
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should reset the timer on subsequent calls', () => {
const mockFn = jest.fn();
const debounced = debounce(mockFn, 500);
debounced('first');
jest.advanceTimersByTime(300);
debounced('second'); // resets the timer
jest.advanceTimersByTime(300);
expect(mockFn).not.toHaveBeenCalled(); // only 300ms since reset
jest.advanceTimersByTime(200);
expect(mockFn).toHaveBeenCalledWith('second');
expect(mockFn).toHaveBeenCalledTimes(1);
});
});
jest.useFakeTimers() replaces setTimeout, setInterval, Date.now(), and other time-related functions with controllable versions. jest.advanceTimersByTime(ms) fast-forwards the clock by the specified milliseconds, causing any pending timers to fire. This eliminates flaky tests caused by real timing delays.
Setup and Teardown
When multiple tests share setup logic, use lifecycle hooks to avoid duplication:
describe('Database operations', () => {
let db: Database;
// Runs ONCE before all tests in this describe block
beforeAll(async () => {
db = await Database.connect('test-db');
});
// Runs ONCE after all tests in this describe block
afterAll(async () => {
await db.disconnect();
});
// Runs before EACH individual test
beforeEach(async () => {
await db.clear();
await db.seed(testData);
});
// Runs after EACH individual test
afterEach(() => {
jest.clearAllMocks();
});
it('should insert a record', async () => {
await db.insert({ name: 'Alice' });
const users = await db.findAll();
expect(users).toHaveLength(testData.length + 1);
});
it('should delete a record', async () => {
await db.delete(1);
const users = await db.findAll();
expect(users).toHaveLength(testData.length - 1);
});
});
Scoping with Nested describe
Lifecycle hooks are scoped to their describe block. This lets you create specialized setup for groups of related tests:
describe('UserService', () => {
beforeEach(() => {
// Shared setup for ALL UserService tests
jest.clearAllMocks();
});
describe('when user exists', () => {
beforeEach(() => {
// Setup specific to "user exists" tests
mockFetchUser.mockResolvedValue({ id: '1', name: 'Alice' });
});
it('should return the user', async () => {
const user = await userService.getUser('1');
expect(user.name).toBe('Alice');
});
it('should return cached user on second call', async () => {
await userService.getUser('1');
await userService.getUser('1');
expect(mockFetchUser).toHaveBeenCalledTimes(1);
});
});
describe('when user does not exist', () => {
beforeEach(() => {
mockFetchUser.mockResolvedValue(null);
});
it('should throw UserNotFoundError', async () => {
await expect(userService.getUser('999')).rejects.toThrow('not found');
});
});
});
This nested structure makes your test output highly readable:
UserService
when user exists
✓ should return the user
✓ should return cached user on second call
when user does not exist
✓ should throw UserNotFoundError
Code Coverage
Jest has built-in code coverage powered by Istanbul. Run with --coverage:
npx jest --coverage
Understanding the Coverage Report
----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------------------|---------|----------|---------|---------|
All files | 85.71 | 66.67 | 100 | 83.33 |
math.ts | 100 | 100 | 100 | 100 |
user-service.ts | 71.43 | 33.33 | 100 | 66.67 |
----------------------|---------|----------|---------|---------|
| Metric | What It Measures |
|---|---|
| Statements | Percentage of code statements executed during tests |
| Branches | Percentage of conditional branches (if/else, ternary, switch cases) covered |
| Functions | Percentage of functions that were called at least once |
| Lines | Percentage of executable lines that ran |
Setting Coverage Thresholds
Add to jest.config.ts to enforce minimum coverage:
export default {
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
If coverage drops below these thresholds, jest --coverage will fail. This is valuable in CI/CD pipelines to prevent coverage regression.
What Should You NOT Cover?
- Third-party libraries — You don't test React, Angular, or lodash
- Type definitions — TypeScript interfaces have no runtime code
- Configuration files —
jest.config.ts,tsconfig.json, etc. - Index files — Barrel files that just re-export
Add these to coveragePathIgnorePatterns in your jest config.
Snapshot Testing
Snapshots capture the output of a function or component and save it to a file. On subsequent runs, Jest compares the new output to the saved snapshot.
// format-user.ts
export function formatUserCard(user: User): string {
return `
Name: ${user.name}
Email: ${user.email}
Role: ${user.role}
Joined: ${user.joinDate}
`.trim();
}
// format-user.test.ts
import { formatUserCard } from './format-user';
it('should match the expected card format', () => {
const user = {
name: 'Alice Johnson',
email: 'alice@example.com',
role: 'Admin',
joinDate: '2024-01-15',
};
expect(formatUserCard(user)).toMatchSnapshot();
});
First run: Jest creates a .snap file with the output.
Subsequent runs: Jest compares the current output against the snapshot. If they differ, the test fails.
Updating Snapshots
When intentional changes cause snapshot failures:
npx jest --updateSnapshot
# or shorthand
npx jest -u
Inline Snapshots
For small outputs, inline snapshots are cleaner:
it('should format the greeting', () => {
expect(greet('Alice')).toMatchInlineSnapshot(`"Hello, Alice! Welcome back."`);
});
The snapshot is stored right in the test file, making it easy to review.
When to Use and NOT Use Snapshots
| ✅ Good Use Cases | ❌ Bad Use Cases |
|---|---|
| Serialized data formats | Dynamic content (timestamps, random IDs) |
| Error message formatting | Large component trees |
| Configuration objects | Frequently changing UIs |
| API response shapes | Anything where a specific assertion is clearer |
The golden rule: If a specific assertion (toBe, toEqual) can express your intent clearly, prefer it over a snapshot. Snapshots are best as a safety net for output format, not as a substitute for meaningful assertions.
Testing React Components
React Testing Library (RTL) is the standard for testing React components. Its philosophy: test what the user sees and does, not implementation details.
Rendering and Querying
// Greeting.tsx
interface GreetingProps {
name: string;
isLoggedIn: boolean;
}
export function Greeting({ name, isLoggedIn }: GreetingProps) {
if (!isLoggedIn) {
return <p>Please log in to continue.</p>;
}
return (
<div>
<h1>Welcome back, {name}!</h1>
<p>You have 3 new notifications.</p>
</div>
);
}
// Greeting.test.tsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
describe('Greeting', () => {
it('should show login prompt when not logged in', () => {
render(<Greeting name="Alice" isLoggedIn={false} />);
expect(screen.getByText('Please log in to continue.')).toBeInTheDocument();
expect(screen.queryByText('Welcome back')).not.toBeInTheDocument();
});
it('should show welcome message when logged in', () => {
render(<Greeting name="Alice" isLoggedIn={true} />);
expect(screen.getByText('Welcome back, Alice!')).toBeInTheDocument();
expect(screen.getByText('You have 3 new notifications.')).toBeInTheDocument();
});
});
Query Priority
RTL provides multiple queries. Use them in this priority order (most accessible first):
| Priority | Query | Use When |
|---|---|---|
| 1st | getByRole | Element has an ARIA role (button, heading, textbox) |
| 2nd | getByLabelText | Form elements with associated labels |
| 3rd | getByPlaceholderText | Input with placeholder |
| 4th | getByText | Non-interactive elements with visible text |
| 5th | getByTestId | Last resort — no accessible way to query |
Testing User Interactions
// Counter.tsx
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(c => c - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
// Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('should start at zero', () => {
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
it('should increment when clicking the increment button', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});
it('should reset to zero', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: 'Increment' }));
await user.click(screen.getByRole('button', { name: 'Increment' }));
await user.click(screen.getByRole('button', { name: 'Reset' }));
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
});
Testing Forms with User Events
// LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('should submit with email and password', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'secure123');
await user.click(screen.getByRole('button', { name: 'Sign In' }));
expect(mockSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secure123',
});
});
it('should show validation error for empty email', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
await user.click(screen.getByRole('button', { name: 'Sign In' }));
expect(screen.getByText('Email is required')).toBeInTheDocument();
});
});
Testing Angular Components with Jest
While Angular traditionally uses Karma, Jest works perfectly with Angular via jest-preset-angular. The key difference is using TestBed for dependency injection:
Basic Component Test
// greeting.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-greeting',
template: `
<div *ngIf="isLoggedIn; else loginPrompt">
<h1>Welcome back, {{ name }}!</h1>
<p>You have {{ notificationCount }} new notifications.</p>
</div>
<ng-template #loginPrompt>
<p>Please log in to continue.</p>
</ng-template>
`,
})
export class GreetingComponent {
@Input() name = '';
@Input() isLoggedIn = false;
@Input() notificationCount = 0;
}
// greeting.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GreetingComponent } from './greeting.component';
describe('GreetingComponent', () => {
let component: GreetingComponent;
let fixture: ComponentFixture<GreetingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [GreetingComponent],
}).compileComponents();
fixture = TestBed.createComponent(GreetingComponent);
component = fixture.componentInstance;
});
it('should show login prompt when not logged in', () => {
component.isLoggedIn = false;
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Please log in to continue.');
expect(element.querySelector('h1')).toBeNull();
});
it('should show welcome message when logged in', () => {
component.name = 'Alice';
component.isLoggedIn = true;
component.notificationCount = 3;
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('h1')?.textContent).toContain('Welcome back, Alice!');
expect(element.textContent).toContain('3 new notifications');
});
});
Testing a Component with a Service
// user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UserService {
constructor(private http: HttpClient) {}
getUser(id: string): Observable<User> {
return this.http.get<User>(`/api/users/${id}`);
}
}
// user-profile.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { UserProfileComponent } from './user-profile.component';
import { UserService } from './user.service';
describe('UserProfileComponent', () => {
let component: UserProfileComponent;
let fixture: ComponentFixture<UserProfileComponent>;
let mockUserService: jest.Mocked<UserService>;
beforeEach(async () => {
// Create a mock UserService with jest.fn() methods
mockUserService = {
getUser: jest.fn(),
} as any;
await TestBed.configureTestingModule({
declarations: [UserProfileComponent],
providers: [
{ provide: UserService, useValue: mockUserService },
],
}).compileComponents();
fixture = TestBed.createComponent(UserProfileComponent);
component = fixture.componentInstance;
});
it('should display user name after loading', () => {
mockUserService.getUser.mockReturnValue(
of({ id: '1', name: 'Alice', email: 'alice@example.com' })
);
fixture.detectChanges(); // triggers ngOnInit
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Alice');
});
});
Key difference from React: Angular uses dependency injection through TestBed.configureTestingModule. You provide mock services using the providers array with { provide: RealService, useValue: mockService }. This is Angular's native way of wiring dependencies, making test setup declarative and predictable.
Common Mistakes to Avoid
Mistake 1: Testing Implementation Details
// ❌ BAD — tests HOW it works (implementation detail)
it('should call setState with the new count', () => {
const setState = jest.spyOn(React, 'useState');
// ... testing internal state management
});
// ✅ GOOD — tests WHAT it does (behavior)
it('should display the incremented count after clicking', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
Testing implementation details (like which internal methods are called or what the internal state looks like) creates brittle tests that break when you refactor, even if the behavior stays identical. Test the observable output — what the user sees and what external APIs receive.
Mistake 2: Not Testing Edge Cases
// ❌ Only testing the happy path
it('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
// ✅ Also test edge cases
it('should throw when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
it('should handle negative numbers', () => {
expect(divide(-10, 2)).toBe(-5);
});
it('should return 0 when numerator is 0', () => {
expect(divide(0, 5)).toBe(0);
});
Mistake 3: Over-Mocking
// ❌ Too many mocks — you're no longer testing real behavior
jest.mock('./utils');
jest.mock('./validators');
jest.mock('./formatters');
jest.mock('./logger');
it('should process the order', () => {
// At this point, everything is mocked.
// What are you actually testing?
});
If you find yourself mocking everything, your function might have too many dependencies. This is a design smell — consider refactoring to reduce coupling. Mock only external I/O boundaries (APIs, databases, file system) while keeping business logic real.
Mistake 4: Forgetting detectChanges() in Angular
// ❌ Angular: forgot to trigger change detection
it('should show the user name', () => {
component.name = 'Alice';
// Missing: fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Alice'); // FAILS
});
// ✅ Always call detectChanges after setting inputs
it('should show the user name', () => {
component.name = 'Alice';
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Alice');
});
Angular's change detection doesn't run automatically in tests. You must manually call fixture.detectChanges() after modifying inputs or triggering events. This is intentional — it gives you full control over when the DOM updates, which is essential for testing async flows.
Best Practices Summary
| Practice | Why |
|---|---|
| Follow AAA (Arrange-Act-Assert) | Makes tests readable and predictable |
| Test behavior, not implementation | Tests survive refactoring |
| Mock only I/O boundaries | Keeps business logic real |
Use clearAllMocks in beforeEach | Prevents test pollution |
| Write descriptive test names | Tests serve as documentation |
| Test edge cases | Catches bugs before users do |
| Use coverage thresholds | Prevents regression |
Prefer userEvent over fireEvent | Simulates real user behavior more accurately |
Interview Preparation
Common Interview Questions
Q: What is the difference between toBe and toEqual?
toBeuses strict equality (===), meaning it checks reference identity. Two different objects with identical properties will failtoBe.toEqualperforms a deep recursive comparison of all properties, so it passes as long as the structure and values match. UsetoBefor primitives (numbers, strings, booleans) andtoEqualfor objects and arrays.
Q: Explain jest.fn(), jest.mock(), and jest.spyOn() — when do you use each?
jest.fn()creates a standalone mock function from scratch — use it when you need a mock callback or dependency.jest.mock()replaces an entire module's exports with mocks — use it when your function imports from another module and you want to control those imports.jest.spyOn()wraps an existing method on an object, tracking calls while optionally overriding behavior — use it when you want to observe calls to a real method or temporarily override one method without replacing the whole module.
Q: How do you test asynchronous code in Jest?
Jest supports multiple patterns:
async/await(preferred — cleanest syntax), returning a promise from the test, using.resolves/.rejectsmatchers, and using thedonecallback (legacy). For timer-based async code, usejest.useFakeTimers()withjest.advanceTimersByTime()to avoid real waiting. Always ensure async tests are properly awaited — unawaited assertions silently pass.
Q: What's the difference between mockClear, mockReset, and mockRestore?
mockClearclears the call history (.mock.calls,.mock.results) but keeps the mock implementation.mockResetclears both history and implementation, making the mock returnundefined.mockRestoredoes everythingmockResetdoes AND restores the original implementation — but only works withjest.spyOn(). Best practice is to calljest.clearAllMocks()inbeforeEachto ensure clean state between tests.
Q: What is snapshot testing and when should you use it?
Snapshot testing captures the serialized output of a value and saves it to a
.snapfile. On subsequent test runs, Jest compares the current output against the stored snapshot. Use snapshots for stable output formats (error messages, serialized data, configuration objects). Avoid them for frequently changing content, dynamic values (timestamps, random IDs), or large component trees. If a specific assertion liketoBeortoEqualcan express your intent clearly, prefer that over a snapshot.
Q: How does React Testing Library differ from Enzyme?
Enzyme tests components from the inside out — it gives access to internal state, props, and lifecycle methods. React Testing Library tests from the outside in — it only interacts with what a real user can see and do (DOM elements, text content, ARIA roles). RTL's approach produces tests that are more resilient to refactoring because they don't depend on implementation details. The React team officially recommends Testing Library over Enzyme, and Enzyme hasn't been updated for React 18+.
Conclusion
Jest isn't just a testing framework — it's a development accelerator. When you have a comprehensive test suite, you can:
- Refactor fearlessly — tests catch regressions instantly
- Ship faster — automated tests replace manual QA for unit-level logic
- onboard faster — tests serve as living documentation of how your code works
- Debug faster — a failing test tells you exactly what broke and where
The key principles to remember:
- Follow AAA (Arrange-Act-Assert) for every test
- Mock only at I/O boundaries — keep business logic real
- Test behavior, not implementation — your tests should survive refactoring
- Cover edge cases — the happy path is the easy part
Whether you're testing a pure utility function, a React component, or an Angular service, the patterns are the same. Master them once, apply them everywhere.
💡 tip[!TIP] Start every feature by writing the test first (Test-Driven Development). Even if you don't follow strict TDD, writing the test first forces you to think about the API, edge cases, and expected behavior before you write a single line of implementation code.
🧠 Test Your Knowledge
Now that you've learned the concepts, let's see if you can apply them! Take this quick quiz to test your understanding.