JavaScript Testing

45 min read Intermediate

Learn unit testing, integration testing, and test-driven development with modern JavaScript testing frameworks.

Why Test?

Testing ensures code works correctly and prevents regressions when making changes.

JavaScript
// Simple test without a framework
function add(a, b) {
    return a + b;
}

// Manual testing
function testAdd() {
    const result = add(2, 3);
    const expected = 5;
    
    if (result === expected) {
        console.log("✓ add(2, 3) = 5");
    } else {
        console.error(`✗ Expected ${expected}, got ${result}`);
    }
}

testAdd();

// Simple assertion function
function assert(condition, message) {
    if (!condition) {
        throw new Error(message || "Assertion failed");
    }
}

assert(add(2, 3) === 5, "2 + 3 should equal 5");
assert(add(-1, 1) === 0, "-1 + 1 should equal 0");
assert(add(0, 0) === 0, "0 + 0 should equal 0");
Types of Tests
  • Unit tests - Test individual functions/modules
  • Integration tests - Test components working together
  • End-to-end tests - Test complete user workflows

Jest Testing Framework

Jest is a popular testing framework with built-in assertions and mocking.

JavaScript
// math.js
export function add(a, b) {
    return a + b;
}

export function divide(a, b) {
    if (b === 0) throw new Error("Cannot divide by zero");
    return a / b;
}

// math.test.js
import { add, divide } from "./math";

// Test suite
describe("Math functions", () => {
    
    // Individual test
    test("adds two numbers", () => {
        expect(add(2, 3)).toBe(5);
        expect(add(-1, 1)).toBe(0);
        expect(add(0, 0)).toBe(0);
    });
    
    // Alternative syntax: it() instead of test()
    it("divides two numbers", () => {
        expect(divide(10, 2)).toBe(5);
        expect(divide(9, 3)).toBe(3);
    });
    
    // Testing errors
    it("throws on division by zero", () => {
        expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
    });
    
});


// ===== Common Matchers =====
test("matchers examples", () => {
    // Equality
    expect(2 + 2).toBe(4);           // Exact equality
    expect({ a: 1 }).toEqual({ a: 1 }); // Deep equality
    
    // Truthiness
    expect(null).toBeNull();
    expect(undefined).toBeUndefined();
    expect("value").toBeDefined();
    expect(true).toBeTruthy();
    expect(false).toBeFalsy();
    
    // Numbers
    expect(2 + 2).toBeGreaterThan(3);
    expect(2 + 2).toBeGreaterThanOrEqual(4);
    expect(2 + 2).toBeLessThan(5);
    expect(0.1 + 0.2).toBeCloseTo(0.3); // Floating point
    
    // Strings
    expect("hello world").toMatch(/world/);
    expect("hello world").toContain("world");
    
    // Arrays
    expect([1, 2, 3]).toContain(2);
    expect([1, 2, 3]).toHaveLength(3);
    
    // Objects
    expect({ a: 1, b: 2 }).toHaveProperty("a");
    expect({ a: 1, b: 2 }).toHaveProperty("a", 1);
    
    // Negation
    expect(1).not.toBe(2);
});

Testing Async Code

Test Promises, async/await, and callback functions.

JavaScript
// api.js
export async function fetchUser(id) {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error("User not found");
    return response.json();
}

export function fetchUserCallback(id, callback) {
    fetch(`/api/users/${id}`)
        .then(res => res.json())
        .then(data => callback(null, data))
        .catch(err => callback(err, null));
}


// api.test.js
import { fetchUser, fetchUserCallback } from "./api";

// Testing with async/await
test("fetches user data", async () => {
    const user = await fetchUser(1);
    expect(user).toHaveProperty("name");
    expect(user.id).toBe(1);
});

// Testing with Promises
test("fetches user with promises", () => {
    return fetchUser(1).then(user => {
        expect(user.id).toBe(1);
    });
});

// Testing rejections
test("throws for invalid user", async () => {
    await expect(fetchUser(999)).rejects.toThrow("User not found");
});

// Testing callbacks
test("fetches with callback", (done) => {
    fetchUserCallback(1, (error, user) => {
        expect(error).toBeNull();
        expect(user.id).toBe(1);
        done(); // Signal test completion
    });
});

// Testing with resolves/rejects matchers
test("resolves to user object", () => {
    return expect(fetchUser(1)).resolves.toHaveProperty("name");
});

test("rejects for missing user", () => {
    return expect(fetchUser(999)).rejects.toThrow();
});

Mocking

Replace dependencies with mock functions for isolated testing.

JavaScript
// ===== Mock Functions =====
test("mock function basics", () => {
    const mockFn = jest.fn();
    
    mockFn("hello");
    mockFn("world");
    
    // Check calls
    expect(mockFn).toHaveBeenCalled();
    expect(mockFn).toHaveBeenCalledTimes(2);
    expect(mockFn).toHaveBeenCalledWith("hello");
    expect(mockFn).toHaveBeenLastCalledWith("world");
    
    // Get call arguments
    expect(mockFn.mock.calls[0][0]).toBe("hello");
    expect(mockFn.mock.calls[1][0]).toBe("world");
});

// Mock with return values
test("mock return values", () => {
    const mockFn = jest.fn()
        .mockReturnValueOnce(10)
        .mockReturnValueOnce(20)
        .mockReturnValue(0);
    
    expect(mockFn()).toBe(10);
    expect(mockFn()).toBe(20);
    expect(mockFn()).toBe(0);
    expect(mockFn()).toBe(0);
});

// Mock implementation
test("mock implementation", () => {
    const mockFn = jest.fn((x) => x * 2);
    
    expect(mockFn(5)).toBe(10);
});


// ===== Mocking Modules =====
// Mock fetch globally
global.fetch = jest.fn();

beforeEach(() => {
    fetch.mockClear();
});

test("mocking fetch", async () => {
    fetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ id: 1, name: "John" })
    });
    
    const user = await fetchUser(1);
    
    expect(fetch).toHaveBeenCalledWith("/api/users/1");
    expect(user.name).toBe("John");
});

test("mocking fetch error", async () => {
    fetch.mockResolvedValueOnce({
        ok: false
    });
    
    await expect(fetchUser(999)).rejects.toThrow("User not found");
});


// ===== Spying =====
const calculator = {
    add: (a, b) => a + b,
    multiply: (a, b) => a * b
};

test("spying on methods", () => {
    const spy = jest.spyOn(calculator, "add");
    
    calculator.add(2, 3);
    
    expect(spy).toHaveBeenCalledWith(2, 3);
    expect(spy).toHaveReturnedWith(5);
    
    spy.mockRestore(); // Restore original
});

Setup & Teardown

Run code before and after tests for proper setup and cleanup.

JavaScript
describe("Database tests", () => {
    let db;
    
    // Run once before all tests in this describe
    beforeAll(async () => {
        db = await connectToDatabase();
    });
    
    // Run once after all tests
    afterAll(async () => {
        await db.disconnect();
    });
    
    // Run before each test
    beforeEach(async () => {
        await db.clear();
        await db.seed();
    });
    
    // Run after each test
    afterEach(() => {
        jest.clearAllMocks();
    });
    
    test("finds users", async () => {
        const users = await db.find("users");
        expect(users.length).toBeGreaterThan(0);
    });
    
    test("creates user", async () => {
        await db.insert("users", { name: "Test" });
        const users = await db.find("users");
        expect(users).toContainEqual(
            expect.objectContaining({ name: "Test" })
        );
    });
});


// Scoping - setup/teardown only applies to describe block
describe("outer", () => {
    beforeEach(() => console.log("outer beforeEach"));
    
    test("outer test", () => {});
    
    describe("inner", () => {
        beforeEach(() => console.log("inner beforeEach"));
        
        test("inner test", () => {});
        // Logs: "outer beforeEach" then "inner beforeEach"
    });
});

Test-Driven Development

Write tests before implementation to drive design.

JavaScript
// TDD Cycle: Red -> Green -> Refactor

// 1. RED: Write a failing test first
describe("ShoppingCart", () => {
    test("starts empty", () => {
        const cart = new ShoppingCart();
        expect(cart.items).toEqual([]);
        expect(cart.total).toBe(0);
    });
    
    test("adds items", () => {
        const cart = new ShoppingCart();
        cart.addItem({ name: "Apple", price: 1.50 });
        
        expect(cart.items).toHaveLength(1);
        expect(cart.items[0].name).toBe("Apple");
    });
    
    test("calculates total", () => {
        const cart = new ShoppingCart();
        cart.addItem({ name: "Apple", price: 1.50 });
        cart.addItem({ name: "Banana", price: 0.75 });
        
        expect(cart.total).toBe(2.25);
    });
    
    test("removes items", () => {
        const cart = new ShoppingCart();
        cart.addItem({ name: "Apple", price: 1.50, id: 1 });
        cart.removeItem(1);
        
        expect(cart.items).toHaveLength(0);
    });
});


// 2. GREEN: Write minimal code to pass
class ShoppingCart {
    constructor() {
        this.items = [];
    }
    
    get total() {
        return this.items.reduce((sum, item) => sum + item.price, 0);
    }
    
    addItem(item) {
        this.items.push(item);
    }
    
    removeItem(id) {
        this.items = this.items.filter(item => item.id !== id);
    }
}


// 3. REFACTOR: Improve code while keeping tests green
class ShoppingCart {
    #items = [];
    
    get items() {
        return [...this.#items]; // Return copy
    }
    
    get total() {
        return this.#items.reduce((sum, item) => sum + item.price, 0);
    }
    
    get itemCount() {
        return this.#items.length;
    }
    
    addItem(item) {
        if (!item.price || item.price < 0) {
            throw new Error("Invalid item price");
        }
        this.#items.push({ ...item, id: item.id || Date.now() });
    }
    
    removeItem(id) {
        const index = this.#items.findIndex(item => item.id === id);
        if (index > -1) {
            this.#items.splice(index, 1);
        }
    }
    
    clear() {
        this.#items = [];
    }
}

Advanced Mocking Strategies

Effective mocking isolates your code from external dependencies, making tests faster, more reliable, and focused on the unit under test.

Module Mocking

JavaScript - Jest Module Mocks
// Mock entire module
jest.mock('./api', () => ({
    fetchUsers: jest.fn(() => Promise.resolve([{ id: 1, name: 'Test' }])),
    createUser: jest.fn(() => Promise.resolve({ id: 2, name: 'New' }))
}));

// Mock with factory (hoisted to top)
jest.mock('./database', () => {
    return {
        query: jest.fn(),
        connect: jest.fn(() => Promise.resolve()),
        disconnect: jest.fn()
    };
});

// Partial mock (keep some real implementations)
jest.mock('./utils', () => {
    const actual = jest.requireActual('./utils');
    return {
        ...actual,
        sendEmail: jest.fn() // Mock only sendEmail
    };
});

// Mock third-party modules
jest.mock('axios');
import axios from 'axios';

axios.get.mockResolvedValue({ data: { users: [] } });
axios.post.mockRejectedValue(new Error('Network error'));

// Mock per-test with different values
beforeEach(() => {
    axios.get.mockClear(); // Clear mock call history
});

test('handles success', async () => {
    axios.get.mockResolvedValueOnce({ data: { id: 1 } });
    const result = await fetchUser(1);
    expect(result.id).toBe(1);
});

test('handles error', async () => {
    axios.get.mockRejectedValueOnce(new Error('Not found'));
    await expect(fetchUser(999)).rejects.toThrow('Not found');
});

Spy and Mock Functions

JavaScript - Spies and Custom Mocks
// Spy on existing method
const user = {
    getName: () => 'John',
    getAge: () => 30
};

const spy = jest.spyOn(user, 'getName');
user.getName();

expect(spy).toHaveBeenCalled();
expect(spy).toHaveReturnedWith('John');

// Replace implementation temporarily
spy.mockReturnValue('Jane');
expect(user.getName()).toBe('Jane');

// Restore original
spy.mockRestore();
expect(user.getName()).toBe('John');

// Mock implementation with custom logic
const mockFn = jest.fn()
    .mockImplementationOnce(() => 'first call')
    .mockImplementationOnce(() => 'second call')
    .mockImplementation(() => 'default');

console.log(mockFn()); // 'first call'
console.log(mockFn()); // 'second call'
console.log(mockFn()); // 'default'

// Capture arguments
const callback = jest.fn();
[1, 2, 3].forEach(callback);

expect(callback.mock.calls).toEqual([[1, 0, [1,2,3]], [2, 1, [1,2,3]], [3, 2, [1,2,3]]]);
expect(callback.mock.calls[0][0]).toBe(1);

// Mock timers
jest.useFakeTimers();

const callback = jest.fn();
setTimeout(callback, 1000);

expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);

jest.useRealTimers();

Integration Testing

Integration tests verify that multiple units work together correctly. They test the interactions between components, services, and external systems.

JavaScript - Integration Test Examples
// Test API endpoint with supertest
import request from 'supertest';
import app from '../src/app';
import { db } from '../src/database';

describe('Users API', () => {
    beforeAll(async () => {
        await db.connect();
    });
    
    afterAll(async () => {
        await db.disconnect();
    });
    
    beforeEach(async () => {
        await db.collection('users').deleteMany({});
    });
    
    test('POST /users creates a new user', async () => {
        const response = await request(app)
            .post('/users')
            .send({ name: 'John', email: 'john@example.com' })
            .expect('Content-Type', /json/)
            .expect(201);
        
        expect(response.body).toMatchObject({
            id: expect.any(String),
            name: 'John',
            email: 'john@example.com'
        });
        
        // Verify database
        const user = await db.collection('users').findOne({ email: 'john@example.com' });
        expect(user).toBeTruthy();
        expect(user.name).toBe('John');
    });
    
    test('GET /users returns all users', async () => {
        // Setup test data
        await db.collection('users').insertMany([
            { name: 'Alice', email: 'alice@example.com' },
            { name: 'Bob', email: 'bob@example.com' }
        ]);
        
        const response = await request(app)
            .get('/users')
            .expect(200);
        
        expect(response.body).toHaveLength(2);
        expect(response.body[0]).toHaveProperty('name');
    });
    
    test('handles validation errors', async () => {
        const response = await request(app)
            .post('/users')
            .send({ name: '' }) // Missing email
            .expect(400);
        
        expect(response.body.errors).toContain('Email is required');
    });
});

// Test component interactions
describe('UserDashboard Integration', () => {
    test('loads and displays user data', async () => {
        const { getByText, findByText } = render();
        
        // Loading state
        expect(getByText('Loading...')).toBeInTheDocument();
        
        // Wait for data
        await findByText('John Doe');
        
        // Verify interactions work
        fireEvent.click(getByText('Edit Profile'));
        await findByText('Edit Profile');
    });
});
Testing Best Practices
  • Test behavior, not implementation: Focus on what code does, not how
  • Keep tests independent: Each test should run in isolation
  • Use descriptive names: Test names should explain expected behavior
  • Follow AAA pattern: Arrange, Act, Assert for clear structure
  • Test edge cases: Empty arrays, null values, boundaries

Summary

Test Structure

describe, test/it, expect

Matchers

toBe, toEqual, toThrow, etc.

Async Testing

async/await, resolves/rejects

Mocking

jest.fn(), jest.spyOn()

Setup/Teardown

beforeEach, afterEach, beforeAll

TDD

Red → Green → Refactor