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 Jest —
describe,it, andexpectcome 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 tinyassert/describe/ithelpers to stand in for the framework, and the build task uses Node's built-innode: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?
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");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:
- Drop the cent padding in
format. Interpolate the raw remainder instead of thepadStart-padded string. Predict which check fails first before running. Whole-cent values still look right, but formatting five cents now yields$0.5instead 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. - Skip the rounding in
fromDollars. Change it toreturn { cents: dollars * 100 };. Predict which check fails first before running. The whole-dollar case (fromDollars(3)) still passes, butfromDollars(1.1)returns{ cents: 110.00000000000001 }, so TODO 1's second check fails first —deepStrictEqualreports the actual cents is not110. 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.
const cents = 425;const price = `$${dollars}.${remainder}`;const remainder = String(cents - dollars * 100).padStart(2, "0");console.log(price);const dollars = Math.floor(cents / 100);
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
IsEqualfor 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.