Skip to main content
TypeScript intermediate Lesson 16 of 21

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: "[email protected]",
    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: "[email protected]" });

    // Verify it was called with the right shape — partial match using expect.objectContaining
    expect(mockSend).toHaveBeenCalledWith(
      expect.objectContaining({ to: "[email protected]" })
    );
  });
});

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: "[email protected]" };
    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
  });
});

Frequently Asked Questions

Do I need ts-jest or can I use Babel to run TypeScript tests?
Both work. ts-jest uses the TypeScript compiler and performs type checking during tests. Babel (via @babel/preset-typescript) strips types without checking them — tests run faster but won't catch type errors. Use ts-jest for full type safety, Babel if build speed is the priority.
How do I mock a TypeScript module with Jest?
Use jest.mock() with a factory function. For full type safety on the mock, cast the mocked module with jest.mocked() (available in Jest 27+) which gives you typed access to mock methods.
What is type-level testing?
Type-level testing verifies that your types behave correctly — that a function returns the right type, that a generic constraint works, or that an illegal type is rejected. Libraries like tsd and expect-type let you write these assertions as test files.