Skip to lesson

learningtypescript.org / advanced / 12-testing · lesson 22 of 25

TL;DR

Write type-safe tests in TypeScript with assertion patterns, mocking, and test organization for reliable, maintainable test suites.

Key concepts

  • TypeScript testing
  • type-safe tests
  • TypeScript mocking
  • unit testing TypeScript

Testing

TypeScript makes tests more reliable by catching type errors at compile time. You can type your mocks, assertions, and test utilities for better developer experience.

How this lesson runs: real projects run tests with a framework like Vitest or Jestdescribe, it, and expect come from the framework, and a runner reports pass/fail. This sandbox has none of that: it strips types and runs the file with plain Node. So the fences below hand-roll tiny assert/describe/it helpers to stand in for the framework, and the build task uses Node's built-in node:assert. The patterns — typed assertions, typed mocks, arranging tests — are exactly what you write against a real framework; only the harness is simulated here.

Assertion Patterns

Use TypeScript to write strongly-typed assertions.

// Type-safe assertion helpers
function assertEqual<T>(actual: T, expected: T, label: string): void {
  const pass = JSON.stringify(actual) === JSON.stringify(expected);
  console.log(`${pass ? "PASS" : "FAIL"}: ${label}`);
  if (!pass) {
    console.log(`  Expected: ${JSON.stringify(expected)}`);
    console.log(`  Actual:   ${JSON.stringify(actual)}`);
  }
}

function assertTrue(value: boolean, label: string): void {
  console.log(`${value ? "PASS" : "FAIL"}: ${label}`);
}

// Test a function
function add(a: number, b: number): number {
  return a + b;
}

assertEqual(add(2, 3), 5, "add(2, 3) should be 5");
assertEqual(add(-1, 1), 0, "add(-1, 1) should be 0");
assertEqual(add(0, 0), 0, "add(0, 0) should be 0");

// Test with objects
interface User {
  id: number;
  name: string;
  email: string;
}

function createUser(name: string, email: string): User {
  return { id: Math.floor(Math.random() * 1000), name, email };
}

const user = createUser("Alice", "alice@example.com");
assertEqual(user.name, "Alice", "user name should be Alice");
assertEqual(user.email, "alice@example.com", "user email should match");
assertTrue(user.id > 0, "user id should be positive");

Mocking with Types

Type-safe mocks ensure your test doubles match the real interface.

// Define interfaces for dependencies
interface EmailService {
  send(to: string, subject: string, body: string): Promise<boolean>;
  getStatus(messageId: string): Promise<string>;
}

interface Logger {
  info(message: string): void;
  error(message: string): void;
}

// Create typed mocks
function createMockEmailService(): EmailService & { calls: string[][] } {
  const calls: string[][] = [];
  return {
    calls,
    async send(to: string, subject: string, body: string) {
      calls.push([to, subject, body]);
      return true;
    },
    async getStatus(_messageId: string) {
      return "delivered";
    }
  };
}

function createMockLogger(): Logger & { messages: string[] } {
  const messages: string[] = [];
  return {
    messages,
    info(message: string) { messages.push(`[INFO] ${message}`); },
    error(message: string) { messages.push(`[ERROR] ${message}`); }
  };
}

// Service under test
class NotificationService {
  constructor(private email: EmailService, private logger: Logger) {}

  async notify(user: string, message: string): Promise<boolean> {
    this.logger.info(`Sending notification to ${user}`);
    const result = await this.email.send(user, "Notification", message);
    if (result) {
      this.logger.info("Notification sent successfully");
    } else {
      this.logger.error("Failed to send notification");
    }
    return result;
  }
}

// Test with mocks
async function testNotify() {
  const mockEmail = createMockEmailService();
  const mockLogger = createMockLogger();
  const service = new NotificationService(mockEmail, mockLogger);

  const result = await service.notify("alice@test.com", "Hello!");

  console.log(`Result: ${result}`);
  console.log(`Email calls: ${mockEmail.calls.length}`);
  console.log(`Email sent to: ${mockEmail.calls[0][0]}`);
  console.log(`Logger messages: ${mockLogger.messages.join(", ")}`);
}

testNotify();

Recall

Without scrolling up: createMockEmailService returns the real EmailService interface intersected with a test-only calls array for inspection. Testing leans on the utility types from 10-utility-types to keep mock shapes honest. If a mock only needed to implement the two read methods of a larger interface, which utility type would you reach for, and why does that keep the mock in sync?

Continue learning

Test Organization

Structure tests with describe/it patterns using simple helpers.

// Minimal test runner
type TestFn = () => void | Promise<void>;

const results: { name: string; passed: boolean; error?: string }[] = [];

function describe(name: string, fn: () => void): void {
  console.log(`\n${name}`);
  fn();
}

function it(name: string, fn: TestFn): void {
  try {
    fn();
    results.push({ name, passed: true });
    console.log(`  PASS: ${name}`);
  } catch (e: unknown) {
    const message = e instanceof Error ? e.message : String(e);
    results.push({ name, passed: false, error: message });
    console.log(`  FAIL: ${name}${message}`);
  }
}

function expect<T>(actual: T) {
  return {
    toBe(expected: T) {
      if (actual !== expected) throw new Error(`Expected ${expected}, got ${actual}`);
    },
    toEqual(expected: T) {
      if (JSON.stringify(actual) !== JSON.stringify(expected))
        throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
    },
    toBeTruthy() {
      if (!actual) throw new Error(`Expected truthy, got ${actual}`);
    }
  };
}

// Tests
function capitalize(str: string): string {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

function slugify(str: string): string {
  return str.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
}

describe("capitalize", () => {
  it("capitalizes first letter", () => {
    expect(capitalize("hello")).toBe("Hello");
  });
  it("handles empty string", () => {
    expect(capitalize("")).toBe("");
  });
  it("keeps already capitalized", () => {
    expect(capitalize("Hello")).toBe("Hello");
  });
});

describe("slugify", () => {
  it("converts spaces to hyphens", () => {
    expect(slugify("hello world")).toBe("hello-world");
  });
  it("removes special characters", () => {
    expect(slugify("Hello World!")).toBe("hello-world");
  });
  it("lowercases everything", () => {
    expect(slugify("TypeScript")).toBe("typescript");
  });
});

// Summary
const passed = results.filter(r => r.passed).length;
console.log(`\n${passed}/${results.length} tests passed`);

Type Testing

Verify that your types work correctly at compile time.

// Type-level assertions using conditional types
type IsEqual<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
type Assert<T extends true> = T;

// Test utility types
interface Todo {
  id: number;
  title: string;
  done: boolean;
}

// Verify Pick works as expected
type Picked = Pick<Todo, "id" | "title">;
type _test1 = Assert<IsEqual<Picked, { id: number; title: string }>>;

// Verify Omit works
type WithoutId = Omit<Todo, "id">;
type _test2 = Assert<IsEqual<WithoutId, { title: string; done: boolean }>>;

// Test your own utility types
type Nullable<T> = { [K in keyof T]: T[K] | null };
type NullableTodo = Nullable<Todo>;
type _test3 = Assert<IsEqual<NullableTodo, { id: number | null; title: string | null; done: boolean | null }>>;

// Runtime verification that our types compile
console.log("All type tests pass (compilation succeeded)");

// Runtime example of Nullable
const partial: NullableTodo = { id: 1, title: null, done: false };
console.log(`Todo: id=${partial.id}, title=${partial.title ?? "(none)"}, done=${partial.done}`);

// Practical: type-safe event map
type EventMap = {
  click: { x: number; y: number };
  keypress: { key: string };
  submit: { data: Record<string, string> };
};

type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];

function emit<E extends EventName>(event: E, payload: EventPayload<E>): void {
  console.log(`Event: ${event}, payload: ${JSON.stringify(payload)}`);
}

emit("click", { x: 10, y: 20 });
emit("keypress", { key: "Enter" });
emit("submit", { data: { name: "Alice" } });

Predict

The Assert<IsEqual<...>> line below is a compile-time type test — like the ones in Type Testing above — but it makes a FALSE claim: it says Pick<Todo, 'id'> equals an object type with both id and title, when picking only 'id' yields an object type with just id. This exact code is checked with tsc --strict AND run by the tsx runner. Predict BOTH: what does tsc report, and what does the runner print?

type IsEqual<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
type Assert<T extends true> = T;

interface Todo { id: number; title: string; }

type Picked = Pick<Todo, "id">;
type _check = Assert<IsEqual<Picked, { id: number; title: string }>>;

console.log("reached the log");
Continue learning

Try It Yourself

You've written typed assertions, typed mocks, and compile-time type tests. Now put them to work: implement three small money units so a fixed suite of node:assert checks passes — the framework-free equivalent of a Vitest spec (see How this lesson runs at the top). Each function is stubbed with only its signature; the spec is in the prompt and the Assertion Patterns section. Run it as-is to see the first failure, implement that, then work down until it prints All checks passed.

Build

Finish the build. Three functions are stubbed with only their signatures; the assert checks below them are the test suite and fail until each returns the right value. Spec: fromDollars(dollars) converts a dollar amount to a Money holding whole cents, rounding to the nearest cent so floating-point noise (0.1 * 100) never leaks through. add(a, b) returns a new Money whose cents is the sum of both. format(money) renders the Money as a dollar string with exactly two cent digits — $4.25, and $0.05 for five cents. Checks run top to bottom, so the first failure is TODO 1 — implement it first, then work down.

import assert from "node:assert";

// A tiny test suite (the runner has no Vitest/Jest — node:assert only). The
// checks are locked; you implement the three units under test.
type Money = { cents: number };

// TODO 1
function fromDollars(dollars: number): Money {
return { cents: 0 };
}

// TODO 2
function add(a: Money, b: Money): Money {
return { cents: 0 };
}

// TODO 3
function format(money: Money): string {
return "";
}

// --- Build checks: these must all pass. Do not edit below this line. ---
assert.deepStrictEqual(fromDollars(3), { cents: 300 }, "TODO 1: fromDollars should convert dollars to whole cents");
assert.deepStrictEqual(fromDollars(1.1), { cents: 110 }, "TODO 1: fromDollars should round away float imprecision (1.1 * 100 is not exactly 110)");

assert.deepStrictEqual(add({ cents: 150 }, { cents: 275 }), { cents: 425 }, "TODO 2: add should sum the cents of both amounts");
assert.deepStrictEqual(add(fromDollars(2), fromDollars(0.5)), { cents: 250 }, "TODO 2: add should compose with fromDollars");

assert.strictEqual(format({ cents: 425 }), "$4.25", "TODO 3: format should render dollars and two-digit cents");
assert.strictEqual(format({ cents: 5 }), "$0.05", "TODO 3: format should pad single-digit cents");

console.log("All checks passed.");
console.log(format(add(fromDollars(19.99), fromDollars(0.01))));

Expected output: All checks passed. $20.00

Continue learning

Once it passes, try two variations and predict each before running:

  1. Drop the cent padding in format. Interpolate the raw remainder instead of the padStart-padded string. Predict which check fails first before running. Whole-cent values still look right, but formatting five cents now yields $0.5 instead of $0.05, so TODO 3's second check fails first — AssertionError: TODO 3: format should pad single-digit cents, '$0.5' !== '$0.05'. Money formatting is exactly where an unpadded field silently corrupts every under-ten-cent value.
  2. Skip the rounding in fromDollars. Change it to return { cents: dollars * 100 };. Predict which check fails first before running. The whole-dollar case (fromDollars(3)) still passes, but fromDollars(1.1) returns { cents: 110.00000000000001 }, so TODO 1's second check fails first — deepStrictEqual reports the actual cents is not 110. This is the float-imprecision bug the integer-cents pattern exists to prevent, and the test is what catches it.

Arrange the code

Reassemble a program that formats an integer-cents amount as a dollar string: it splits the cents into whole dollars and a padded remainder, assembles the price, and logs it. The lines are shuffled; each const consumes the binding above it, so only one order runs top-to-bottom and prints $4.25.

  1. const cents = 425;
  2. const price = `$${dollars}.${remainder}`;
  3. const remainder = String(cents - dollars * 100).padStart(2, "0");
  4. console.log(price);
  5. const dollars = Math.floor(cents / 100);
Continue learning

Key Takeaways

  • Type your assertions to catch mismatches at compile time
  • Mock interfaces, not implementations — keeps mocks in sync with real code
  • Use conditional types like IsEqual for compile-time type testing
  • Organize tests with describe/it patterns for readability
  • Generic test helpers (expect<T>) provide type safety across all tests
  • Test both runtime behavior and type-level correctness

Pro Tip: When writing mocks, define the mock as InterfaceName & { calls: ... } to get both the correct interface shape and test inspection capabilities. This way, if the interface changes, your mock will fail to compile rather than silently becoming stale.

Next Steps

You know how to test TypeScript code with typed assertions, mocks, and compile-time checks. Next, you'll meet decorators — the @decorator syntax that adds behavior to classes and their members, the machinery behind frameworks like Angular and NestJS.

Ready to continue? Head to Decorators!

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.