Table of Contents

1. Why Test & Types of Tests 2. Test Doubles: Mocks, Spies, Stubs, Fakes 3. Dependency Injection (DI) for Testable Code 4. Jest Deep Dive 5. Jasmine 6. Vitest -- The Modern Runner 7. Assertions & Matchers 8. Mocking Deep Dive -- Real Patterns 9. Spies Deep Dive 10. Testing Async Code 11. Real-World Testing Examples 12. Testing HTTP APIs 13. Snapshot Testing 14. Code Coverage 15. TDD (Test-Driven Development) 16. E2E Testing with Playwright 17. Testing in CI

1. Why Test & Types of Tests

Text
Testing Pyramid:

         /  E2E  \          ← Slow, expensive, few of these
        / ———— \
       / Integration\       ← Tests modules working together
      / —————— \
     /    Unit Tests   \    ← Fast, isolated, many of these
    / ———————— \
Text
Type           Scope                        Speed    Tools
—————————————————————————————
Unit           Single function/class        Fast     Vitest, Jest, Jasmine
Integration    Multiple modules together    Medium   Vitest, Jest + DB
E2E            Full app from user's view    Slow     Playwright, Cypress
What makes a good test:
  • Tests behavior, not implementation -- test what something does, not how it does it
  • Fails when behavior breaks, passes when it's correct
  • Fast and deterministic -- no flaky tests, no network calls, no randomness
  • Readable -- serves as documentation for what the code should do
  • Isolated -- one test failing shouldn't make others fail

The AAA Pattern

Every test follows this structure:

TypeScript
it("calculates total with tax", () => {
  // ARRANGE -- set up the data and dependencies
  const cart = new Cart();
  cart.add({ name: "Widget", price: 100 });

  // ACT -- do the thing you're testing
  const total = cart.totalWithTax(0.1);

  // ASSERT -- check the result
  expect(total).toBe(110);
});

2. Test Doubles: Mocks, Spies, Stubs, Fakes

"Test double" is the umbrella term for any object you substitute for a real dependency in tests. There are 5 kinds, and most people mix up the names. Here's the actual vocabulary.

Text
Type       What it does                              Example
——————————————————————————————————
Dummy      Passed around but never used              Placeholder to fill a parameter
Stub       Returns pre-programmed values             getUser() always returns { name: "Sean" }
Spy        Records calls (who, what, when)           Wraps real method, tracks calls
Mock       Pre-programmed with expectations           Verifies it was called correctly
Fake       Working implementation, simplified         In-memory database instead of Postgres

Dummy

An object that exists just to satisfy a type signature. You don't care what it does.

TypeScript
// We need a Logger to create UserService, but this test doesn't care about logging
const dummyLogger: Logger = {
  info: () => {},
  error: () => {},
  warn: () => {},
};

const service = new UserService(db, dummyLogger);

Stub

Returns canned answers. You control the output so you can test how your code handles different scenarios.

TypeScript
// Stub: always returns the same user, no real DB call
const stubUserRepo = {
  findById: async (id: string) => ({ id, name: "Sean", email: "sean@test.com" }),
  save: async () => {},
};

const service = new UserService(stubUserRepo);
const user = await service.getUser("123");
expect(user.name).toBe("Sean");

Spy

Wraps a real (or fake) function and records every call: arguments, return value, how many times.

TypeScript
const spy = vi.fn(); // or jest.fn()

spy("hello", 42);
spy("world");

expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith("hello", 42);
expect(spy).toHaveBeenLastCalledWith("world");
expect(spy.mock.calls).toEqual([["hello", 42], ["world"]]);

Mock

A mock is a spy with built-in expectations. It verifies the interaction itself: "was this method called with these args?"

TypeScript
// Mock: we verify that sendEmail was called, not just what it returns
const mockEmailService = {
  sendEmail: vi.fn().mockResolvedValue(undefined),
};

const service = new RegistrationService(userRepo, mockEmailService);
await service.register({ name: "Sean", email: "sean@test.com" });

// The test is about the INTERACTION -- did it send the welcome email?
expect(mockEmailService.sendEmail).toHaveBeenCalledWith(
  "sean@test.com",
  expect.stringContaining("Welcome")
);

Fake

A fully working but simplified implementation. Unlike stubs/mocks, fakes have real logic.

TypeScript
// Fake: an in-memory implementation instead of a real database
class FakeUserRepo implements UserRepository {
  private users: Map<string, User> = new Map();

  async save(user: User) {
    this.users.set(user.id, user);
  }

  async findById(id: string) {
    return this.users.get(id) ?? null;
  }

  async findAll() {
    return [...this.users.values()];
  }
}

// Use in tests -- it actually stores and retrieves, just in memory
const repo = new FakeUserRepo();
const service = new UserService(repo);
await service.createUser({ name: "Sean" });
const found = await service.getUser("sean-id");
expect(found).not.toBeNull();
When to use which:
  • Dummy -- when a parameter is required but irrelevant to the test
  • Stub -- when you need to control what a dependency returns
  • Spy -- when you want to verify something was called
  • Mock -- when the interaction IS the thing you're testing
  • Fake -- for integration-style tests where you need real behavior without real infrastructure

3. Dependency Injection (DI) for Testable Code

Dependency Injection means passing dependencies in from outside instead of creating them inside. This is the #1 pattern that makes code testable.

The Problem: Hard-Coded Dependencies

TypeScript
// ❌ BAD -- impossible to test without a real database
class UserService {
  private db = new PostgresClient("postgres://localhost:5432/mydb");

  async getUser(id: string) {
    return this.db.query("SELECT * FROM users WHERE id = $1", [id]);
  }
}

// How do you test this? You'd need a running Postgres.
// What if the DB is down? What if you want to test the error path?
// You can't swap it out because it's created INSIDE the class.

The Solution: Constructor Injection

TypeScript
// ✅ GOOD -- dependency is passed in, easy to swap for tests
interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

class UserService {
  constructor(private repo: UserRepository) {}

  async getUser(id: string): Promise<User> {
    const user = await this.repo.findById(id);
    if (!user) throw new Error("User not found");
    return user;
  }
}

// Production: real database
const service = new UserService(new PostgresUserRepo(pool));

// Tests: stub, mock, or fake -- your choice
const service = new UserService(stubUserRepo);

Full DI Example: Service with Multiple Dependencies

TypeScript
// Define interfaces for each dependency
interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

interface EmailService {
  send(to: string, subject: string, body: string): Promise<void>;
}

interface Logger {
  info(msg: string): void;
  error(msg: string, err?: Error): void;
}

// Service depends on INTERFACES, not concrete classes
class RegistrationService {
  constructor(
    private users: UserRepository,
    private email: EmailService,
    private log: Logger,
  ) {}

  async register(input: { name: string; email: string }) {
    const user: User = { id: crypto.randomUUID(), ...input };
    await this.users.save(user);
    await this.email.send(input.email, "Welcome!", `Hi ${input.name}`);
    this.log.info(`Registered user ${user.id}`);
    return user;
  }
}

Testing the DI'd Service

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

describe("RegistrationService", () => {
  // Create test doubles for each dependency
  function setup() {
    const mockRepo: UserRepository = {
      findById: vi.fn(),
      save: vi.fn().mockResolvedValue(undefined),
    };
    const mockEmail: EmailService = {
      send: vi.fn().mockResolvedValue(undefined),
    };
    const mockLog: Logger = {
      info: vi.fn(),
      error: vi.fn(),
    };
    const service = new RegistrationService(mockRepo, mockEmail, mockLog);
    return { service, mockRepo, mockEmail, mockLog };
  }

  it("saves the user", async () => {
    const { service, mockRepo } = setup();

    await service.register({ name: "Sean", email: "sean@test.com" });

    expect(mockRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({ name: "Sean", email: "sean@test.com" })
    );
  });

  it("sends a welcome email", async () => {
    const { service, mockEmail } = setup();

    await service.register({ name: "Sean", email: "sean@test.com" });

    expect(mockEmail.send).toHaveBeenCalledWith(
      "sean@test.com",
      "Welcome!",
      expect.stringContaining("Sean")
    );
  });

  it("logs the registration", async () => {
    const { service, mockLog } = setup();

    const user = await service.register({ name: "Sean", email: "sean@test.com" });

    expect(mockLog.info).toHaveBeenCalledWith(
      expect.stringContaining(user.id)
    );
  });

  it("still saves user if email fails", async () => {
    const { service, mockEmail, mockRepo } = setup();
    mockEmail.send = vi.fn().mockRejectedValue(new Error("SMTP down"));

    await expect(service.register({ name: "Sean", email: "sean@test.com" }))
      .rejects.toThrow("SMTP down");

    // User was saved BEFORE the email step
    expect(mockRepo.save).toHaveBeenCalled();
  });
});
DI without a framework: You don't need NestJS or InversifyJS for DI. Just pass dependencies through constructors or function parameters. The pattern is the important part, not the framework.

Function-Based DI (No Classes Needed)

TypeScript
// You can do DI with plain functions too
type Deps = {
  getUser: (id: string) => Promise<User | null>;
  sendEmail: (to: string, body: string) => Promise<void>;
};

export function createRegistrationHandler(deps: Deps) {
  return async function register(name: string, email: string) {
    // ... use deps.getUser, deps.sendEmail
  };
}

// Production
const register = createRegistrationHandler({ getUser: dbGetUser, sendEmail: smtpSend });

// Test
const register = createRegistrationHandler({ getUser: vi.fn(), sendEmail: vi.fn() });

4. Jest Deep Dive

Jest is the most widely used JS test framework. Created by Facebook, batteries-included: runner, assertions, mocking, coverage, snapshots all built in.

Bash
# Install for TypeScript
pnpm add -D jest @types/jest ts-jest

# Initialize config
npx ts-jest config:init

# Run
npx jest                    # run all tests
npx jest --watch            # watch mode
npx jest --coverage         # with coverage report
npx jest path/to/file.test  # run specific file
npx jest -t "user"          # run tests matching name

Jest Config

JavaScript
// jest.config.js
export default {
  preset: "ts-jest",
  testEnvironment: "node",
  roots: ["<rootDir>/src"],
  testMatch: ["**/*.test.ts"],
  collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"],
  coverageThreshold: {
    global: { branches: 80, functions: 80, lines: 80, statements: 80 },
  },
  clearMocks: true,  // auto-clear mock state between tests
};

Lifecycle Hooks

TypeScript
describe("UserService", () => {
  let service: UserService;
  let db: FakeDB;

  // Runs ONCE before all tests in this describe
  beforeAll(async () => {
    db = await FakeDB.connect();
  });

  // Runs before EACH test
  beforeEach(() => {
    service = new UserService(db);
  });

  // Runs after EACH test
  afterEach(async () => {
    await db.clear(); // clean state between tests
  });

  // Runs ONCE after all tests
  afterAll(async () => {
    await db.disconnect();
  });

  it("creates a user", async () => {
    const user = await service.create({ name: "Sean" });
    expect(user.id).toBeDefined();
  });
});

Parameterized Tests: describe.each / it.each

TypeScript
// Run the same test with different data
describe.each([
  { input: "hello",  expected: "HELLO" },
  { input: "world",  expected: "WORLD" },
  { input: "",       expected: "" },
])("toUpperCase($input)", ({ input, expected }) => {
  it(`returns "${expected}"`, () => {
    expect(input.toUpperCase()).toBe(expected);
  });
});

// Shorter: table format
it.each([
  [1, 2, 3],
  [0, 0, 0],
  [-1, 1, 0],
])("add(%i, %i) = %i", (a, b, expected) => {
  expect(add(a, b)).toBe(expected);
});

Skipping & Focusing Tests

TypeScript
it.skip("this test is skipped", () => { /* ... */ });
it.only("ONLY this test runs", () => { /* ... */ });
it.todo("write this test later");

describe.skip("skip entire group", () => { /* ... */ });
describe.only("only this group runs", () => { /* ... */ });

Jest Mock Functions

TypeScript
// jest.fn() creates a spy/mock function
const mockFn = jest.fn();
const mockWithImpl = jest.fn((x: number) => x * 2);
const mockAsync = jest.fn().mockResolvedValue({ id: "1" });

// jest.spyOn() wraps an existing method
const spy = jest.spyOn(myObject, "myMethod");
spy.mockReturnValue("fake");

// jest.mock() replaces an entire module
jest.mock("./database");
jest.mock("axios");

// Manual mock: create __mocks__/database.ts alongside database.ts
// Jest auto-uses it when you call jest.mock("./database")

Jest Module Mocking Patterns

TypeScript
// Mock with custom implementation
jest.mock("./database", () => ({
  getUser: jest.fn().mockResolvedValue({ id: "1", name: "Sean" }),
  saveUser: jest.fn(),
}));

// Partial mock: keep real implementation, mock specific exports
jest.mock("./utils", () => ({
  ...jest.requireActual("./utils"),
  sendEmail: jest.fn(), // only mock this one
}));

// Access the mocked module to change behavior per test
import { getUser } from "./database";
const mockedGetUser = getUser as jest.MockedFunction<typeof getUser>;

it("handles not found", async () => {
  mockedGetUser.mockResolvedValueOnce(null);
  await expect(service.getUser("bad-id")).rejects.toThrow("Not found");
});
Vitest vs Jest API mapping: They're almost identical.
jest.fn() = vi.fn()
jest.spyOn() = vi.spyOn()
jest.mock() = vi.mock()
jest.useFakeTimers() = vi.useFakeTimers()
If you know one, you know the other.

5. Jasmine

Jasmine is the OG JavaScript test framework. It came before Jest and influenced its API. Angular still uses Jasmine by default. The syntax is very similar to Jest/Vitest but with some differences.

Bash
# Install
pnpm add -D jasmine @types/jasmine jasmine-ts

# Initialize
npx jasmine init

# Run
npx jasmine

Jasmine Syntax vs Jest

TypeScript
// Jasmine uses the same describe/it/expect pattern
describe("Calculator", () => {
  let calc: Calculator;

  beforeEach(() => {
    calc = new Calculator();
  });

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

  it("throws on divide by zero", () => {
    expect(() => calc.divide(1, 0)).toThrowError("Cannot divide by zero");
  });
});

Jasmine Spies

TypeScript
// Create a spy on an existing method
spyOn(calculator, "add").and.returnValue(42);
calculator.add(1, 2); // returns 42, not 3
expect(calculator.add).toHaveBeenCalledWith(1, 2);

// Create a standalone spy (like jest.fn())
const callback = jasmine.createSpy("callback");
callback("hello");
expect(callback).toHaveBeenCalledWith("hello");

// Spy strategies (what happens when the spy is called)
spyOn(obj, "method").and.callThrough();      // call the real method
spyOn(obj, "method").and.returnValue(42);     // return a value
spyOn(obj, "method").and.callFake((x) => x); // custom implementation
spyOn(obj, "method").and.throwError("nope"); // throw
spyOn(obj, "method").and.returnValues(1,2,3); // different each call

Jasmine Spy Matchers

TypeScript
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledTimes(3);
expect(spy).toHaveBeenCalledWith("arg1", "arg2");
expect(spy).toHaveBeenCalledOnceWith("arg");
expect(spy).not.toHaveBeenCalled();

// Inspect call details
spy.calls.count();        // how many times
spy.calls.argsFor(0);     // args of first call
spy.calls.allArgs();      // args of all calls
spy.calls.mostRecent();   // last call details
spy.calls.reset();        // clear tracking

Jasmine Custom Matchers

TypeScript
beforeEach(() => {
  jasmine.addMatchers({
    toBeValidEmail() {
      return {
        compare(actual: string) {
          const pass = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(actual);
          return {
            pass,
            message: pass
              ? `Expected "${actual}" not to be a valid email`
              : `Expected "${actual}" to be a valid email`,
          };
        },
      };
    },
  });
});

it("validates email", () => {
  expect("sean@test.com").toBeValidEmail();
  expect("not-an-email").not.toBeValidEmail();
});

Jasmine Async

TypeScript
// async/await works the same as Jest
it("fetches data", async () => {
  const data = await fetchData();
  expect(data).toBeDefined();
});

// Jasmine also supports done() callback (older style)
it("fetches data", (done) => {
  fetchData().then((data) => {
    expect(data).toBeDefined();
    done();
  });
});
Text
Feature         Jest              Jasmine           Vitest
—————————————————————————————
Spies           jest.fn()         jasmine.createSpy  vi.fn()
Spy on method   jest.spyOn()      spyOn()            vi.spyOn()
Module mocking  jest.mock()       ❌ manual only      vi.mock()
Snapshot tests  ✅ built-in        ❌ plugin           ✅ built-in
Timer mocking   jest.useFakeTimers jasmine.clock()    vi.useFakeTimers
Speed (w/ TS)   Slow (ts-jest)    Medium             Fast (native ESM)
Used by         React, Node       Angular            Vite projects

6. Vitest -- The Modern Runner

Vitest is a Vite-native test runner. It understands TypeScript, ESM, and JSX out of the box with zero config. Same API as Jest.

Bash
pnpm add -D vitest

npx vitest           # watch mode (default)
npx vitest run       # single run (CI)
npx vitest run --reporter=verbose

Config

TypeScript
// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,           // no need to import describe/it/expect
    environment: "node",     // or "jsdom" for browser APIs
    coverage: {
      provider: "v8",
      reporter: ["text", "html"],
    },
    include: ["src/**/*.test.ts"],
    clearMocks: true,       // auto-reset mocks between tests
  },
});

File Naming

Text
Vitest finds:  **/*.test.ts  **/*.spec.ts  **/__tests__/**

7. Assertions & Matchers

These work in Jest, Vitest, and mostly in Jasmine too.

TypeScript
// ── Equality ──
expect(1 + 1).toBe(2);               // strict === (primitives)
expect({ a: 1 }).toEqual({ a: 1 }); // deep equality (objects)
expect(obj).toStrictEqual(other);    // deep + checks undefined props

// ── Truthiness ──
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// ── Numbers ──
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(10).toBeLessThan(20);
expect(0.1 + 0.2).toBeCloseTo(0.3);   // floating point safe

// ── Strings ──
expect("hello world").toContain("world");
expect("hello").toMatch(/^hel/);

// ── Arrays ──
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
expect([{ id: 1 }, { id: 2 }]).toContainEqual({ id: 1 });
expect([3, 1, 2]).toEqual(expect.arrayContaining([1, 2]));

// ── Objects ──
expect(obj).toHaveProperty("key");
expect(obj).toHaveProperty("nested.deep.key");
expect(obj).toMatchObject({ name: "Sean" }); // partial match
expect(obj).toEqual(expect.objectContaining({ name: "Sean" }));

// ── Exceptions ──
expect(() => throwingFn()).toThrow();
expect(() => throwingFn()).toThrow("specific message");
expect(() => throwingFn()).toThrow(TypeError);

// ── Negation ──
expect(1).not.toBe(2);

// ── Asymmetric matchers (inside other matchers) ──
expect(obj).toEqual({
  id: expect.any(String),
  name: expect.stringContaining("Sean"),
  tags: expect.arrayContaining(["admin"]),
  createdAt: expect.any(Date),
});

8. Mocking Deep Dive -- Real Patterns

Mock Functions: The Complete API

TypeScript
const fn = vi.fn(); // or jest.fn()

// ── Return values ──
fn.mockReturnValue("always this");
fn.mockReturnValueOnce("first call");
fn.mockResolvedValue({ data: 1 });       // async: resolves
fn.mockResolvedValueOnce({ data: 1 });   // async: resolves once
fn.mockRejectedValue(new Error("fail")); // async: rejects
fn.mockRejectedValueOnce(new Error());   // async: rejects once

// ── Custom implementation ──
fn.mockImplementation((x) => x * 2);
fn.mockImplementationOnce((x) => x * 10);

// ── Chain them for different behavior per call ──
const getUserMock = vi.fn()
  .mockResolvedValueOnce({ id: "1", name: "Sean" })   // 1st call
  .mockResolvedValueOnce(null)                         // 2nd call
  .mockRejectedValue(new Error("DB down"));           // all after

// ── Inspect calls ──
fn.mock.calls;           // [[arg1, arg2], [arg3]]
fn.mock.calls[0];        // args of first call
fn.mock.results;         // [{ type: "return", value: ... }]
fn.mock.lastCall;        // args of most recent call

// ── Reset ──
fn.mockClear();          // clear calls & results (keep implementation)
fn.mockReset();          // clear everything (implementation becomes () => undefined)
fn.mockRestore();        // restore original (only for spyOn)

Mocking Modules

TypeScript
// ── Full module mock ──
vi.mock("./database", () => ({
  getUser: vi.fn().mockResolvedValue({ id: "1", name: "Sean" }),
  saveUser: vi.fn(),
}));

// ── Partial module mock (keep real exports, mock some) ──
vi.mock("./utils", async (importOriginal) => {
  const actual = await importOriginal();
  return {
    ...actual,
    sendEmail: vi.fn(), // only mock this one export
  };
});

// ── Mock a third-party package ──
vi.mock("axios", () => ({
  default: {
    get: vi.fn().mockResolvedValue({ data: { users: [] } }),
    post: vi.fn().mockResolvedValue({ data: { id: "new" } }),
  },
}));

Mocking fetch / Network Requests

TypeScript
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;

beforeEach(() => {
  mockFetch.mockReset();
});

it("fetches users", async () => {
  mockFetch.mockResolvedValue({
    ok: true,
    json: async () => [{ id: "1", name: "Sean" }],
  });

  const users = await getUsers();

  expect(mockFetch).toHaveBeenCalledWith("https://api.example.com/users");
  expect(users).toHaveLength(1);
});

it("handles network error", async () => {
  mockFetch.mockRejectedValue(new Error("Network failed"));

  await expect(getUsers()).rejects.toThrow("Network failed");
});

it("handles non-OK response", async () => {
  mockFetch.mockResolvedValue({
    ok: false,
    status: 500,
    statusText: "Internal Server Error",
  });

  await expect(getUsers()).rejects.toThrow();
});

Mocking Timers

TypeScript
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });

it("debounces calls", () => {
  const callback = vi.fn();
  const debounced = debounce(callback, 300);

  debounced();
  debounced();
  debounced();

  expect(callback).not.toHaveBeenCalled(); // not yet

  vi.advanceTimersByTime(300);

  expect(callback).toHaveBeenCalledTimes(1); // only once
});

it("runs setInterval", () => {
  const callback = vi.fn();
  setInterval(callback, 1000);

  vi.advanceTimersByTime(3000);
  expect(callback).toHaveBeenCalledTimes(3);
});

// Other timer controls
vi.runAllTimers();         // fast-forward until all timers fire
vi.runOnlyPendingTimers(); // run currently pending timers only
vi.advanceTimersToNextTimer(); // jump to next timer
vi.setSystemTime(new Date("2025-01-01")); // mock Date.now()

Mocking Classes

TypeScript
// Automatically mock all methods of a class
vi.mock("./EmailService");
import { EmailService } from "./EmailService";

it("uses mocked EmailService", () => {
  const email = new EmailService();
  // All methods are now vi.fn() -- no real implementation
  email.send("to", "subject", "body");
  expect(email.send).toHaveBeenCalled();
});

9. Spies Deep Dive

A spy wraps an existing function/method and tracks its calls while optionally changing its behavior.

spyOn -- Watch Without Changing

TypeScript
const obj = {
  greet(name: string) { return `Hello ${name}`; }
};

const spy = vi.spyOn(obj, "greet");

obj.greet("Sean"); // still calls the REAL method

expect(spy).toHaveBeenCalledWith("Sean");
expect(spy).toHaveReturnedWith("Hello Sean");

spy.mockRestore(); // remove the spy, restore original

spyOn -- Replace Behavior

TypeScript
const spy = vi.spyOn(obj, "greet").mockReturnValue("mocked");

obj.greet("Sean"); // returns "mocked", NOT the real implementation

expect(spy).toHaveBeenCalledWith("Sean");

Spy on Console, Date, Math

TypeScript
// Silence console.error in tests
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

doSomethingThatLogs(); // no output in test runner

expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("warning"));
errorSpy.mockRestore();

// Spy on Math.random for deterministic tests
vi.spyOn(Math, "random").mockReturnValue(0.5);
expect(rollDice()).toBe(4); // deterministic

// Spy on Date.now
vi.spyOn(Date, "now").mockReturnValue(1704067200000); // Jan 1 2024

Tracking Call Order Across Multiple Spies

TypeScript
const save = vi.fn();
const notify = vi.fn();

await registerUser(save, notify);

// Verify save was called BEFORE notify
const saveOrder = save.mock.invocationCallOrder[0];
const notifyOrder = notify.mock.invocationCallOrder[0];
expect(saveOrder).toBeLessThan(notifyOrder);

10. Testing Async Code

TypeScript
// ── async/await (preferred) ──
it("fetches user", async () => {
  const user = await getUser("1");
  expect(user.name).toBe("Sean");
});

// ── Testing rejections ──
it("throws on invalid id", async () => {
  await expect(getUser("")).rejects.toThrow("Invalid ID");
});

// ── Testing resolved values ──
it("resolves with user", async () => {
  await expect(getUser("1")).resolves.toEqual(
    expect.objectContaining({ name: "Sean" })
  );
});

// ── Testing callbacks (legacy pattern) ──
it("calls back with data", (done) => {
  fetchData((err, data) => {
    expect(err).toBeNull();
    expect(data).toBeDefined();
    done(); // MUST call done() or test hangs
  });
});

// ── Testing event emitters ──
it("emits 'ready' event", async () => {
  const server = new MyServer();
  const promise = new Promise((resolve) => {
    server.on("ready", resolve);
  });

  server.start();

  await expect(promise).resolves.toBeDefined();
});

11. Real-World Testing Examples

Example 1: Testing a Service with DI

TypeScript
// order-service.ts
interface Deps {
  inventory: { checkStock: (productId: string) => Promise<number> };
  payments: { charge: (amount: number, card: string) => Promise<{ txId: string }> };
  orders: { save: (order: Order) => Promise<void> };
}

export function createOrderService(deps: Deps) {
  return {
    async placeOrder(productId: string, qty: number, card: string) {
      const stock = await deps.inventory.checkStock(productId);
      if (stock < qty) throw new Error("Insufficient stock");

      const { txId } = await deps.payments.charge(qty * 10, card);

      const order = { id: crypto.randomUUID(), productId, qty, txId };
      await deps.orders.save(order);
      return order;
    },
  };
}
TypeScript
// order-service.test.ts
describe("OrderService.placeOrder", () => {
  function setup(overrides: Partial<Deps> = {}) {
    const deps: Deps = {
      inventory: { checkStock: vi.fn().mockResolvedValue(100) },
      payments:  { charge: vi.fn().mockResolvedValue({ txId: "tx-123" }) },
      orders:    { save: vi.fn().mockResolvedValue(undefined) },
      ...overrides,
    };
    return { service: createOrderService(deps), ...deps };
  }

  it("creates an order when stock is available", async () => {
    const { service, orders } = setup();

    const order = await service.placeOrder("prod-1", 2, "card-xxx");

    expect(order.productId).toBe("prod-1");
    expect(order.txId).toBe("tx-123");
    expect(orders.save).toHaveBeenCalledWith(expect.objectContaining({ qty: 2 }));
  });

  it("rejects when out of stock", async () => {
    const { service } = setup({
      inventory: { checkStock: vi.fn().mockResolvedValue(0) },
    });

    await expect(service.placeOrder("prod-1", 5, "card"))
      .rejects.toThrow("Insufficient stock");
  });

  it("does NOT save order if payment fails", async () => {
    const { service, orders } = setup({
      payments: { charge: vi.fn().mockRejectedValue(new Error("Card declined")) },
    });

    await expect(service.placeOrder("prod-1", 1, "bad-card"))
      .rejects.toThrow("Card declined");

    expect(orders.save).not.toHaveBeenCalled();
  });

  it("charges the correct amount", async () => {
    const { service, payments } = setup();

    await service.placeOrder("prod-1", 3, "card-xxx");

    expect(payments.charge).toHaveBeenCalledWith(30, "card-xxx");
  });
});

Example 2: Testing Error Paths

TypeScript
describe("error handling", () => {
  it("retries on transient failure then succeeds", async () => {
    const mockFetch = vi.fn()
      .mockRejectedValueOnce(new Error("timeout"))    // 1st: fail
      .mockRejectedValueOnce(new Error("timeout"))    // 2nd: fail
      .mockResolvedValue({ data: "success" });       // 3rd: ok

    const result = await fetchWithRetry(mockFetch, 3);

    expect(result).toEqual({ data: "success" });
    expect(mockFetch).toHaveBeenCalledTimes(3);
  });

  it("gives up after max retries", async () => {
    const mockFetch = vi.fn().mockRejectedValue(new Error("timeout"));

    await expect(fetchWithRetry(mockFetch, 3)).rejects.toThrow("timeout");
    expect(mockFetch).toHaveBeenCalledTimes(3);
  });
});

Example 3: Testing a Cache

TypeScript
describe("CachedUserService", () => {
  it("calls DB on first request, returns cache on second", async () => {
    const dbGet = vi.fn().mockResolvedValue({ id: "1", name: "Sean" });
    const service = new CachedUserService({ findById: dbGet });

    const first  = await service.getUser("1");
    const second = await service.getUser("1");

    expect(first).toEqual(second);
    expect(dbGet).toHaveBeenCalledTimes(1); // only ONE db call
  });

  it("cache miss for different IDs", async () => {
    const dbGet = vi.fn().mockResolvedValue({ id: "1", name: "Sean" });
    const service = new CachedUserService({ findById: dbGet });

    await service.getUser("1");
    await service.getUser("2");

    expect(dbGet).toHaveBeenCalledTimes(2); // different IDs = 2 calls
  });
});

Example 4: Testing Event Emitters

TypeScript
import { EventEmitter } from "node:events";

class TaskRunner extends EventEmitter {
  async run(task: () => Promise<void>) {
    this.emit("start");
    try {
      await task();
      this.emit("success");
    } catch (err) {
      this.emit("error", err);
    }
  }
}

describe("TaskRunner", () => {
  it("emits start then success", async () => {
    const runner = new TaskRunner();
    const startSpy = vi.fn();
    const successSpy = vi.fn();

    runner.on("start", startSpy);
    runner.on("success", successSpy);

    await runner.run(async () => {});

    expect(startSpy).toHaveBeenCalledTimes(1);
    expect(successSpy).toHaveBeenCalledTimes(1);
  });

  it("emits error on failure", async () => {
    const runner = new TaskRunner();
    const errorSpy = vi.fn();
    runner.on("error", errorSpy);

    await runner.run(async () => { throw new Error("boom"); });

    expect(errorSpy).toHaveBeenCalledWith(expect.any(Error));
  });
});

12. Testing HTTP APIs

Use supertest to test Express/Fastify/Hono apps without starting a real server.

TypeScript
import { describe, it, expect } from "vitest";
import request from "supertest";
import { app } from "./app";

describe("GET /api/users", () => {
  it("returns 200 and users", async () => {
    const res = await request(app)
      .get("/api/users")
      .set("Authorization", "Bearer test-token")
      .expect(200);

    expect(res.body).toHaveLength(3);
    expect(res.body[0]).toHaveProperty("name");
  });

  it("returns 401 without auth", async () => {
    await request(app).get("/api/users").expect(401);
  });
});

describe("POST /api/users", () => {
  it("creates a user", async () => {
    const res = await request(app)
      .post("/api/users")
      .send({ name: "Sean", email: "sean@test.com" })
      .expect(201);

    expect(res.body.id).toBeDefined();
  });

  it("returns 400 on invalid input", async () => {
    const res = await request(app)
      .post("/api/users")
      .send({ name: "" }) // missing email
      .expect(400);

    expect(res.body.errors).toBeDefined();
  });
});

Testing with DI in Express

TypeScript
// app.ts -- accepts dependencies
export function createApp(deps: { userRepo: UserRepository }) {
  const app = express();
  app.use(express.json());

  app.get("/api/users/:id", async (req, res) => {
    const user = await deps.userRepo.findById(req.params.id);
    if (!user) return res.status(404).json({ error: "Not found" });
    res.json(user);
  });

  return app;
}

// app.test.ts
describe("GET /api/users/:id", () => {
  it("returns user when found", async () => {
    const app = createApp({
      userRepo: { findById: vi.fn().mockResolvedValue({ id: "1", name: "Sean" }) },
    });

    const res = await request(app).get("/api/users/1");
    expect(res.status).toBe(200);
    expect(res.body.name).toBe("Sean");
  });

  it("returns 404 when not found", async () => {
    const app = createApp({
      userRepo: { findById: vi.fn().mockResolvedValue(null) },
    });

    const res = await request(app).get("/api/users/bad-id");
    expect(res.status).toBe(404);
  });
});

13. Snapshot Testing

TypeScript
it("generates correct config", () => {
  const config = generateConfig({ env: "production" });
  expect(config).toMatchSnapshot();
  // First run: creates __snapshots__/file.test.ts.snap
  // Next runs: compares against saved snapshot
});

// Inline snapshot (stored in the test file itself)
it("formats name", () => {
  expect(formatName("sean")).toMatchInlineSnapshot(`"Sean"`);
});
Bash
# Update snapshots when output intentionally changes
npx vitest run --update     # vitest
npx jest --updateSnapshot   # jest
Snapshot testing gotcha: Snapshots can become "rubber stamp" tests where devs just update them without checking. Use them for structured output (configs, serialized data) but prefer explicit assertions for business logic.

14. Code Coverage

Bash
npx vitest run --coverage
npx jest --coverage

# Install coverage provider for Vitest
pnpm add -D @vitest/coverage-v8
Text
Coverage Metrics:
—————————————————————————
Statements   How many statements were executed
Branches     How many if/else/switch branches were taken
Functions    How many functions were called
Lines        How many lines were executed

Coverage Thresholds

TypeScript
// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      thresholds: {
        statements: 80,
        branches: 80,
        functions: 80,
        lines: 80,
      },
    },
  },
});
80% coverage is a good target. 100% is usually wasteful -- you end up testing getters and trivial code. Focus coverage on business logic and edge cases.

15. TDD (Test-Driven Development)

Text
The TDD Cycle:

1. RED    → Write a failing test first
2. GREEN  → Write the minimum code to make it pass
3. REFACTOR → Clean up, keep tests passing
4. Repeat

TDD Example: Password Validator

TypeScript
// Step 1: RED -- write failing tests FIRST
describe("validatePassword", () => {
  it("rejects empty string", () => {
    expect(validatePassword("")).toEqual({ valid: false, errors: ["Too short"] });
  });

  it("rejects short passwords", () => {
    expect(validatePassword("abc")).toEqual({ valid: false, errors: ["Too short"] });
  });

  it("requires a number", () => {
    const result = validatePassword("abcdefgh");
    expect(result.valid).toBe(false);
    expect(result.errors).toContain("Must contain a number");
  });

  it("accepts valid password", () => {
    expect(validatePassword("abcdef1!")).toEqual({ valid: true, errors: [] });
  });
});

// Step 2: GREEN -- implement just enough to pass
export function validatePassword(pw: string) {
  const errors: string[] = [];
  if (pw.length < 8) errors.push("Too short");
  if (!/\d/.test(pw)) errors.push("Must contain a number");
  if (!/[!@#$%]/.test(pw)) errors.push("Must contain a special character");
  return { valid: errors.length === 0, errors };
}

// Step 3: REFACTOR -- maybe extract a Rule type, clean up, tests still pass

16. E2E Testing with Playwright

Playwright tests your app from a real browser -- clicks buttons, fills forms, checks what the user sees.

Bash
pnpm add -D @playwright/test
npx playwright install
TypeScript
import { test, expect } from "@playwright/test";

test("user can log in", async ({ page }) => {
  await page.goto("http://localhost:3000/login");
  await page.fill('input[name="email"]', "sean@test.com");
  await page.fill('input[name="password"]', "password123");
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL("/dashboard");
  await expect(page.locator("h1")).toHaveText("Welcome, Sean");
});

test("shows error on bad password", async ({ page }) => {
  await page.goto("http://localhost:3000/login");
  await page.fill('input[name="email"]', "sean@test.com");
  await page.fill('input[name="password"]', "wrong");
  await page.click('button[type="submit"]');

  await expect(page.locator(".error")).toBeVisible();
});
Bash
npx playwright test            # run tests
npx playwright test --ui       # visual debugging
npx playwright codegen http://localhost:3000  # generate tests by clicking

17. Testing in CI

YAML
# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm typecheck
      - run: pnpm test:ci
      - run: pnpm lint
JSON
{
  "scripts": {
    "test": "vitest",
    "test:ci": "vitest run --coverage --reporter=verbose",
    "typecheck": "tsc --noEmit",
    "lint": "eslint src/"
  }
}
CI testing checklist:
  • Use --frozen-lockfile to ensure reproducible installs
  • Run type checking, tests, and linting as separate steps
  • Set coverage thresholds to prevent regression
  • Cache node_modules for faster CI runs