Testing in TypeScript

TypeScript Real World Applications — Testing in TypeScript
TypeScript Real World Applications
Course 3 · Chapter 2 · Testing in TypeScript

🧪 Testing in TypeScript

The compiler catches an entire category of bugs before you ever run your code — but it can't tell you whether calculateDiscount() returns the right number. That's what tests are for. This chapter covers Jest with TypeScript, type-safe mocking, and how the DI container from the last chapter makes both unit and integration testing dramatically easier.

Types Aren't Tests

The type checker verifies shape — that you're passing a number where a number is expected. It says nothing about behavior:

function calculateDiscount(price: number, percent: number): number { return price + (price * percent) / 100; // ❌ Bug: should be minus, not plus } // TypeScript is perfectly happy with this — the types all line up. // Only a test catches that discount(100, 10) returns 110, not 90.

Type Errors

Caught at compile time: wrong shape, wrong argument count, undefined not handled.

Logic Errors

Only caught at runtime, by tests: wrong formula, off-by-one, incorrect branching.

Setting Up Jest with TypeScript

The most common setup uses ts-jest so Jest compiles .ts files on the fly:

npm install --save-dev jest ts-jest @types/jest npx ts-jest config:init

That generates a jest.config.js pointing Jest at the TypeScript preset. Test files follow the *.test.ts or *.spec.ts convention:

// discount.test.ts import { calculateDiscount } from "./discount"; describe("calculateDiscount", () => { it("reduces the price by the given percentage", () => { expect(calculateDiscount(100, 10)).toBe(90); }); it("returns the full price when percent is 0", () => { expect(calculateDiscount(50, 0)).toBe(50); }); });

🎭 Type-Safe Mocking

Mocking Interfaces from Chapter 1

Because OrderService from the DI chapter depends on interfaces, not concrete classes, mocking is just building an object that satisfies the interface:

interface Database { save(order: Order): void; } interface Mailer { send(to: string): void; } describe("OrderService", () => { it("saves the order and emails the customer", () => { const db: Database = { save: jest.fn() }; const mailer: Mailer = { send: jest.fn() }; const service = new OrderService(db, mailer); service.placeOrder({ id: "1", email: "a@b.com" }); expect(db.save).toHaveBeenCalledTimes(1); expect(mailer.send).toHaveBeenCalledWith("a@b.com"); }); });

Typed Mocks with jest.Mocked<T>

For a mock that must track every method of a real implementation, jest.Mocked<T> gives you full autocomplete and type checking on the mock itself:

import { PostgresDatabase } from "./postgres-database"; jest.mock("./postgres-database"); const MockedDatabase = PostgresDatabase as jest.MockedClass<typeof PostgresDatabase>; it("calls save with the right shape", () => { const instance = new MockedDatabase(); instance.save.mockImplementation(() => {}); // ✅ typed — no `any` const service = new OrderService(instance, fakeMailer()); service.placeOrder({ id: "1", email: "a@b.com" }); expect(instance.save).toHaveBeenCalled(); });

Hand-Rolled Mock

A plain object matching the interface + jest.fn() per method — no magic, fully typed by TypeScript's structural typing.

jest.Mocked<T>

Wraps an existing class's type so every mocked method keeps its original signature and return type.

Type-Safe Test Helpers: Object Builders

Constructing a full Order object by hand in every test is noisy. A typed builder with sensible defaults keeps tests focused on what actually varies:

A Typed Test Data Builder

interface Order { id: string; email: string; total: number; items: string[]; } function buildOrder(overrides: Partial<Order> = {}): Order { return { id: "order-1", email: "test@example.com", total: 100, items: ["widget"], ...overrides, }; } // Tests only specify what matters for that case: const freeOrder = buildOrder({ total: 0 }); const bulkOrder = buildOrder({ items: ["widget", "gadget", "gizmo"] });

Partial<Order> — the mapped type from Course 2 — is exactly what makes overrides optional on every field at once, without writing it out by hand.

🔗 Integration Testing

Unit tests isolate one piece with mocks. Integration tests wire real pieces together and check the seams — often using the DI container itself:

Unit Test vs Integration Test

Unit Test

One class, dependencies mocked. Fast, pinpoints exactly what broke.

const service = new OrderService(mockDb, mockMailer);
Integration Test

Real container, real wiring, maybe an in-memory database. Slower, catches wiring mistakes.

const container = buildTestContainer(); // real graph, fake infra const service = container.resolve(OrderServiceToken);
function buildTestContainer(): Container { const container = new Container(); container.register(DatabaseToken, () => new InMemoryDatabase(), "singleton"); container.register(MailerToken, () => new FakeMailer(), "singleton"); container.register(OrderServiceToken, () => new OrderService(container.resolve(DatabaseToken), container.resolve(MailerToken)) ); return container; } it("places an order end-to-end through the real container", () => { const service = buildTestContainer().resolve(OrderServiceToken); service.placeOrder(buildOrder()); // assert against the in-memory database's state, not a mock call count });

💻 Coding Challenges

Challenge 1: Test a Pure Function

Write a formatCurrency(amount: number): string function and a Jest test suite covering zero, negative numbers, and large values.

Goal: Practice describe/it structure and multiple expect assertions per case.

→ Solution

Challenge 2: Mock an Interface Dependency

Take the OrderService from Chapter 1 and write a unit test that mocks both Database and Mailer, asserting each is called with the correct arguments.

Goal: Practice hand-rolled typed mocks without a mocking library beyond jest.fn().

→ Solution

Challenge 3: Build a Test Data Builder

Write a buildUser(overrides?: Partial<User>): User helper with sensible defaults, then use it in two different test cases that each override a different field.

Goal: Practice Partial<T> and the spread operator for flexible, type-safe test fixtures.

→ Solution

⚠️ Gotcha: Over-Mocking Hides Real Bugs

It's tempting to mock everything a class touches, but a test built entirely of mocks can pass even when the real integration is broken — every mock just returns whatever you told it to. Keep a handful of integration tests that use real (or in-memory) implementations through the actual container, so wiring mistakes get caught somewhere.

🎯 What's Next

With dependencies injectable and tests in place, the next chapter moves outward: API Design & Documentation — typing request/response shapes, REST conventions, and generating OpenAPI/Swagger docs straight from your TypeScript types.