Testing JavaScript

โ–ถ Try It Yourself

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
Note: Each test should have exactly one logical assertion about one specific behaviour. A test named “processes order correctly” that checks 15 different things is not one test โ€” it is 15 tests jammed together. When it fails, you cannot tell which of the 15 things broke. Small, focused tests with descriptive names make failures immediately obvious and easy to fix.
Tip: Follow the AAA pattern for every test: Arrange (set up data and dependencies), Act (call the function under test), Assert (verify the result). This structure makes tests readable at a glance. If the Arrange section is very long, consider extracting it into beforeEach. If the Assert section checks many things, split into multiple tests.
Warning: Tests that test implementation details rather than behaviour are brittle โ€” they break when you refactor without changing behaviour. Test what the function does, not how it does it. A good test answers: “given these inputs, does the function produce the correct output?” not “does the function call these specific internal methods in this order?”

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');
});

▶ Try It Yourself

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 }) => {})

🧠 Test Yourself

In a test for an async function that should reject, which of these correctly asserts the rejection?





โ–ถ Try It Yourself