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.
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();
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" } });
Try It Yourself
// Build a test suite for a shopping cart
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
class ShoppingCart {
private items: CartItem[] = [];
add(item: Omit<CartItem, "quantity">, quantity = 1): void {
const existing = this.items.find(i => i.id === item.id);
if (existing) {
existing.quantity += quantity;
} else {
this.items.push({ ...item, quantity });
}
}
remove(id: string): void {
this.items = this.items.filter(i => i.id !== id);
}
total(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
count(): number {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
getItems(): ReadonlyArray<Readonly<CartItem>> {
return this.items;
}
}
// Test the cart
function assert(condition: boolean, message: string): void {
console.log(`${condition ? "PASS" : "FAIL"}: ${message}`);
}
const cart = new ShoppingCart();
assert(cart.total() === 0, "Empty cart has zero total");
assert(cart.count() === 0, "Empty cart has zero items");
cart.add({ id: "a", name: "Laptop", price: 999 });
assert(cart.count() === 1, "One item after add");
assert(cart.total() === 999, "Total is item price");
cart.add({ id: "a", name: "Laptop", price: 999 });
assert(cart.count() === 2, "Quantity increases for same item");
assert(cart.total() === 1998, "Total doubles with quantity");
cart.add({ id: "b", name: "Mouse", price: 29 }, 3);
assert(cart.count() === 5, "Total count includes new item quantity");
assert(cart.total() === 2085, "Total includes all items");
cart.remove("a");
assert(cart.count() === 3, "Count decreases after remove");
assert(cart.total() === 87, "Total updates after remove");
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 — now it's time to apply those skills to the most popular UI framework. Next, you'll learn how to type React components, props, hooks, events, and context for a fully type-safe frontend.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.