Skip to content

07 · Testing with Jest + ts-jest

Writing TypeScript doesn't automatically mean your logic is correct — the compiler catches type mismatches, not wrong business logic. Jest is the most widely used JavaScript/TypeScript test runner, and ts-jest lets it run .ts files directly, type errors and all, without a separate build step.

Installing and configuring

npm install --save-dev typescript jest ts-jest @types/jest
npx ts-jest config:init

That last command generates a jest.config.js wired to the ts-jest preset:

// jest.config.js
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
};

ts-jest type-checks your test files using your project's real tsconfig.json — a test with a type error fails the same way a production file would, before it ever runs.

Your first typed test

// src/mathUtils.ts
export function add(a: number, b: number): number {
  return a + b;
}

export function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error("Cannot divide by zero");
  }
  return a / b;
}
// __tests__/mathUtils.test.ts
import { add, divide } from "../src/mathUtils";

test("adds two numbers", () => {
  expect(add(2, 3)).toBe(5);
});

test("throws on division by zero", () => {
  expect(() => divide(1, 0)).toThrow("Cannot divide by zero");
});
npx jest

# PASS  __tests__/mathUtils.test.ts
#   ✓ adds two numbers
#   ✓ throws on division by zero
#
# Test Suites: 1 passed, 1 total
# Tests:       2 passed, 2 total

Note the expect(() => divide(1, 0)).toThrow(...) pattern — you must wrap the throwing call in a function. expect(divide(1, 0)).toThrow(...) would throw immediately while evaluating the argument, before expect ever runs, and Jest wouldn't catch it as a test assertion at all.

Typed test fixtures and describe blocks

// src/user.ts
export interface User {
  id: number;
  name: string;
  active: boolean;
}

export function activeUsers(users: User[]): User[] {
  return users.filter((u) => u.active);
}
// __tests__/user.test.ts
import { activeUsers, User } from "../src/user";

describe("activeUsers", () => {
  const sample: User[] = [
    { id: 1, name: "Ada", active: true },
    { id: 2, name: "Bob", active: false },
    { id: 3, name: "Cy", active: true },
  ];

  it("returns only active users", () => {
    const result = activeUsers(sample);
    expect(result).toHaveLength(2);
    expect(result.map((u) => u.name)).toEqual(["Ada", "Cy"]);
  });

  it("returns an empty array when nobody is active", () => {
    const noneActive: User[] = sample.map((u) => ({ ...u, active: false }));
    expect(activeUsers(noneActive)).toEqual([]);
  });
});

sample: User[] gets full autocomplete and type checking in the test file, exactly as it would in production code — a typo like activ: true is a compile error in the test, not a silent bug that only shows up when the assertion mysteriously fails.

Typed test doubles with jest.fn()

jest.fn() creates a mock function. Left untyped, calls to it are effectively any; annotate it to keep the mock's shape honest:

// src/notifier.ts
export interface Notifier {
  send(to: string, message: string): Promise<boolean>;
}

export async function notifyAll(
  notifier: Notifier,
  recipients: string[],
  message: string
): Promise<number> {
  let successCount = 0;
  for (const to of recipients) {
    const sent = await notifier.send(to, message);
    if (sent) successCount += 1;
  }
  return successCount;
}
// __tests__/notifier.test.ts
import { notifyAll, Notifier } from "../src/notifier";

test("counts only successful sends", async () => {
  const mockSend = jest.fn<Promise<boolean>, [string, string]>();
  mockSend
    .mockResolvedValueOnce(true)
    .mockResolvedValueOnce(false)
    .mockResolvedValueOnce(true);

  const fakeNotifier: Notifier = { send: mockSend };

  const count = await notifyAll(fakeNotifier, ["a@x.com", "b@x.com", "c@x.com"], "Hi");

  expect(count).toBe(2);
  expect(mockSend).toHaveBeenCalledTimes(3);
  expect(mockSend).toHaveBeenNthCalledWith(1, "a@x.com", "Hi");
});

jest.fn<Promise<boolean>, [string, string]>() types the mock's return value and parameter tuple explicitly, so mockResolvedValueOnce(true) only accepts a boolean and a typo like mockResolvedValueOnce("yes") would be a compile error, not a test that silently passes for the wrong reason.

Testing async code and rejected promises

// src/fetchStatus.ts
export async function fetchStatus(shouldFail: boolean): Promise<string> {
  if (shouldFail) {
    throw new Error("Network error");
  }
  return "ok";
}
// __tests__/fetchStatus.test.ts
import { fetchStatus } from "../src/fetchStatus";

test("resolves with ok on success", async () => {
  await expect(fetchStatus(false)).resolves.toBe("ok");
});

test("rejects with an error on failure", async () => {
  await expect(fetchStatus(true)).rejects.toThrow("Network error");
});

.resolves and .rejects unwrap a promise for you inside expect — always await the whole expect(...) expression, or the test can finish and report "passed" before the assertion even runs.

Coverage

npx jest --coverage
----------------|---------|----------|---------|---------|
File            | % Stmts | % Branch | % Funcs | % Lines |
----------------|---------|----------|---------|---------|
All files       |   92.3  |   85.7   |  100     |  91.6   |
 mathUtils.ts   |   100   |   100    |  100     |  100    |
 user.ts        |   100   |   100    |  100     |  100    |
 notifier.ts    |   80    |   50     |  100     |  80     |
----------------|---------|----------|---------|---------|

Coverage percentage is a signal, not a goal to game — 100% coverage with assertions that never check meaningful behavior (expect(true).toBe(true) inside every function) is worse than useful 80% coverage. Use the report to find code paths nobody exercises, not as a score to maximize.

A trap: any in test helpers defeats the whole exercise

It's tempting to type test setup loosely to "get it working faster," but that's exactly where bad data quietly slips through untested:

// Weak: `any` means a wrong shape here wouldn't be caught until the
// function under test happens to touch the missing/wrong field.
function makeUser(overrides: any = {}) {
  return { id: 1, name: "Test", active: true, ...overrides };
}

// Better: overrides are constrained to a real subset of User.
function makeTypedUser(overrides: Partial<User> = {}): User {
  return { id: 1, name: "Test", active: true, ...overrides };
}

// makeTypedUser({ name: 123 });
// error TS2322: Type 'number' is not assignable to type 'string'.
// The `any` version above would accept this silently.

Factory functions like makeTypedUser are common in larger test suites — keep them typed with Partial<T> (from Module 5) so a typo in a test's setup fails at compile time, not three tests later with a confusing runtime error.

How It Actually Works

Running TypeScript tests through Jest actually involves a second, independent compilation step that's easy to overlook: ts-jest (or Babel's TypeScript preset) type-checks and transpiles each test file in the Jest process, separately from any tsc build you might also run — which is why it's possible for npm test to pass while tsc --noEmit reports errors, or vice versa, if the two are configured against different tsconfig.json settings (a common gotcha: ts-jest defaulting to a different target/module than your production build config, silently downleveling test code differently).

Type assertions used to shape mock data (as SomeInterface, or as unknown as SomeInterface to bypass an intermediate compatibility check) tell the checker to skip its normal structural comparison and simply trust your claim — this is exactly why a test's compiled, type-checked mock object can still crash at runtime if the interface's real shape has drifted since the mock was written: as doesn't insert a runtime validation, it only silences the static one. Libraries like jest.mocked() exist specifically to avoid this — instead of asserting a shape, jest.mocked(fn) narrows the checker's inferred type of an already-typed function to include Jest's mock-specific members (.mockReturnValue, .mock.calls), by intersecting with jest.Mock<...>, computed from the original function's real signature rather than a hand-typed guess.

Type-checking a test file and running it are, once again, fully decoupled processes: a test can be 100% type-correct and still fail its assertions (correct types say nothing about correct runtime values), and — depending on your Jest transform config — a test with type errors can sometimes still execute if the transform only transpiles (strips types) without invoking the full checker, which is a common source of "why didn't this type error block my test run" confusion.

Cheat sheet

Task API
Define a test test("name", () => { ... }) or it("name", () => { ... })
Group related tests describe("group", () => { ... })
Basic equality expect(x).toBe(y) (primitives) / expect(x).toEqual(y) (objects/arrays)
Expect a throw expect(() => fn()).toThrow("message")
Async resolve/reject await expect(promise).resolves.toBe(x) / .rejects.toThrow(...)
Typed mock function jest.fn<ReturnType, [ArgTypes]>()
Queue a mock's async result mockFn.mockResolvedValueOnce(value)
Run with coverage npx jest --coverage

Exercise

Write src/cart.ts exporting a Cart class with addItem(name: string, price: number): void, removeItem(name: string): void, and total(): number. Write __tests__/cart.test.ts with a describe("Cart") block covering: adding a single item updates the total correctly, adding multiple items sums correctly, removing an item that exists reduces the total, and removing an item that doesn't exist leaves the total unchanged. Run npx jest --coverage and confirm cart.ts shows 100% statement coverage.