Testing

Write tests that catch bugs before users do. Confidence in every deploy.

Why Test

Tests are proof that your code works — now and after every change.

// Without tests:
// "I changed the login function... did I break anything?"
// "This worked last week... what changed?"
// "Ship it and pray" 🙏

// With tests:
// ✓ Change code → run tests → instant feedback
// ✓ Refactor fearlessly — tests catch regressions
// ✓ Document behavior — tests show how code should work
// ✓ Deploy with confidence

// Testing tools landscape:
// Test runners: Vitest (fast, ESM), Jest (mature, widespread)
// Assertions: built-in expect() in both
// DOM testing: Testing Library (@testing-library/*)
// E2E: Playwright, Cypress
// Coverage: c8, istanbul

// This chapter focuses on Vitest (same API as Jest, but faster):
// npm install -D vitest

Unit Tests

Test one function, one behavior, in isolation.

// The function to test:
function add(a, b) {
  return a + b;
}

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

// The test file (add.test.js):
import { describe, it, expect } from "vitest";

describe("add", () => {
  it("adds two positive numbers", () => {
    expect(add(2, 3)).toBe(5);
  });

  it("handles negative numbers", () => {
    expect(add(-1, -2)).toBe(-3);
  });

  it("handles zero", () => {
    expect(add(0, 5)).toBe(5);
  });
});

Tests follow AAA: Arrange (setup), Act (call function), Assert (check result). Each test checks ONE behavior.

1 / 2

Mocking

Replace dependencies with controlled fakes.

import { vi, describe, it, expect } from "vitest";

// Mock a function:
const mockFetch = vi.fn();

// Control what mock returns:
mockFetch.mockResolvedValue({ ok: true, json: () => ({ id: 1 }) });

// Use in test:
describe("getUser", () => {
  it("fetches user by id", async () => {
    global.fetch = mockFetch;

    const user = await getUser(1);

    // Assert it was called correctly:
    expect(mockFetch).toHaveBeenCalledWith("/api/users/1");
    expect(mockFetch).toHaveBeenCalledTimes(1);
    expect(user).toEqual({ id: 1 });
  });
});

// Mock a module:
vi.mock("./database", () => ({
  query: vi.fn().mockResolvedValue([{ id: 1, name: "Alice" }]),
}));

// Spy on existing methods (without replacing):
const spy = vi.spyOn(console, "log");
doSomething();
expect(spy).toHaveBeenCalledWith("expected output");
spy.mockRestore(); // clean up

// Timer mocks:
vi.useFakeTimers();
setTimeout(callback, 1000);
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
vi.useRealTimers();

Async Testing

Test Promises, async/await, and callbacks.

// Async functions — just use async/await in tests:
describe("fetchPosts", () => {
  it("returns posts for a user", async () => {
    const posts = await fetchPosts(1);
    expect(posts).toHaveLength(3);
    expect(posts[0]).toHaveProperty("title");
  });

  it("throws on invalid user", async () => {
    await expect(fetchPosts(-1)).rejects.toThrow("User not found");
  });
});

// Testing Promise resolution/rejection:
it("resolves with data", async () => {
  await expect(asyncFn()).resolves.toBe("success");
});

it("rejects with error", async () => {
  await expect(asyncFn()).rejects.toThrow();
});

// Testing event-based code:
it("emits after delay", async () => {
  const result = await new Promise((resolve) => {
    emitter.on("done", resolve);
    emitter.start();
  });
  expect(result).toBe("complete");
});

// Setup and teardown:
describe("database tests", () => {
  beforeEach(async () => {
    await db.seed(); // fresh data each test
  });

  afterEach(async () => {
    await db.cleanup();
  });

  it("inserts a record", async () => {
    await db.insert({ name: "test" });
    const records = await db.findAll();
    expect(records).toHaveLength(1);
  });
});

TDD

Test-Driven Development: Red → Green → Refactor.

// TDD Cycle:
// 1. RED — Write a failing test first
// 2. GREEN — Write minimum code to pass
// 3. REFACTOR — Improve code, tests still pass

// Example: Build a password validator

// Step 1 — RED: Write the test first
describe("validatePassword", () => {
  it("rejects passwords shorter than 8 chars", () => {
    expect(validatePassword("short")).toBe(false);
  });
});

// Run test → FAILS (function doesn't exist yet)
// ✗ ReferenceError: validatePassword is not defined

RED: Write a test that describes the behavior you want. It fails because the code doesn't exist yet.

1 / 2

FAQ

Common questions about testing.