Testing is not optional overhead — it is a core engineering practice that pays dividends at every stage of the software lifecycle. Without tests, every deployment is a gamble.
Teams with comprehensive test suites deploy more frequently, recover faster from failures, and spend less time debugging production issues.
The testing pyramid is a strategy for balancing speed, cost, and confidence. More tests at the base, fewer at the top.
Test individual functions/classes in isolation. Run in milliseconds. Easiest to write and maintain. Mock external dependencies.
Test modules working together — API routes, database queries, middleware chains. Slower but catch wiring bugs unit tests miss.
Test the full application from a user's perspective — real browser, real network. Slowest and most brittle, but highest confidence.
// math.js
function add(a, b) { return a + b; }
function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
module.exports = { add, divide };
// math.test.js
const { add, divide } = require('./math');
describe('Math utilities', () => {
describe('add', () => {
it('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(add(-1, -2)).toBe(-3);
});
});
describe('divide', () => {
it('divides correctly', () => {
expect(divide(10, 2)).toBe(5);
});
it('throws on division by zero', () => {
expect(() => divide(10, 0))
.toThrow('Division by zero');
});
});
});
| Matcher | Use Case |
|---|---|
toBe(val) | Strict equality (===) |
toEqual(obj) | Deep equality (objects/arrays) |
toBeTruthy() | Truthy check |
toContain(item) | Array/string includes |
toHaveLength(n) | Array/string length |
toThrow(msg) | Error thrown |
toMatch(/regex/) | String regex match |
toHaveProperty(k) | Object has key |
toBeNull() | Strict null check |
toBeGreaterThan(n) | Numeric comparison |
beforeAll(() => { /* once before all tests */ });
afterAll(() => { /* once after all tests */ });
beforeEach(() => { /* before each test */ });
afterEach(() => { /* after each test */ });
// userService.js
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('User not found');
return res.json();
}
// userService.test.js
test('fetches user by ID', async () => {
const user = await fetchUser(1);
expect(user).toHaveProperty('name');
expect(user.id).toBe(1);
});
test('throws for missing user', async () => {
await expect(fetchUser(99999))
.rejects.toThrow('User not found');
});
test('resolves user data', () => {
return fetchUser(1).then(user => {
expect(user.name).toBeDefined();
});
});
test('calls back with data', (done) => {
fetchUserCallback(1, (err, user) => {
try {
expect(err).toBeNull();
expect(user.name).toBeDefined();
done();
} catch (e) {
done(e);
}
});
});
jest.useFakeTimers();
test('debounce waits before calling', () => {
const fn = jest.fn();
const debounced = debounce(fn, 500);
debounced();
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(500);
expect(fn).toHaveBeenCalledTimes(1);
});
afterEach(() => jest.useRealTimers());
Forgetting to return or await a promise in a test — Jest won't wait for it and the test passes even if the assertion would fail.
const callback = jest.fn();
callback('hello');
expect(callback).toHaveBeenCalledWith('hello');
expect(callback).toHaveBeenCalledTimes(1);
// Return values
const getPrice = jest.fn()
.mockReturnValueOnce(9.99)
.mockReturnValueOnce(19.99);
expect(getPrice()).toBe(9.99);
expect(getPrice()).toBe(19.99);
const spy = jest.spyOn(console, 'log');
myFunction();
expect(spy).toHaveBeenCalledWith('Starting...');
spy.mockRestore(); // clean up
// Mock entire module
jest.mock('./database');
const db = require('./database');
db.findUser.mockResolvedValue({
id: 1, name: 'Alice'
});
test('returns user from DB', async () => {
const user = await getUser(1);
expect(user.name).toBe('Alice');
expect(db.findUser).toHaveBeenCalledWith(1);
});
// __mocks__/axios.js
module.exports = {
get: jest.fn(() =>
Promise.resolve({ data: {} })
),
post: jest.fn(() =>
Promise.resolve({ data: {} })
),
};
Design functions to accept dependencies as parameters — makes mocking trivial without jest.mock().
// Easy to test: inject the DB
function getUser(db, id) {
return db.findUser(id);
}
Integration tests verify that multiple modules work together correctly. They test the wiring between components — database access, middleware chains, and service interactions.
const { Pool } = require('pg');
const { createUser, getUser } = require('./users');
let pool;
beforeAll(async () => {
pool = new Pool({
connectionString: process.env.TEST_DB_URL
});
await pool.query('BEGIN');
});
afterAll(async () => {
await pool.query('ROLLBACK');
await pool.end();
});
test('creates and retrieves a user', async () => {
const created = await createUser(pool, {
name: 'Alice', email: 'alice@test.com'
});
const found = await getUser(pool, created.id);
expect(found.name).toBe('Alice');
expect(found.email).toBe('alice@test.com');
});
| Unit | Integration |
|---|---|
| Single function | Multiple modules |
| Mocked deps | Real deps |
| Milliseconds | Seconds |
| Precise errors | Wiring errors |
const request = require('supertest');
const app = require('./app');
describe('GET /api/products', () => {
test('returns 200 and JSON array', async () => {
const res = await request(app)
.get('/api/products')
.expect('Content-Type', /json/)
.expect(200);
expect(res.body).toBeInstanceOf(Array);
expect(res.body.length).toBeGreaterThan(0);
});
test('filters by category', async () => {
const res = await request(app)
.get('/api/products?category=electronics')
.expect(200);
res.body.forEach(product => {
expect(product.category).toBe('electronics');
});
});
});
describe('POST /api/products', () => {
const token = 'Bearer test-jwt-token';
test('creates product with valid auth', async () => {
const res = await request(app)
.post('/api/products')
.set('Authorization', token)
.send({
name: 'Widget',
price: 29.99,
category: 'electronics'
})
.expect(201);
expect(res.body.id).toBeDefined();
expect(res.body.name).toBe('Widget');
});
test('returns 401 without auth', async () => {
await request(app)
.post('/api/products')
.send({ name: 'Widget' })
.expect(401);
});
test('returns 400 for invalid body', async () => {
await request(app)
.post('/api/products')
.set('Authorization', token)
.send({ name: '' }) // validation fails
.expect(400);
});
});
Export your Express app without calling .listen(). Supertest binds to an ephemeral port internally. This avoids port conflicts when running tests in parallel.
import { render, screen } from
'@testing-library/react';
import UserProfile from './UserProfile';
test('renders user name and email', () => {
render(<UserProfile
name="Alice"
email="alice@example.com"
/>);
expect(screen.getByText('Alice'))
.toBeInTheDocument();
expect(screen.getByText('alice@example.com'))
.toBeInTheDocument();
});
test('shows loading state', () => {
render(<UserProfile loading={true} />);
expect(screen.getByRole('progressbar'))
.toBeInTheDocument();
expect(screen.queryByText('Alice'))
.not.toBeInTheDocument();
});
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('submits login form', async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(
screen.getByLabelText('Email'),
'alice@test.com'
);
await user.type(
screen.getByLabelText('Password'),
'secret123'
);
await user.click(
screen.getByRole('button', { name: /log in/i })
);
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@test.com',
password: 'secret123',
});
});
getByRole > getByLabelText > getByText > getByTestId. Prefer accessible queries — they test what users see.
Use findBy* for elements that appear after async operations. Uses waitFor under the hood with a default timeout.
"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds
// tests/login.spec.js
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email')
.fill('alice@test.com');
await page.getByLabel('Password')
.fill('secret123');
await page.getByRole('button', { name: 'Log in' })
.click();
// Wait for navigation
await expect(page).toHaveURL('/dashboard');
await expect(
page.getByText('Welcome, Alice')
).toBeVisible();
});
test('shows error for bad credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('bad@test.com');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Log in' })
.click();
await expect(
page.getByText('Invalid credentials')
).toBeVisible();
});
// fixtures.js
import { test as base } from '@playwright/test';
class LoginPage {
constructor(page) { this.page = page; }
async login(email, password) {
await this.page.goto('/login');
await this.page.getByLabel('Email').fill(email);
await this.page.getByLabel('Password')
.fill(password);
await this.page.getByRole('button',
{ name: 'Log in' }).click();
}
}
export const test = base.extend({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
// Usage in tests
test('dashboard loads', async ({ loginPage, page }) => {
await loginPage.login('alice@test.com', 'secret');
await expect(page).toHaveURL('/dashboard');
});
// cypress/e2e/products.cy.js
describe('Product page', () => {
beforeEach(() => {
cy.visit('/products');
});
it('displays product list', () => {
cy.get('[data-testid="product-card"]')
.should('have.length.greaterThan', 0);
});
it('filters by search', () => {
cy.get('[data-testid="search-input"]')
.type('laptop');
cy.get('[data-testid="product-card"]')
.each(($card) => {
cy.wrap($card)
.should('contain.text', 'laptop');
});
});
it('adds item to cart', () => {
cy.get('[data-testid="add-to-cart"]')
.first().click();
cy.get('[data-testid="cart-count"]')
.should('have.text', '1');
});
});
it('handles API failure gracefully', () => {
cy.intercept('GET', '/api/products', {
statusCode: 500,
body: { error: 'Server error' }
}).as('getProducts');
cy.visit('/products');
cy.wait('@getProducts');
cy.get('[data-testid="error-message"]')
.should('contain', 'Something went wrong');
});
// cypress/support/commands.js
Cypress.Commands.add('login', (email, pw) => {
cy.session([email, pw], () => {
cy.request('POST', '/api/auth/login', {
email, password: pw
}).then(({ body }) => {
window.localStorage.setItem(
'token', body.token
);
});
});
});
// Usage
beforeEach(() => cy.login('alice@test.com', 'pw'));
| Feature | Playwright | Cypress |
|---|---|---|
| Browsers | All three | Chromium-focused |
| Language | JS/TS/Python/C# | JS/TS only |
| Tabs/windows | Yes | Limited |
| Dev UX | Good | Excellent |
TDD is a development workflow where you write a failing test first, then write the minimum code to pass, then refactor. This cycle is called Red-Green-Refactor.
// 1. RED: write the test first
test('validates email format', () => {
expect(isValidEmail('bad')).toBe(false);
expect(isValidEmail('a@b.com')).toBe(true);
});
// 2. GREEN: simplest implementation
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// 3. REFACTOR: improve without breaking tests
| Metric | Meaning |
|---|---|
| Statement | % of statements executed |
| Branch | % of if/else paths taken |
| Function | % of functions called |
| Line | % of lines executed |
# Jest (built-in Istanbul)
npx jest --coverage
# Vitest
npx vitest --coverage
# c8 (native V8 coverage)
npx c8 node my-script.js
npx c8 report --reporter=html
------------|---------|----------|---------|
File | % Stmts | % Branch | % Funcs |
------------|---------|----------|---------|
math.js | 100 | 85.71 | 100 |
utils.js | 92.3 | 75.00 | 100 |
auth.js | 78.5 | 60.00 | 83.3 |
------------|---------|----------|---------|
All files | 89.2 | 72.41 | 94.1 |
------------|---------|----------|---------|
// jest.config.js
module.exports = {
coverageThreshold: {
global: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
'./src/critical/': {
statements: 95,
branches: 90,
},
},
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/**/*.test.js',
'!src/index.js',
],
};
import { render } from '@testing-library/react';
import UserCard from './UserCard';
test('renders correctly', () => {
const { container } = render(
<UserCard name="Alice" role="admin" />
);
// First run: creates .snap file
// Next runs: compares against snapshot
expect(container).toMatchSnapshot();
});
// To update snapshots after intentional changes:
// npx jest --updateSnapshot
// or press 'u' in watch mode
test('formats currency', () => {
expect(formatCurrency(1299))
.toMatchInlineSnapshot(`"$12.99"`);
expect(formatCurrency(0))
.toMatchInlineSnapshot(`"$0.00"`);
});
// Jest writes the expected value into the source
| Good For | Bad For |
|---|---|
| Serialisable output (JSON, HTML) | Frequently changing UI |
| API response shapes | Large DOM trees |
| Config objects | Replacing all other assertions |
| Error messages | Dynamic data (dates, IDs) |
test('user object shape', () => {
expect(createUser('Alice')).toMatchSnapshot({
id: expect.any(String),
createdAt: expect.any(Date),
// name: 'Alice' checked via snapshot
});
});
// load-test.js (run with: k6 run load-test.js)
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 50 }, // ramp up
{ duration: '1m', target: 50 }, // sustain
{ duration: '10s', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% < 500ms
http_req_failed: ['rate<0.01'], // <1% errors
},
};
export default function () {
const res = http.get('http://localhost:3000/api');
check(res, {
'status 200': (r) => r.status === 200,
'body has data': (r) => r.json().data !== null,
});
sleep(1);
}
# artillery-config.yml
config:
target: "http://localhost:3000"
phases:
- duration: 60
arrivalRate: 20
name: "Sustained load"
scenarios:
- name: "Browse products"
flow:
- get:
url: "/api/products"
expect:
- statusCode: 200
- think: 2
- get:
url: "/api/products/1"
# Install and run
npm i -g @lhci/cli
lhci autorun --config=lighthouserc.json
# Assert performance budgets
# performance >= 90, accessibility >= 95
# .github/workflows/test.yml
name: Test Suite
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: test
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
- run: npx playwright install --with-deps
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: test-results/
# Split tests across shards
jobs:
test:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: npx jest --shard=${{ matrix.shard }}
e2e:
strategy:
matrix:
shard: [1/3, 2/3, 3/3]
steps:
- run: npx playwright test
--shard=${{ matrix.shard }}
node_modules and Playwright browserstest('calculates order total', () => {
// Arrange
const items = [
{ price: 10, qty: 2 },
{ price: 5, qty: 3 },
];
// Act
const total = calculateTotal(items);
// Assert
expect(total).toBe(35);
});
beforeEach to reset, not beforeAllafterEach// Bad
test('works', () => { ... });
// Good
test('returns 404 when user ID does not exist',
() => { ... });
// BAD: tests internal state
test('sets isLoading to true', () => {
const wrapper = shallow(<App />);
wrapper.instance().fetchData();
expect(wrapper.state('isLoading')).toBe(true);
});
// GOOD: tests observable behaviour
test('shows loading spinner', async () => {
render(<App />);
expect(screen.getByRole('progressbar'))
.toBeInTheDocument();
});
waitFor, avoid sleepif/switch).skipThank you! — Built with Reveal.js · Single self-contained HTML file