25 — Testing Architecture: Vitest, Mocking & TDD Patterns

Test Types and Their Boundaries

javascript
// ── Test pyramid (most tests at the bottom, fewest at the top) ──
//
//        E2E (few)     — full browser/network (Playwright, Cypress)
//       /          \
//   Integration (some) — multiple units together (API + DB, component + store)
//  /                \
// Unit (many)           — isolated function/class (mocked dependencies)
//
// ── Unit test: test one function in isolation (dependencies mocked) ──
// ── Integration test: test multiple units together (real dependencies) ──
// ── E2E test: test the full user flow (browser, server, database) ──

// ── Vitest test structure ──
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { calculateTotal, applyDiscount } from "./math.js";

describe("calculateTotal", () => {  // group related tests
    it("sums an array of prices", () => {  // individual test
        expect(calculateTotal([10, 20, 30])).toBe(60);
    });

    it("returns 0 for empty array", () => {
        expect(calculateTotal([])).toBe(0);
    });

    it("handles negative values", () => {
        expect(calculateTotal([-10, 20])).toBe(10);
    });
});

// ── Vitest assertions ──
expect(value).toBe(expected);        // strict equality (===)
expect(value).toEqual(expected);    // deep equality (object/array content)
expect(value).toBeTruthy();         // truthy check
expect(value).toBeFalsy();          // falsy check
expect(value).toBeNull();           // === null
expect(value).toBeUndefined();      // === undefined
expect(value).toBeNaN();            // Number.isNaN
expect(array).toContain(item);     // array.includes(item) or string.includes
expect(array).toHaveLength(3);     // array.length === 3
expect(fn).toThrow("error msg");   // function throws
expect(value).toMatch(/regex/);    // regex match
expect(object).toMatchObject({ key: value });  // partial object match

Mocking: vi.fn, vi.mock, and vi.spyOn

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

// ── vi.fn: create a mock function (track calls, return values) ──
const mockCallback = vi.fn();
mockCallback("arg1", "arg2");
expect(mockCallback).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenCalledWith("arg1", "arg2");
expect(mockCallback).toHaveBeenLastCalledWith("arg1", "arg2");

// Control the return value:
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => ({ data: "test" }) });
await mockFetch("/api/data");  // returns the mocked resolved value

// ── vi.mock: replace a module's exports with mocks ──
vi.mock("./api.js", () => ({
    fetchData: vi.fn().mockResolvedValue({ id: 1, name: "test" }),
    deleteUser: vi.fn().mockResolvedValue(true),
}));

import { fetchData, deleteUser } from "./api.js";
// fetchData is now a mock — returns { id: 1, name: "test" } without hitting the real API

// ── vi.spyOn: spy on an existing method (track calls, optionally override) ──
const obj = { method: (x) => x * 2 };
const spy = vi.spyOn(obj, "method");
obj.method(5);
expect(spy).toHaveBeenCalledWith(5);
expect(spy).toHaveReturnedWith(10);
// spy tracks calls but calls the REAL method (unless you mock the return):
spy.mockReturnValue(999);
obj.method(5);  // returns 999 (mocked, real method not called)

// ── beforeEach / afterEach: setup and teardown ──
beforeEach(() => {
    vi.clearAllMocks();  // reset call counts and return values
    // vi.resetAllMocks();  // also resets the implementation
});

afterEach(() => {
    vi.restoreAllMocks();  // restore spies to original implementations
});

Anti-Pattern: Testing Implementation Details

javascript
// ❌ NAIVE — testing implementation details (breaks on refactoring)
describe("UserService", () => {
    it("uses Map internally", () => {
        const service = new UserService();
        expect(service.users).toBeInstanceOf(Map);  // testing the internal data structure
        // If you refactor from Map to Array, this test breaks — even if behavior is correct.
    });

    it("calls validate exactly once", () => {
        const spy = vi.spyOn(service, "validate");
        service.create({ name: "Alice" });
        expect(spy).toHaveBeenCalledTimes(1);  // testing internal call count
        // If you add caching to validate, this test breaks — even if behavior is correct.
    });
});

// ✅ CORRECT — test behavior (public API, not internals)
describe("UserService", () => {
    it("creates a user and returns it with an ID", () => {
        const service = new UserService();
        const user = service.create({ name: "Alice" });
        expect(user).toEqual(expect.objectContaining({  // partial match
            id: expect.any(Number),
            name: "Alice",
        }));
    });

    it("throws on invalid input", () => {
        const service = new UserService();
        expect(() => service.create({ name: "" })).toThrow("invalid name");
        expect(() => service.create({})).toThrow("invalid name");
    });

    it("retrieves a created user by ID", () => {
        const service = new UserService();
        const created = service.create({ name: "Alice" });
        const found = service.getById(created.id);
        expect(found).toEqual(created);
    });
});

Production Pattern: Dependency Injection for Testability

javascript
// ── Inject dependencies (don't import them directly) for easy mocking ──

// ❌ Hard to test (direct import — can't mock without vi.mock)
class UserService {
    async getUser(id) {
        const response = await fetch(`/api/users/${id}`);  // direct fetch — hard to mock
        return response.json();
    }
}

// ✅ Testable (dependency injection — pass fetch as a constructor arg)
class TestableUserService {
    constructor(fetchImpl = fetch) {  // default to global fetch, but injectable
        this.fetch = fetchImpl;
    }
    async getUser(id) {
        const response = await this.fetch(`/api/users/${id}`);
        return response.json();
    }
}

// Test with a mock fetch:
test("getUser fetches from the API", async () => {
    const mockFetch = vi.fn().mockResolvedValue({
        json: () => Promise.resolve({ id: 1, name: "Alice" }),
    });
    const service = new TestableUserService(mockFetch);

    const user = await service.getUser(1);

    expect(mockFetch).toHaveBeenCalledWith("/api/users/1");
    expect(user).toEqual({ id: 1, name: "Alice" });
});

// ── Factory function with injected dependencies ──
const createUserService = ({ fetch: fetchImpl = fetch, log = console.log } = {}) => ({
    async getUser(id) {
        const response = await fetchImpl(`/api/users/${id}`);
        const user = await response.json();
        log("fetched user:", user.name);
        return user;
    },
});
// Test: createUserService({ fetch: mockFetch, log: mockLog })

TDD: Red-Green-Refactor

javascript
// ── TDD cycle: 1. Red (write failing test) → 2. Green (make it pass) → 3. Refactor ──

// Step 1: RED — write a test for a function that doesn't exist yet
describe("formatCurrency", () => {
    it("formats a number as USD", () => {
        expect(formatCurrency(1234.56)).toBe("$1,234.56");
    });
    it("handles zero", () => {
        expect(formatCurrency(0)).toBe("$0.00");
    });
    it("handles negative numbers", () => {
        expect(formatCurrency(-100)).toBe("-$100.00");
    });
});
// Test fails: formatCurrency is not defined (RED — write minimal code to pass)

// Step 2: GREEN — write the minimum code to make the test pass
function formatCurrency(amount) {
    return new Intl.NumberFormat("en-US", {
        style: "currency",
        currency: "USD",
    }).format(amount);
}
// Test passes (GREEN)

// Step 3: REFACTOR — improve the code without changing behavior (tests still pass)
// e.g., add error handling, optimize, extract constants — tests ensure correctness.

// ── Snapshot testing (for stable output like rendered HTML) ──
it("matches the snapshot", () => {
    const html = renderComponent({ title: "Hello" });
    expect(html).toMatchInlineSnapshot(`
      "<div class=\\"title\\">Hello</div>"
    `");
    // If the output changes, the snapshot test fails (review and update if intentional).
});

💡 Tips & Tricks

javascript
// ── Test data builders (reduce boilerplate) ──
const createUser = (overrides = {}) => ({
    id: Math.random(),
    name: "Test User",
    email: "test@example.com",
    active: true,
    ...overrides,  // allow partial overrides
});
createUser({ name: "Alice" });  // { id: ..., name: "Alice", email: ..., active: true }

// ── Test async code ──
it("fetches user data", async () => {
    const user = await getUser(1);
    expect(user.name).toBe("Alice");
});

// ── Test rejected promises ──
it("rejects on invalid ID", async () => {
    await expect(getUser(-1)).rejects.toThrow("invalid ID");
});

// ── beforeEach for common setup ──
beforeEach(() => {
    localStorage.clear();  // reset state before each test
    document.body.innerHTML = "";  // clean DOM
});

// ── Test with timers (vi.useFakeTimers) ──
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it("debounces calls", () => {
    const fn = vi.fn();
    const debounced = debounce(fn, 1000);
    debounced(); debounced(); debounced();  // multiple calls
    vi.advanceTimersByTime(1000);  // fast-forward 1s (no real waiting)
    expect(fn).toHaveBeenCalledTimes(1);  // only called once after debounce
});

⚠️ Edge Cases & Gotchas

javascript
// ── Don't test implementation details (test behavior, not internals) ──
// Tests that check internal state, call counts, or data structures break on refactoring.
// Test the public API and observable behavior.

// ── `toEqual` does deep equality; `toBe` does strict equality ──
expect({ a: 1 }).toEqual({ a: 1 });  // ✓ (deep — same content)
expect({ a: 1 }).toBe({ a: 1 });    // ✗ (different object references)

// ── `toBeNaN` (not `toBe(NaN)`) ──
expect(NaN).toBeNaN();  // ✓ (NaN !== NaN, so toBe(NaN) would fail)

// ── Mock cleanup: clear mocks between tests to avoid cross-test pollution ──
beforeEach(() => vi.clearAllMocks());  // reset call counts

// ── vi.mock is hoisted (runs before imports) ──
// vi.mock("./api.js", () => ({ fetchData: vi.fn() }));
// This is hoisted to the top — even imports below are mocked.
// Can't use variables defined later in the mock factory (hoisting breaks references).

// ── Snapshot tests can hide bugs (update too easily) ──
// If the output changes, the test fails, but `--update-snapshot` silently passes.
// Review snapshot changes carefully — don't blindly update.

// ── Testing async: always use async/await or expect.assertions ──
it("async test", async () => {
    expect.assertions(1);  // ensure exactly 1 assertion runs (catches swallowed async)
    await someAsyncFunction();
    expect(result).toBe(true);
});

🧠 Quick Quiz

Why is this test fragile?

javascript
it("sorts users by name", () => {
    const users = [createUser({ name: "Zara" }), createUser({ name: "Alice" })];
    const sorted = sortBy(users, "name");
    expect(sorted[0].id).toBe(users[1].id);  // fragile
});
Answer

The test depends on the implementation detail of which user object was created second (users[1].id). If the test data creation order changes, or if createUser is refactored to generate IDs differently, the test breaks — even if the sort is correct.

Fix: test the observable behavior (the sorted order of names), not the implementation detail (which object ID is first):

it("sorts users by name", () => {
    const users = [createUser({ name: "Zara" }), createUser({ name: "Alice" })];
    const sorted = sortBy(users, "name");
    expect(sorted.map(u => u.name)).toEqual(["Alice", "Zara"]);  // test behavior
});

The lesson: test what the user observes (the sorted names), not implementation details (object IDs, internal order). Behavior tests survive refactoring; implementation tests break when internals change.