Skip to editor content
learningtypescript.orglesson 22 of 25

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 Jest — describe, 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.

What You'll Learn

By the end of this lesson you will build three small money units and a locked suite of checks that proves them — writing the assertions, the typed test double the code under test depends on, and the type-level checks that only the compiler can run. The skill you are really practising is deciding which lane a given claim belongs in, because TypeScript gives you two and they catch different things.

This lesson is not tied to a capstone milestone; it is here so the code you write in every other lesson can be held to a standard you can run. You arrive able to model a domain with interfaces and unions (Interfaces and Types), derive shapes from them with Pick and Partial (Utility Types), and return failures as values rather than throwing them (Error Handling) — all three are what makes a unit small enough to test in the first place. What is new is the discipline of stating the expected result before the code exists, and of keeping each test's state its own.

Assertion Patterns

Use TypeScript to write strongly-typed assertions.

The model that predicts which lane catches what: a check belongs to the type lane if the compiler can decide it from declarations alone, and to the runtime lane if deciding it requires a value. "Does Pick<Todo, 'id'> have a title?" needs no value — the compiler answers it, and the answer is fixed before the program starts. "Does fromDollars(1.1) give 110 cents?" needs the value, because the answer depends on arithmetic that only happens at run time. Apply the model to a case you have not seen and it still decides: "does this mock implement every method the service calls?" is answerable from declarations, so a type error catches it; "does this mock still hold the previous test's recorded calls?" is a question about a value in memory, so only running it can tell you. That is why every lesson in this track has two lanes, and why a bug that lives on one side is invisible from the other.

// 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)}`);
  }
}

// Why <T> on both sides instead of (actual: unknown, expected: unknown)? The
// shared T is what makes assertEqual("5", 5, ...) a compile error rather than a
// silently failing test: with unknown, every mismatch would only surface at run
// time, and a test that fails for the wrong reason is worse than no test.
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");

// Your turn: createUser generates a RANDOM id, so no assertEqual on it can pass
// reliably. Write the one assertion that is still deterministic about the id, and
// say why assertTrue is the right helper for it and assertEqual is not.

That last comment is the shape worth pausing on. assertEqual(user.id, 42, ...) looks like a test and is really a coin flip — Math.random() decides it. The assertion you can make is a property that holds for every run (user.id > 0), and choosing the property over the value is most of what separates a test that catches regressions from one that fails on Tuesdays. Deciding which of the two you can write requires knowing what the function does with values — the runtime lane again.

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();

A typed mock keeps the shape honest. It does nothing at all about the mock's state, and that is the failure this next block seeds. Commit a hypothesis before you touch the code.

Debug

Two tests share one mock mailer. The first test invites one person and passes. The second invites two people and fails with a count nobody wrote — the mock reports three recipients when the test sent two. The types are correct and the compiler is silent, so the answer is not in the annotations. Commit a hypothesis about what mailer.sent is holding when the second test reads it, then fix it so both tests pass and the program reports two passing tests.

import assert from "node:assert";

interface Mailer {
send(to: string): boolean;
}

type MailerMock = Mailer & { sent: string[] };

// The test double every test below uses.
function mailerMock(): MailerMock {
return {
	sent: [],
	send(to: string) {
		this.sent.push(to);
		return true;
	},
};
}

// Built once, up here, so every test can reach it.
const mailer = mailerMock();

function inviteAll(m: Mailer, people: string[]): number {
let n = 0;
for (const p of people) {
	if (m.send(p)) n++;
}
return n;
}

const log: string[] = [];

function test(name: string, fn: () => void): void {
fn();
log.push(name);
}

test("invites one person", () => {
inviteAll(mailer, ["ada@x.co"]);
assert.deepStrictEqual(mailer.sent, ["ada@x.co"], "expected exactly one recipient, got " + JSON.stringify(mailer.sent));
});

test("invites two people", () => {
inviteAll(mailer, ["bob@x.co", "cleo@x.co"]);
assert.strictEqual(mailer.sent.length, 2, "expected 2 recipients in this test, got " + mailer.sent.length);
});

console.log("passed:", log.length);
for (const name of log) console.log("ok -", name);

Expected output:

passed: 2
ok - invites one person
ok - invites two people

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`);

Those hand-rolled helpers exist to show the shape. Node ships a real test runner as well, and it does run here — node:test needs no install and no framework, so this fence is graded by an actual runner rather than by counting console lines:

import { test } from "node:test";
import assert from "node:assert";

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

test("converts spaces to hyphens", () => {
  assert.strictEqual(slugify("hello world"), "hello-world");
});

test("removes special characters", () => {
  assert.strictEqual(slugify("Hello World!"), "hello-world");
});

test("lowercases everything", () => {
  assert.strictEqual(slugify("TypeScript"), "typescript");
});

The output is TAP: an ok N - name line per passing test and a # fail 0 summary. Two things are worth knowing before you rely on it. First, the process exits nonzero when any test fails — that exit code is what a CI pipeline reads, and it is the difference between a suite that reports and a suite that merely prints. Second, describe/it from a framework map onto this directly; only the import line changes. Change one expected value above to something wrong and rerun: the ok becomes not ok, the summary reports # fail 1, and the run goes red.

Type Testing

Verify that your types work correctly at compile time.

The two lanes split here, and the split is the point. A type-level assertion is checked by tsc and erased before anything runs — so the Run button below can never report on it. That is why this first fence is no-run: pressing Run on it would strip every line except a console.log and exit 0, which would tell you the type tests "passed" even when they are wrong.

// no-run: every line here is a TYPE declaration. tsx erases all of them and the
// runner would exit 0 whether these assertions hold or not. Only tsc checks them.
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;
  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 }>>;

Run under tsc --strict, that file is silent — silence is the pass. Break one deliberately, by claiming Pick<Todo, "id" | "title"> also has done, and the compiler reports the failure:

error TS2344: Type 'false' does not satisfy the constraint 'true'.

That is the whole mechanism: IsEqual evaluates to false, and Assert<false> violates T extends true. The runtime shadow of that same broken file is nothing at all — no output, no error, exit 0, because there is no runtime left after erasure. A type test that is never given to tsc is not a test.

Runtime checks are the other lane, and they are the one this sandbox can grade. The fence below is the runnable half of the same material — Nullable<Todo> used as a real value, and a typed event map whose payload type is chosen by the event name:

type Nullable<T> = { [K in keyof T]: T[K] | null };
interface Todo {
  id: number;
  title: string;
  done: boolean;
}
type NullableTodo = Nullable<Todo>;

// 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" } });

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 the Utility Types lesson 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?

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");

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

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

These five lines are one test, unrolled: they arrange a mock mailer, act by inviting two people through it, read back what the mock recorded, build the report line, and log it. The lines are shuffled. Put them back in an order that runs - then answer the question the puzzle is really asking: which line could not have moved, and what does the runner do if you move it?

  1. console.log(report);
  2. const invited = ["ada@x.co", "bob@x.co"].filter((p) => mailer.send(p)).length;
  3. const report = `invited ${invited}: ${record}`;
  4. const mailer = { sent: [] as string[], send(to: string) { mailer.sent.push(to); return true; } };
  5. const record = mailer.sent.join(", ");

Transfer

This lesson ran the same idea in two places that look nothing alike. One is Assert<IsEqual<Picked, ...>>, checked by tsc, which produces no output at all when it passes. The other is assert.strictEqual(format({ cents: 5 }), '$0.05'), run by node, which also produces no output when it passes. Both are called assertions and both are silent on success. What is the invariant that carries from one setting to the other, and what actually changed?

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!

Next lesson

Decorators

Learn TypeScript decorators to add metadata and behavior to classes, methods, and properties. Used in Angular, NestJS, and TypeORM.

25 min