Untested code is code you cannot confidently refactor, deploy, or hand off to another developer. Testing transforms your codebase from a fragile collection of assumptions into a verified, maintainable system. JavaScript has a rich testing ecosystem โ unit tests with Jest or Vitest, integration tests, DOM testing with Testing Library, and end-to-end tests with Playwright or Cypress. In this lesson you will master the vocabulary, structure, and philosophy of JavaScript testing: writing effective unit tests, structuring test suites, mocking dependencies, testing async code, and the principles that make tests valuable rather than burdensome.
Test Types and the Testing Pyramid
| Type | What It Tests | Speed | Cost | Tools |
|---|---|---|---|---|
| Unit | Single function or class in isolation | Fastest (<1ms) | Lowest | Jest, Vitest |
| Integration | Multiple units working together | Fast (~10ms) | Medium | Jest, Vitest + Testing Library |
| End-to-End (E2E) | Full user journey in a real browser | Slow (seconds) | Highest | Playwright, Cypress |
Jest / Vitest Key APIs
| API | Purpose |
|---|---|
describe('group', fn) |
Group related tests |
it('should ...', fn) / test() |
Define a single test case |
expect(value) |
Create an assertion |
beforeEach(fn) / afterEach(fn) |
Setup / teardown per test |
beforeAll(fn) / afterAll(fn) |
Setup / teardown per suite |
vi.fn() / jest.fn() |
Create a mock function |
vi.spyOn(obj, 'method') |
Spy on existing method |
vi.mock('./module') |
Mock an entire module |
it.todo('...') |
Placeholder for future tests |
Common Matchers
| Matcher | Asserts |
|---|---|
.toBe(val) |
Strict equality (===) |
.toEqual(val) |
Deep equality โ objects and arrays |
.toStrictEqual(val) |
Deep equality โ also checks type and undefined |
.toBeTruthy() / .toBeFalsy() |
Truthy or falsy value |
.toBeNull() / .toBeUndefined() |
Exactly null or undefined |
.toContain(item) |
Array contains item or string contains substring |
.toHaveLength(n) |
Array or string has length n |
.toThrow(error?) |
Function throws (optionally matching message) |
.toHaveBeenCalled() |
Mock was called at least once |
.toHaveBeenCalledWith(...args) |
Mock was called with specific arguments |
.resolves.toBe(val) |
Promise resolves with value |
.rejects.toThrow() |
Promise rejects |
beforeEach. If the Assert section checks many things, split into multiple tests.Basic Example
// โโ Function to test โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// cart.js
export function createCart(items = []) {
return {
items: [...items],
add(item) {
const existing = this.items.find(i => i.id === item.id);
if (existing) existing.qty = (existing.qty ?? 1) + 1;
else this.items.push({ ...item, qty: 1 });
return this;
},
remove(id) {
this.items = this.items.filter(i => i.id !== id);
return this;
},
getTotal() {
return this.items.reduce((sum, i) => sum + i.price * (i.qty ?? 1), 0);
},
isEmpty() { return this.items.length === 0; },
count() { return this.items.reduce((n, i) => n + (i.qty ?? 1), 0); },
};
}
// โโ Test suite โ cart.test.js โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import { describe, it, expect, beforeEach } from 'vitest';
import { createCart } from './cart.js';
describe('createCart', () => {
let cart;
beforeEach(() => {
cart = createCart(); // fresh cart for every test
});
describe('add()', () => {
it('adds a new item', () => {
// Arrange
const item = { id: 1, name: 'Widget', price: 9.99 };
// Act
cart.add(item);
// Assert
expect(cart.items).toHaveLength(1);
expect(cart.items[0]).toMatchObject({ id: 1, qty: 1 });
});
it('increments qty when adding existing item', () => {
const item = { id: 1, name: 'Widget', price: 9.99 };
cart.add(item).add(item);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].qty).toBe(2);
});
});
describe('remove()', () => {
it('removes an item by id', () => {
cart.add({ id: 1, name: 'A', price: 1 });
cart.add({ id: 2, name: 'B', price: 2 });
cart.remove(1);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].id).toBe(2);
});
it('does nothing when id not found', () => {
cart.add({ id: 1, name: 'A', price: 1 });
cart.remove(99);
expect(cart.items).toHaveLength(1);
});
});
describe('getTotal()', () => {
it('returns 0 for empty cart', () => {
expect(cart.getTotal()).toBe(0);
});
it('calculates total correctly', () => {
cart.add({ id: 1, name: 'A', price: 10 });
cart.add({ id: 2, name: 'B', price: 5 });
cart.add({ id: 1, name: 'A', price: 10 }); // qty = 2
expect(cart.getTotal()).toBe(25); // 10*2 + 5*1
});
});
it('isEmpty() returns true for new cart', () => {
expect(cart.isEmpty()).toBe(true);
});
});
// โโ Async test โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe('fetchUser', () => {
it('returns user data', async () => {
const user = await fetchUser(1);
expect(user).toMatchObject({ id: 1, name: expect.any(String) });
});
it('throws on invalid id', async () => {
await expect(fetchUser(0)).rejects.toThrow('ID required');
});
});
// โโ Mocking โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import { vi, describe, it, expect, afterEach } from 'vitest';
afterEach(() => vi.restoreAllMocks());
describe('sendEmail', () => {
it('calls the email service with correct args', async () => {
const mockSend = vi.fn().mockResolvedValue({ id: 'msg_123' });
vi.spyOn(emailService, 'send').mockImplementation(mockSend);
await sendEmail({ to: 'alice@example.com', subject: 'Hello' });
expect(mockSend).toHaveBeenCalledOnce();
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({ to: 'alice@example.com' })
);
});
it('throws if email service fails', async () => {
vi.spyOn(emailService, 'send').mockRejectedValue(new Error('SMTP error'));
await expect(
sendEmail({ to: 'alice@example.com', subject: 'Hello' })
).rejects.toThrow('SMTP error');
});
});
// โโ Mock entire module โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
vi.mock('./api.js', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
fetchPosts: vi.fn().mockResolvedValue([{ id: 10, title: 'Post 1' }]),
}));
// โโ Table-driven / parameterised tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe.each([
{ input: '', expected: 'Input is required' },
{ input: 'ab', expected: 'Too short' },
{ input: 'a'.repeat(201), expected: 'Too long' },
{ input: 'valid input', expected: null },
])('validateName($input)', ({ input, expected }) => {
it(`returns "${expected ?? 'null'}"`, () => {
expect(validateName(input)).toBe(expected);
});
});
How It Works
Step 1 โ beforeEach Ensures Test Isolation
Tests must not share state โ one test’s mutations should never affect another’s. beforeEach creates a fresh instance before every test in a describe block. This guarantees that each test starts from a known, clean state. Tests that share state fail intermittently depending on execution order โ the hardest category of bug to diagnose.
Step 2 โ Mocks Replace Real Dependencies
Unit tests should test one unit in isolation. vi.fn() creates a mock function that records every call, its arguments, and its return values. .mockResolvedValue(data) makes it return a resolved Promise. This lets you test how your code responds to a successful or failed API call without making real network requests โ making tests fast, deterministic, and free from external dependencies.
Step 3 โ expect.objectContaining for Flexible Assertions
expect(value).toMatchObject(shape) and expect.objectContaining(subset) check that an object contains at least the specified properties โ extra properties are allowed. This makes assertions resilient to changes in irrelevant fields. Use toBe for exact primitive equality and toEqual for exact deep equality only when you need the whole value.
Step 4 โ describe.each Eliminates Repetitive Tests
Table-driven tests (parameterised tests) run the same test logic with different inputs and expected outputs. Instead of writing 10 near-identical tests for boundary conditions, express them as a data table. Each row becomes a separate test case with its own name, making failures pinpoint which input case broke.
Step 5 โ Test Behaviour, Not Implementation
The public contract of a function is its inputs, outputs, and side effects. Tests should verify this contract. If your tests need to know which private methods were called or what internal data structure was used, they will break whenever you improve the implementation โ even when the behaviour is unchanged. Good tests survive refactoring.
Real-World Example: Testing an API Client
// api-client.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { APIClient } from './api-client.js';
describe('APIClient', () => {
let client;
beforeEach(() => {
client = new APIClient('https://api.example.com');
// Mock global fetch
global.fetch = vi.fn();
});
afterEach(() => vi.restoreAllMocks());
function mockResponse(data, status = 200) {
global.fetch.mockResolvedValue({
ok: status >= 200 && status < 300,
status,
headers: { get: () => 'application/json' },
json: vi.fn().mockResolvedValue(data),
});
}
describe('get()', () => {
it('returns parsed JSON on success', async () => {
mockResponse({ id: 1, name: 'Alice' });
const result = await client.get('/users/1');
expect(result).toEqual({ id: 1, name: 'Alice' });
});
it('throws on HTTP error', async () => {
mockResponse({ message: 'Not found' }, 404);
await expect(client.get('/users/999')).rejects.toThrow('Not found');
});
it('uses correct URL', async () => {
mockResponse({});
await client.get('/users');
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/users',
expect.objectContaining({ method: 'GET' })
);
});
});
describe('post()', () => {
it('sends JSON body with correct headers', async () => {
mockResponse({ id: 2 }, 201);
await client.post('/users', { name: 'Bob' });
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/users',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'Bob' }),
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
})
);
});
});
});
Common Mistakes
Mistake 1 โ Not resetting mocks between tests
โ Wrong โ mock call count carries over to next test:
const mockFn = vi.fn();
it('test 1', () => { mockFn(); expect(mockFn).toHaveBeenCalledOnce(); });
it('test 2', () => { mockFn(); expect(mockFn).toHaveBeenCalledOnce(); /* fails โ called twice total! */ });
โ Correct โ use afterEach to restore:
afterEach(() => vi.clearAllMocks());
Mistake 2 โ Testing implementation details instead of behaviour
โ Wrong โ test breaks if internal method is renamed:
it('calls _calculateDiscount internally', () => {
const spy = vi.spyOn(cart, '_calculateDiscount');
cart.getTotal();
expect(spy).toHaveBeenCalled(); // tests HOW, not WHAT
});
โ Correct โ test observable output:
it('applies discount to total', () => {
cart.applyDiscount(10); // 10%
cart.add({ price: 100 });
expect(cart.getTotal()).toBe(90); // tests the result
});
Mistake 3 โ Missing await on async assertions
โ Wrong โ test always passes because the Promise is never awaited:
it('throws on error', () => {
expect(fetchUser(0)).rejects.toThrow('ID required'); // missing await!
});
โ Correct โ always await async assertions:
it('throws on error', async () => {
await expect(fetchUser(0)).rejects.toThrow('ID required');
});
Quick Reference
| Task | Code |
|---|---|
| Group tests | describe('feature', () => { }) |
| Single test | it('should ...', () => { }) |
| Setup per test | beforeEach(() => { instance = new Thing(); }) |
| Assert equality | expect(a).toBe(b) / .toEqual(b) |
| Assert throws | expect(() => fn()).toThrow('message') |
| Assert async rejects | await expect(promise).rejects.toThrow() |
| Mock function | const fn = vi.fn().mockReturnValue(42) |
| Spy on method | vi.spyOn(obj, 'method').mockResolvedValue(data) |
| Parameterised | it.each(table)('label', ({ input, expected }) => {}) |