Testing is essential for building reliable software. This guide covers testing fundamentals, popular frameworks, and best practices for JavaScript testing.
Types of Tests #
Unit Tests #
Test individual functions or components in isolation.
// sum.js
function sum(a, b) {
return a + b;
}
// sum.test.js
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});Integration Tests #
Test how multiple units work together.
// userService.js
class UserService {
constructor(database) {
this.db = database;
}
async createUser(name, email) {
const user = { name, email };
await this.db.insert('users', user);
return user;
}
}
// userService.test.js
test('creates user in database', async () => {
const mockDb = {
insert: jest.fn()
};
const service = new UserService(mockDb);
const user = await service.createUser('Alice', 'alice@example.com');
expect(mockDb.insert).toHaveBeenCalledWith('users', user);
});End-to-End Tests #
Test complete user workflows.
// e2e/login.test.js
test('user can login', async () => {
await page.goto('http://localhost:3000/login');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'password');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('http://localhost:3000/dashboard');
});Jest Basics #
Setup #
npm install --save-dev jest// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}Basic Test Structure #
describe('Calculator', () => {
test('adds numbers', () => {
expect(sum(1, 2)).toBe(3);
});
test('subtracts numbers', () => {
expect(subtract(5, 2)).toBe(3);
});
test('multiplies numbers', () => {
expect(multiply(2, 3)).toBe(6);
});
});Matchers #
// Equality
expect(value).toBe(4);
expect(value).toEqual({ a: 1, b: 2 });
expect(value).not.toBe(null);
// 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.5);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(4.5);
expect(value).toBeCloseTo(0.3); // Floating point
// Strings
expect('team').not.toMatch(/I/);
expect('Christoph').toMatch(/stop/);
// Arrays
expect(['apple', 'banana']).toContain('apple');
expect([1, 2, 3]).toHaveLength(3);
// Objects
expect({ a: 1, b: 2 }).toHaveProperty('a');
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 });
// Exceptions
expect(() => {
throw new Error('Error');
}).toThrow();
expect(() => {
throw new Error('Network error');
}).toThrow('Network error');Async Testing #
Promises #
test('fetches user data', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('Alice');
});
});
// Or use async/await
test('fetches user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});Async Matchers #
test('resolves to user', async () => {
await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'Alice' });
});
test('rejects with error', async () => {
await expect(fetchUser(-1)).rejects.toThrow('Invalid user ID');
});Callbacks #
test('callback is called', done => {
function callback(data) {
expect(data).toBe('success');
done();
}
fetchData(callback);
});Mocking #
Mock Functions #
const mockFn = jest.fn();
// Call mock
mockFn('arg1', 'arg2');
mockFn('arg3');
// Assertions
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('arg3');
// Return values
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);
mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2);
expect(mockFn()).toBe(1);
expect(mockFn()).toBe(2);
// Implementation
mockFn.mockImplementation((a, b) => a + b);
expect(mockFn(1, 2)).toBe(3);Mock Modules #
// api.js
export async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// user.test.js
jest.mock('./api');
import { fetchUser } from './api';
test('gets user', async () => {
fetchUser.mockResolvedValue({ id: 1, name: 'Alice' });
const user = await getUser(1);
expect(user.name).toBe('Alice');
});Partial Mocks #
import * as api from './api';
jest.spyOn(api, 'fetchUser').mockResolvedValue({ id: 1, name: 'Alice' });
// Only fetchUser is mocked, other functions work normally
Mock Timers #
jest.useFakeTimers();
test('delays execution', () => {
const callback = jest.fn();
setTimeout(callback, 1000);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
});
test('runs all timers', () => {
const callback = jest.fn();
setTimeout(callback, 1000);
setInterval(callback, 500);
jest.runAllTimers();
expect(callback).toHaveBeenCalled();
});Setup and Teardown #
Before/After Hooks #
beforeAll(() => {
// Runs once before all tests
return initializeDatabase();
});
beforeEach(() => {
// Runs before each test
clearDatabase();
});
afterEach(() => {
// Runs after each test
cleanupResources();
});
afterAll(() => {
// Runs once after all tests
closeDatabase();
});Scoped Hooks #
describe('User operations', () => {
beforeEach(() => {
// Runs before each test in this describe block
});
test('creates user', () => {
// ...
});
describe('nested', () => {
beforeEach(() => {
// Runs after parent beforeEach
});
test('updates user', () => {
// ...
});
});
});Testing Patterns #
AAA Pattern #
Arrange, Act, Assert.
test('user service creates user', async () => {
// Arrange
const mockDb = { insert: jest.fn() };
const service = new UserService(mockDb);
// Act
const user = await service.createUser('Alice', 'alice@example.com');
// Assert
expect(user).toEqual({ name: 'Alice', email: 'alice@example.com' });
expect(mockDb.insert).toHaveBeenCalledWith('users', user);
});Test Data Builders #
class UserBuilder {
constructor() {
this.user = {
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user'
};
}
withId(id) {
this.user.id = id;
return this;
}
withName(name) {
this.user.name = name;
return this;
}
asAdmin() {
this.user.role = 'admin';
return this;
}
build() {
return this.user;
}
}
test('admin user has permissions', () => {
const admin = new UserBuilder()
.withName('Admin')
.asAdmin()
.build();
expect(hasPermission(admin, 'delete')).toBe(true);
});Test Fixtures #
// fixtures/users.js
export const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
// test file
import { users } from './fixtures/users';
test('finds user by id', () => {
const user = findUserById(users, 1);
expect(user.name).toBe('Alice');
});Testing React Components #
React Testing Library #
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
test('increments counter', () => {
render(<Counter />);
const button = screen.getByText('Increment');
const count = screen.getByText('Count: 0');
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});Testing User Events #
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('submits form', async () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText('Email'), 'user@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'password');
await userEvent.click(screen.getByText('Login'));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password'
});
});Testing Async Components #
import { render, screen, waitFor } from '@testing-library/react';
test('loads and displays user', async () => {
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});Snapshot Testing #
import { render } from '@testing-library/react';
test('matches snapshot', () => {
const { container } = render(<Button>Click me</Button>);
expect(container.firstChild).toMatchSnapshot();
});
// Update snapshots with: jest --updateSnapshot
Coverage #
Configuration #
// jest.config.js
module.exports = {
collectCoverage: true,
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
coveragePathIgnorePatterns: [
'/node_modules/',
'/tests/'
]
};Running Coverage #
npm test -- --coverageTest Organization #
File Structure #
src/
components/
Button.js
Button.test.js
utils/
validation.js
validation.test.js
tests/
integration/
api.test.js
e2e/
user-flow.test.jsNaming Conventions #
// Describe what is being tested
describe('UserService', () => {
// Use "should" or describe expected behavior
test('should create user with valid data', () => {
// ...
});
test('should throw error with invalid email', () => {
// ...
});
});Testing Best Practices #
Keep Tests Simple #
// Bad - Testing multiple things
test('user operations', async () => {
const user = await createUser({ name: 'Alice' });
expect(user.name).toBe('Alice');
const updated = await updateUser(user.id, { name: 'Bob' });
expect(updated.name).toBe('Bob');
await deleteUser(user.id);
const deleted = await findUser(user.id);
expect(deleted).toBeNull();
});
// Good - One test per behavior
test('creates user with name', async () => {
const user = await createUser({ name: 'Alice' });
expect(user.name).toBe('Alice');
});
test('updates user name', async () => {
const user = await createUser({ name: 'Alice' });
const updated = await updateUser(user.id, { name: 'Bob' });
expect(updated.name).toBe('Bob');
});Don’t Test Implementation Details #
// Bad - Testing internal state
test('counter increments state', () => {
const counter = new Counter();
counter.increment();
expect(counter._value).toBe(1); // Private implementation
});
// Good - Testing public interface
test('counter increments value', () => {
const counter = new Counter();
counter.increment();
expect(counter.getValue()).toBe(1);
});Use Descriptive Test Names #
// Bad
test('test1', () => { });
// Good
test('returns empty array when no users exist', () => { });
test('throws error when email is invalid', () => { });
test('updates user profile successfully', () => { });Test-Driven Development (TDD) #
Red-Green-Refactor #
// 1. Red - Write failing test
test('validates email format', () => {
expect(isValidEmail('invalid')).toBe(false);
expect(isValidEmail('test@example.com')).toBe(true);
});
// 2. Green - Write minimal code to pass
function isValidEmail(email) {
return email.includes('@');
}
// 3. Refactor - Improve implementation
function isValidEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}Common Testing Pitfalls #
- Testing trivial code - Don’t test getters/setters
- Flaky tests - Tests that pass/fail randomly
- Slow tests - Avoid unnecessary setup
- Too many mocks - Over-mocking hides issues
- Brittle tests - Tests break with minor changes
- No edge cases - Test boundary conditions
- Ignoring failures - Don’t skip broken tests
Best Practices Summary #
- Write tests first - TDD approach
- Test behavior, not implementation - Focus on what, not how
- Keep tests independent - No shared state
- Use descriptive names - Tests are documentation
- Test edge cases - Null, empty, negative values
- Mock external dependencies - Database, API calls
- Run tests frequently - Continuous integration
- Maintain test code - Refactor tests like production code
- Measure coverage - Aim for high coverage
- Fast feedback - Keep tests fast
Testing is crucial for software quality. Write tests, run them often, and use them to drive design decisions.