Everything about testing JavaScript and TypeScript: the test doubles vocabulary (mocks, spies, stubs, fakes), dependency injection for testable code, Jest and Jasmine in depth, Vitest, real-world mocking patterns, async testing, HTTP API testing, TDD, E2E with Playwright, and CI integration.
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
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);
});
"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
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);
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");
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"]]);
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")
);
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();
Dependency Injection means passing dependencies in from outside instead of creating them inside. This is the #1 pattern that makes code testable.
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.
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);
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;
}
}
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();
});
});
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() });
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
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
};
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();
});
});
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);
});
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", () => { /* ... */ });
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")
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");
});
jest.fn() = vi.fn()jest.spyOn() = vi.spyOn()jest.mock() = vi.mock()jest.useFakeTimers() = vi.useFakeTimers()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
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");
});
});
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
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
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();
});
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
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
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
},
});
Text
Vitest finds: **/*.test.ts **/*.spec.ts **/__tests__/**
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),
});
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)
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" } }),
},
}));
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();
});
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()
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();
});
A spy wraps an existing function/method and tracks its calls while optionally changing its behavior.
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
TypeScript
const spy = vi.spyOn(obj, "greet").mockReturnValue("mocked");
obj.greet("Sean"); // returns "mocked", NOT the real implementation
expect(spy).toHaveBeenCalledWith("Sean");
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
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);
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();
});
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");
});
});
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);
});
});
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
});
});
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));
});
});
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();
});
});
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);
});
});
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
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
TypeScript
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
},
});
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
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
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
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/"
}
}
--frozen-lockfile to ensure reproducible installsnode_modules for faster CI runs