Testing TypeScript
Set up Jest with TypeScript using ts-jest, write typed mocks, and perform type-level testing with expect-type and tsd.
Setting Up Jest with TypeScript
Running TypeScript tests requires a transform step that converts .ts files to JavaScript before Jest executes them. ts-jest handles this while keeping the TypeScript compiler in the loop, so type errors in your test files surface as test failures rather than silently passing. This matters because tests that don’t type-check aren’t actually verifying your types — they’re just testing JavaScript with a TypeScript veneer.
npm install -D jest ts-jest @types/jest typescript
Create jest.config.ts:
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest", // use ts-jest to transform .ts files
testEnvironment: "node",
roots: ["<rootDir>/src"],
testMatch: ["**/__tests__/**/*.ts", "**/*.test.ts", "**/*.spec.ts"],
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"],
coverageDirectory: "coverage",
};
export default config;
Add scripts to package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
Writing Typed Tests
TypeScript’s type system applies to test files just like application files. The benefit is that your test data must conform to your types — if you rename a field or change a return type, tests that reference the old shape fail to compile before you even run them. This turns the type checker into a free layer of test maintenance.
// src/utils/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
// src/utils/math.test.ts
import { add, clamp } from "./math";
describe("add", () => {
it("adds two positive numbers", () => {
expect(add(1, 2)).toBe(3);
});
it("handles negative numbers", () => {
expect(add(-1, -2)).toBe(-3);
expect(add(-1, 1)).toBe(0);
});
});
describe("clamp", () => {
it("returns value when within range", () => {
expect(clamp(5, 0, 10)).toBe(5);
});
it("clamps to minimum", () => {
expect(clamp(-5, 0, 10)).toBe(0);
});
it("clamps to maximum", () => {
expect(clamp(15, 0, 10)).toBe(10);
});
});
Typing Test Fixtures
Repeating object literals across tests is noisy and fragile — change a required field and you have to update dozens of test files. Factory functions with Partial<T> overrides solve this elegantly: each test gets a sensible default object and overrides only the fields that matter for that specific case. TypeScript ensures the base object and every override satisfies the full type, so factories stay in sync with your types automatically.
// src/__tests__/fixtures.ts
import type { User, Post } from "../types";
// Returns a complete User with sensible defaults — override only what matters for the test
export function makeUser(overrides: Partial<User> = {}): User {
return {
id: 1,
name: "Alice",
email: "alice@example.com",
role: "user",
createdAt: new Date("2024-01-01"),
...overrides,
};
}
export function makePost(overrides: Partial<Post> = {}): Post {
return {
id: 1,
title: "Test Post",
body: "Content here",
authorId: 1,
publishedAt: new Date("2024-01-01"),
...overrides,
};
}
// In tests — override only the fields the test cares about
import { makeUser } from "./fixtures";
it("returns user profile", async () => {
const user = makeUser({ role: "admin" }); // everything else uses defaults
const profile = buildProfile(user);
expect(profile.isAdmin).toBe(true);
});
Mocking Modules
Mocking replaces a real dependency with a controlled substitute that you can program with specific return values. This lets you test code in isolation — you don’t need a real database or a live API to test a controller. jest.mocked() (Jest 27+) is the key TypeScript addition here: it casts the mocked module to a typed mock, giving you autocomplete on .mockResolvedValue(), .mockRejectedValue(), and all other mock methods.
// src/services/userService.ts
export async function getUserById(id: number): Promise<User | null> {
const res = await fetch(`/api/users/${id}`);
if (res.status === 404) return null;
return res.json();
}
// src/__tests__/userController.test.ts
import { getUserById } from "../services/userService";
import { handleGetUser } from "../controllers/userController";
// Replace the entire module with auto-mocked stubs
jest.mock("../services/userService");
// jest.mocked gives typed access to mock methods — no `as jest.Mock` casting needed
const mockGetUserById = jest.mocked(getUserById);
describe("handleGetUser", () => {
beforeEach(() => {
jest.clearAllMocks(); // reset call history between tests
});
it("returns user when found", async () => {
const user = makeUser();
mockGetUserById.mockResolvedValue(user); // program the return value
const result = await handleGetUser(1);
expect(result.status).toBe(200);
expect(result.body).toEqual(user);
expect(mockGetUserById).toHaveBeenCalledWith(1);
});
it("returns 404 when user not found", async () => {
mockGetUserById.mockResolvedValue(null); // simulate not found
const result = await handleGetUser(999);
expect(result.status).toBe(404);
});
it("returns 500 on service error", async () => {
mockGetUserById.mockRejectedValue(new Error("DB connection failed")); // simulate failure
const result = await handleGetUser(1);
expect(result.status).toBe(500);
});
});
Mocking Classes
When a dependency is a class rather than a module of functions, you mock the class itself and then control the behavior of its instance methods. This pattern is common when testing code that instantiates a service class internally — you swap the real class for a version whose methods return whatever the test needs.
// Mock an entire class — all methods become jest.fn() stubs
jest.mock("../services/EmailService");
import { EmailService } from "../services/EmailService";
const MockEmailService = jest.mocked(EmailService);
describe("UserRegistration", () => {
it("sends welcome email on registration", async () => {
// Control what the instance method does
const mockSend = jest.fn().mockResolvedValue(undefined);
MockEmailService.prototype.send = mockSend;
await registerUser({ name: "Alice", email: "alice@example.com" });
// Verify it was called with the right shape — partial match using expect.objectContaining
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({ to: "alice@example.com" })
);
});
});
Typed Spies
Spies let you observe calls to real methods without replacing the implementation entirely. Unlike mocks, spies wrap the original function — useful when you want to verify that a method was called with specific arguments while still letting it run its actual logic. jest.spyOn is fully typed: it infers the method signature from the object, so TypeScript catches mistyped argument expectations at compile time.
class Calculator {
add(a: number, b: number): number { return a + b; }
multiply(a: number, b: number): number { return a * b; }
}
describe("Calculator", () => {
let calc: Calculator;
beforeEach(() => {
calc = new Calculator();
});
it("calls multiply correctly", () => {
// Spy wraps the real method — it still runs
const spy = jest.spyOn(calc, "multiply");
calc.multiply(3, 4);
expect(spy).toHaveBeenCalledWith(3, 4);
expect(spy).toHaveReturnedWith(12);
});
it("can override implementation", () => {
// mockReturnValue replaces the implementation for this test only
jest.spyOn(calc, "add").mockReturnValue(100);
const result = calc.add(1, 2);
expect(result).toBe(100); // mocked value, not 1 + 2
});
});
Testing Async Code
Async code has historically been tricky to test, but modern Jest handles it cleanly with native async/await support. The key is always returning a promise — either by making the test function async, or by returning the promise directly. Forgetting this means Jest considers the test passed before the async work finishes.
// Testing that a promise resolves with the right value
it("resolves with user data", async () => {
const user = await getUser(1);
expect(user).toMatchObject({ id: 1, name: "Alice" });
});
// Testing that a promise rejects with the right error
it("throws on invalid id", async () => {
await expect(getUser(-1)).rejects.toThrow("Invalid user ID");
});
// Using done callback for callback-based async (legacy APIs)
it("calls callback with result", (done) => {
getUserWithCallback(1, (err, user) => {
expect(err).toBeNull();
expect(user?.name).toBe("Alice");
done(); // signal test completion
});
});
Type-Level Testing with expect-type
Type-level testing verifies that your generic types, utility types, and overloads resolve to exactly the right types — not just that the runtime behavior is correct. This catches regressions where a refactor changes a return type or a generic stops inferring correctly. expect-type integrates with Jest so type assertions live alongside runtime assertions in the same test files.
npm install -D expect-type
import { expectTypeOf } from "expect-type";
import { getUser, createUser } from "../services/userService";
describe("type-level tests", () => {
it("getUser returns User or null", () => {
// Fails at compile time if getUser's return type changes
expectTypeOf(getUser).returns.resolves.toEqualTypeOf<User | null>();
});
it("createUser accepts CreateUserDto", () => {
// Catches signature changes that would break callers
expectTypeOf(createUser).parameter(0).toEqualTypeOf<CreateUserDto>();
});
it("User has required fields", () => {
expectTypeOf<User>().toHaveProperty("id").toBeNumber();
expectTypeOf<User>().toHaveProperty("email").toBeString();
});
});
Testing with Vitest (Modern Alternative)
Vitest is a Jest-compatible test runner built on Vite with native TypeScript support — no ts-jest transform needed. It’s significantly faster for projects already using Vite because it reuses the same transform pipeline, and its API is close enough to Jest that migration is usually a matter of changing imports.
npm install -D vitest
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true, // enables describe/it/expect without imports (like Jest)
environment: "node",
},
});
// Import from vitest instead of relying on Jest globals
import { describe, it, expect, vi, beforeEach } from "vitest";
import { expectTypeOf } from "expect-type";
describe("userService", () => {
it("returns typed user", async () => {
const user = await getUser(1);
// Combine runtime and type-level assertions in one test
expectTypeOf(user).toEqualTypeOf<User | null>();
expect(user?.name).toBe("Alice");
});
});
Testing Custom Type Guards
Type guards require two kinds of testing: runtime tests that verify the function returns true for valid inputs and false for invalid ones, and type-level tests that verify TypeScript actually narrows the type in the truthy branch. Testing only one side leaves gaps — a guard could return the right boolean but fail to narrow, or narrow correctly but return wrong results for edge cases.
import { isUser, isPost } from "../utils/typeGuards";
describe("isUser", () => {
it("accepts valid user objects", () => {
const valid = { id: 1, name: "Alice", email: "a@example.com" };
expect(isUser(valid)).toBe(true);
// After the guard, TypeScript should narrow valid to User
if (isUser(valid)) {
expectTypeOf(valid).toEqualTypeOf<User>(); // type-level assertion
}
});
it("rejects invalid objects", () => {
expect(isUser(null)).toBe(false);
expect(isUser({ id: "string-id" })).toBe(false); // id should be number
expect(isUser({ id: 1, name: 42 })).toBe(false); // name should be string
});
});