Why Testing Matters
Testing is your safety net. It catches bugs before they reach users, enables confident refactoring, and serves as living documentation for your code.
Testing Benefits:
WITHOUT TESTS WITH TESTS
─────────────────────────────────────────────────────────
"Did I break something?" "All 150 tests pass!"
Manual testing after changes Automated verification
Fear of refactoring Confident improvements
Bugs found in production Bugs caught in development
"Works on my machine" Works everywhere
Testing Pyramid:
▲
/E\ E2E Tests (few, slow, expensive)
/───\ - Full user flows
/ Int \ Integration Tests (some)
/───────\ - Components working together
/ Unit \ Unit Tests (many, fast, cheap)
/───────────\ - Individual functions/components
───────────────
Types of Tests:
• Unit: Test one function/component in isolation
• Integration: Test components working together
• E2E: Test full user journeys (Cypress, Playwright)
Getting Started with Jest
# Install Jest
npm install --save-dev jest
# For TypeScript
npm install --save-dev jest @types/jest ts-jest
# package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
// Basic test structure
// math.js
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, multiply };
// math.test.js
const { add, multiply } = require('./math');
describe('Math functions', () => {
describe('add', () => {
test('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('adds negative numbers', () => {
expect(add(-1, -1)).toBe(-2);
});
test('adds zero', () => {
expect(add(5, 0)).toBe(5);
});
});
describe('multiply', () => {
test('multiplies two numbers', () => {
expect(multiply(3, 4)).toBe(12);
});
test('multiplies by zero', () => {
expect(multiply(5, 0)).toBe(0);
});
});
});
// Run tests
npm test
Jest Matchers
// Equality
expect(value).toBe(expected); // Exact equality (===)
expect(value).toEqual(expected); // Deep equality for objects
expect(value).toStrictEqual(expected); // Strict deep equality
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3);
expect(value).toBeLessThan(5);
expect(value).toBeCloseTo(0.3, 5); // For floating point
// Strings
expect(string).toMatch(/pattern/);
expect(string).toContain('substring');
// Arrays
expect(array).toContain(item);
expect(array).toHaveLength(3);
// Objects
expect(object).toHaveProperty('key');
expect(object).toHaveProperty('key', 'value');
expect(object).toMatchObject({ partial: 'match' });
// Exceptions
expect(() => dangerousCall()).toThrow();
expect(() => dangerousCall()).toThrow('error message');
expect(() => dangerousCall()).toThrow(ErrorType);
// Negation
expect(value).not.toBe(other);
// Async matchers
await expect(asyncFn()).resolves.toBe(value);
await expect(asyncFn()).rejects.toThrow();
// Example
describe('User validation', () => {
test('valid user object', () => {
const user = {
id: 1,
name: 'Alice',
email: 'alice@example.com',
roles: ['user', 'admin']
};
expect(user).toHaveProperty('id');
expect(user.name).toBe('Alice');
expect(user.email).toMatch(/@/);
expect(user.roles).toContain('admin');
expect(user.roles).toHaveLength(2);
});
});
Mocking
Mocking lets you isolate code by replacing dependencies with controlled substitutes.
// Mock functions
const mockFn = jest.fn();
mockFn('arg1', 'arg2');
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledTimes(1);
// Return values
const mock = jest.fn()
.mockReturnValue('default')
.mockReturnValueOnce('first call')
.mockReturnValueOnce('second call');
mock(); // 'first call'
mock(); // 'second call'
mock(); // 'default'
// Mock implementations
const mockCalculate = jest.fn((a, b) => a + b);
expect(mockCalculate(2, 3)).toBe(5);
// Mocking modules
// api.js
export const fetchUser = async (id) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
};
// user.test.js
import { fetchUser } from './api';
jest.mock('./api');
test('displays user name', async () => {
fetchUser.mockResolvedValue({ id: 1, name: 'Alice' });
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});
// Mocking with implementation
jest.mock('./api', () => ({
fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' })
}));
// Spying on methods
const user = {
getName: () => 'Alice'
};
const spy = jest.spyOn(user, 'getName');
user.getName();
expect(spy).toHaveBeenCalled();
spy.mockRestore(); // Restore original
// Mock timers
jest.useFakeTimers();
test('calls callback after delay', () => {
const callback = jest.fn();
setTimeout(callback, 1000);
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
});
jest.useRealTimers();
React Testing Library
RTL encourages testing components the way users interact with them.
# Install
npm install --save-dev @testing-library/react @testing-library/jest-dom
// setupTests.js
import '@testing-library/jest-dom';
// Button.jsx
function Button({ onClick, children, disabled }) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
// Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button', () => {
test('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click</Button>);
fireEvent.click(screen.getByText('Click'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByText('Submit')).toBeDisabled();
});
});
// Queries priority (best to worst):
// 1. getByRole - accessible to everyone
// 2. getByLabelText - form fields
// 3. getByPlaceholderText - input placeholders
// 4. getByText - non-interactive elements
// 5. getByDisplayValue - current input value
// 6. getByAltText - images
// 7. getByTitle - title attribute
// 8. getByTestId - last resort
// Examples
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter your name');
screen.getByText(/welcome/i);
screen.getByAltText('Profile picture');
screen.getByTestId('custom-element');
Testing Async Components
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// UserProfile.jsx
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div>Welcome, {user.name}</div>;
}
// UserProfile.test.jsx
jest.mock('./api');
test('shows loading then user name', async () => {
fetchUser.mockResolvedValue({ id: 1, name: 'Alice' });
render(<UserProfile userId={1} />);
// Initially shows loading
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for user to appear
await waitFor(() => {
expect(screen.getByText('Welcome, Alice')).toBeInTheDocument();
});
});
// Using findBy (combines getBy + waitFor)
test('displays user after fetch', async () => {
fetchUser.mockResolvedValue({ id: 1, name: 'Bob' });
render(<UserProfile userId={1} />);
// findBy waits for element to appear
expect(await screen.findByText('Welcome, Bob')).toBeInTheDocument();
});
// Testing user interactions
test('form submission', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(mockSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
Testing Hooks
import { renderHook, act } from '@testing-library/react';
// useCounter.js
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// useCounter.test.js
describe('useCounter', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Test Organization & Best Practices
// File structure
src/
├── components/
│ ├── Button/
│ │ ├── Button.jsx
│ │ ├── Button.test.jsx
│ │ └── Button.module.css
│ └── Form/
│ ├── Form.jsx
│ └── Form.test.jsx
├── hooks/
│ ├── useAuth.js
│ └── useAuth.test.js
└── utils/
├── helpers.js
└── helpers.test.js
// Test structure - Arrange, Act, Assert
test('user can submit form', async () => {
// Arrange - set up test data and render
const mockSubmit = jest.fn();
render(<ContactForm onSubmit={mockSubmit} />);
// Act - perform user actions
await userEvent.type(
screen.getByLabelText('Name'),
'John Doe'
);
await userEvent.click(
screen.getByRole('button', { name: /submit/i })
);
// Assert - verify expected outcomes
expect(mockSubmit).toHaveBeenCalledWith({
name: 'John Doe'
});
});
// Best Practices:
// 1. Test behavior, not implementation
// ✗ expect(component.state.isOpen).toBe(true);
// ✓ expect(screen.getByRole('dialog')).toBeVisible();
// 2. Use accessible queries
// ✗ screen.getByTestId('submit-btn');
// ✓ screen.getByRole('button', { name: /submit/i });
// 3. One assertion per test (when practical)
// 4. Use describe blocks for organization
describe('LoginForm', () => {
describe('validation', () => {
test('shows error for invalid email', () => {});
test('shows error for short password', () => {});
});
describe('submission', () => {
test('calls onSubmit with credentials', () => {});
test('shows loading state', () => {});
});
});
// 5. Use beforeEach for common setup
describe('Dashboard', () => {
beforeEach(() => {
jest.clearAllMocks();
fetchUser.mockResolvedValue({ id: 1, name: 'Test' });
});
test('...', () => {});
});
Code Coverage
# Run with coverage
npm test -- --coverage
# jest.config.js
module.exports = {
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/index.js'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
# Coverage report:
# ----------------------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# ----------------------|---------|----------|---------|---------|
# All files | 85.71 | 83.33 | 88.89 | 85.71 |
# Button.jsx | 100 | 100 | 100 | 100 |
# Form.jsx | 78.57 | 75 | 83.33 | 78.57 |
# ----------------------|---------|----------|---------|---------|
# Coverage types:
# Statements: executable statements executed
# Branches: if/else paths taken
# Functions: functions called
# Lines: lines of code executed
Write Better Tests
Our Full Stack JavaScript program covers testing in depth. Build reliable applications with expert guidance.
Explore JavaScript Program