Testing TypeScript
Typing mocks and test helpers, narrowing unknown caught errors, and a complete typed Vitest/Jest test file.
Testing TypeScript is testing JavaScript, plus types
Both Jest and Vitest (a newer, Vite-native test runner with a near-identical API and typically faster startup, common in modern frontend projects) run TypeScript test files directly — Vitest natively, Jest via ts-jest or a Babel/SWC transform. Everything from this app's JavaScript testing page (describe, test, expect, mocking, beforeEach) applies unchanged; what TypeScript adds is compile-time checking of your test code itself, and the ability to precisely type mocks and test helpers so a typo in a mock's shape is caught before the test ever runs.
npm install --save-dev vitest
{
"scripts": {
"test": "vitest run"
}
}
Vitest's API is intentionally close to Jest's — describe/it/expect behave the same way, and most of what follows applies identically if your project uses Jest with ts-jest instead.
The code under test
// bankAccount.ts
export class InsufficientFundsError extends Error {
constructor(public readonly balance: number, public readonly amount: number) {
super(`Cannot withdraw ${amount}: balance is only ${balance}`);
this.name = "InsufficientFundsError";
}
}
export class BankAccount {
#balance: number;
constructor(initialBalance: number = 0) {
this.#balance = initialBalance;
}
get balance(): number {
return this.#balance;
}
deposit(amount: number): void {
if (amount <= 0) throw new Error("Deposit amount must be positive");
this.#balance += amount;
}
withdraw(amount: number): void {
if (amount > this.#balance) {
throw new InsufficientFundsError(this.#balance, amount);
}
this.#balance -= amount;
}
}
// userService.ts
export interface User {
id: number;
name: string;
}
export interface HttpClient {
get(url: string): Promise<{ ok: boolean; json(): Promise<unknown> }>;
}
export async function fetchUser(id: number, http: HttpClient): Promise<User> {
const response = await http.get(`/users/${id}`);
if (!response.ok) {
throw new Error(`User ${id} not found`);
}
return (await response.json()) as User;
}
A typed test file
// bankAccount.test.ts
import { describe, test, expect, beforeEach } from "vitest";
import { BankAccount, InsufficientFundsError } from "./bankAccount.js";
describe("BankAccount", () => {
let account: BankAccount;
beforeEach(() => {
account = new BankAccount(100);
});
describe("deposit", () => {
test("increases the balance", () => {
account.deposit(50);
expect(account.balance).toBe(150);
});
test("rejects a non-positive amount", () => {
expect(() => account.deposit(0)).toThrowError("must be positive");
});
});
describe("withdraw", () => {
test("decreases the balance", () => {
account.withdraw(40);
expect(account.balance).toBe(60);
});
test("throws a typed InsufficientFundsError with structured data", () => {
expect(() => account.withdraw(500)).toThrow(InsufficientFundsError);
try {
account.withdraw(500);
} catch (error) {
// narrowing the caught error before accessing its custom fields
expect(error).toBeInstanceOf(InsufficientFundsError);
if (error instanceof InsufficientFundsError) {
expect(error.balance).toBe(100);
expect(error.amount).toBe(500);
}
}
});
});
});
catch (error) gives error the type unknown in strict TypeScript (never assume it's an Error by default — anything can technically be thrown), which is why the test narrows it with instanceof InsufficientFundsError before reading .balance/.amount — the exact same narrowing discipline covered on the advanced-types page in this track, just applied inside a test.
Typing mocks precisely
A hand-written fake object implementing an interface is automatically checked against that interface — if HttpClient changes shape, every mock implementing it fails to compile until updated, catching a stale test before it ever runs against the wrong shape:
// userService.test.ts
import { describe, test, expect, vi } from "vitest";
import { fetchUser, type HttpClient, type User } from "./userService.js";
function createMockHttpClient(response: { ok: boolean; body?: unknown }): HttpClient {
return {
get: vi.fn().mockResolvedValue({
ok: response.ok,
json: () => Promise.resolve(response.body),
}),
};
}
describe("fetchUser", () => {
test("returns a typed User on success", async () => {
const mockUser: User = { id: 1, name: "Ada" };
const httpClient = createMockHttpClient({ ok: true, body: mockUser });
const result = await fetchUser(1, httpClient);
expect(result).toEqual(mockUser);
expect(httpClient.get).toHaveBeenCalledWith("/users/1");
});
test("throws when the response is not ok", async () => {
const httpClient = createMockHttpClient({ ok: false });
await expect(fetchUser(1, httpClient)).rejects.toThrow("User 1 not found");
});
});
createMockHttpClient's return type annotation (: HttpClient) is doing real work here: if the mock's get method were accidentally typed to resolve to the wrong shape, or were missing entirely, this function would fail to compile — the test suite catches a mismatch between the mock and the real interface at compile time, before the test even runs, rather than the mock silently drifting out of sync with the real HttpClient shape over time.
A generic test helper
Generics are just as useful in test code as in application code — a small, reusable, fully-typed helper avoids repeating the same setup logic with slightly different types scattered across many test files:
function expectValidationError<T>(fn: () => T, expectedMessage: string): void {
expect(fn).toThrowError(expectedMessage);
}
test("deposit validation", () => {
expectValidationError(() => account.deposit(-10), "must be positive");
});
fn: () => T keeps the helper generic over whatever the wrapped function actually returns, so it can wrap any throwing call — a deposit, a withdrawal, a totally unrelated validation function elsewhere in the codebase — without needing a separate helper typed for each one.
Common mistakes
- Typing a caught error as
Error(or worse,any) instead of narrowing fromunknownwithinstanceof— TypeScript'scatchclause types the caught value asunknownby design (anything can be thrown), and skipping the narrowing step either produces a compile error understrictor silently reintroducesany's lack of safety. - Writing a mock object with no interface annotation at all (
const mockHttp = { get: vi.fn()... }with no: HttpClient) — it compiles today, but nothing catches the mock drifting out of sync with the real interface if that interface changes later. - Over-relying on
astype assertions to force a mock into shape instead of actually implementing the interface it stands in for — an assertion silences the compiler rather than proving the mock is actually correct. - Forgetting that Vitest and Jest matchers (
toThrow,toEqual,rejects) behave identically to their JavaScript-only usage — TypeScript adds compile-time checking around your test code, not new matcher behavior.
Interview questions
Q: Why does TypeScript type a caught error as unknown rather than Error, and how should a test handle that?
Because JavaScript technically permits throwing any value at all — a string, a number, a plain object, not just an Error instance — so assuming the caught value is always an Error would be unsound. A test (or any code) should narrow it first, typically with if (error instanceof SomeErrorClass), before accessing error-specific properties like .message or custom fields like .balance on a domain-specific error subclass.
Q: What real benefit does typing a mock against its interface give you over an untyped mock object?
If the real interface (say, HttpClient) later changes — a method renamed, a return shape altered — every mock implementing that interface fails to compile until it's updated to match, which surfaces a stale or incorrect mock immediately as a compile error rather than as a confusing runtime test failure (or worse, a mock that silently no longer represents the real dependency's actual shape, letting a bug slip through undetected).