Skip to main content
TypeScript · lesson 16 of 21
TypeScript intermediate Lesson 16 of 21

Also part of: Software Testing

Testing TypeScript

Test TypeScript with Vitest: typed fixtures, vi.mock and vi.mocked, spies, fake timers, expectTypeOf type tests, and Jest with ts-jest — with real runs.

By SkillByExample · Updated

Versions for every run below: Node 22, TypeScript 6.0.3, Vitest 5.0.3, Jest 30.5 with ts-jest 29.4. The project has "type": "module" in package.json, typescript and vitest as dev dependencies, and this tsconfig.json:

{
  "compilerOptions": {
    "module": "nodenext",
    "noEmit": true,
    "types": ["node"],
    "verbatimModuleSyntax": true
  },
  "include": ["src"]
}

Vitest needs no configuration file for this: it finds *.test.ts files and runs TypeScript directly.

A First Typed Test

Tests import the code under test like any other module, and the compiler checks the calls in them too.

export function clamp(value: number, min: number, max: number): number {
  if (min > max) throw new RangeError(`min ${min} is greater than max ${max}`);
  return Math.min(Math.max(value, min), max);
}
import { describe, it, expect } from "vitest";
import { clamp } from "./math.js";

describe("clamp", () => {
  it("keeps values inside the range", () => {
    expect(clamp(5, 0, 10)).toBe(5);
  });
  it("pulls values to the nearest bound", () => {
    expect(clamp(-3, 0, 10)).toBe(0);
    expect(clamp(99, 0, 10)).toBe(10);
  });
  it("rejects an inverted range", () => {
    expect(() => clamp(1, 10, 0)).toThrow(RangeError);
  });
});
$ npx vitest run --reporter=verbose 2>&1 | grep -v "Start at\|Duration"

 RUN  v5.0.3 /root/ts/testing

 ✓ src/math.test.ts > clamp > keeps values inside the range 3ms
 ✓ src/math.test.ts > clamp > pulls values to the nearest bound 1ms
 ✓ src/math.test.ts > clamp > rejects an inverted range 1ms

 Test Files  1 passed (1)
      Tests  3 passed (3)

(The start time and duration lines are trimmed from the Vitest outputs.) A wrong argument type in a test is a compile error like anywhere else — but Vitest itself doesn’t type-check:

import { it, expect } from "vitest";
import { clamp } from "./math.js";

it("passes a string by mistake", () => {
  expect(clamp("5", 0, 10)).toBe(5);
});
$ npx vitest run src/wrong.test.ts 2>&1 | grep -v "Start at\|Duration"

 RUN  v5.0.3 /root/ts/testing


 Test Files  1 passed (1)
      Tests  1 passed (1)
$ npx tsc
src/wrong.test.ts(5,16): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

The test passed: Math.max("5", 0) coerces the string, so the bug is invisible at runtime. Only tsc catches it. Run tsc --noEmit (here npx tsc, since the config sets noEmit) alongside the tests in CI.

Typed Fixtures

A factory that takes Partial<T> overrides gives every test a valid object while letting it change only the fields it cares about.

export interface User { id: number; name: string; email: string; role: "admin" | "member"; active: boolean }

export function canDelete(actor: User, target: User): boolean {
  if (!actor.active) return false;
  return actor.role === "admin" && actor.id !== target.id;
}
import type { User } from "./users.js";

let nextId = 1;
export function makeUser(overrides: Partial<User> = {}): User {
  const id = nextId++;
  return { id, name: `user${id}`, email: `user${id}@example.com`, role: "member", active: true, ...overrides };
}
import { it, expect } from "vitest";
import { canDelete } from "./users.js";
import { makeUser } from "./fixtures.js";

it.each([
  { case: "admin deletes member", actor: makeUser({ role: "admin" }), target: makeUser(), expected: true },
  { case: "member deletes member", actor: makeUser(), target: makeUser(), expected: false },
  { case: "inactive admin", actor: makeUser({ role: "admin", active: false }), target: makeUser(), expected: false },
])("$case", ({ actor, target, expected }) => {
  expect(canDelete(actor, target)).toBe(expected);
});

it("admin cannot delete themselves", () => {
  const admin = makeUser({ role: "admin" });
  expect(canDelete(admin, admin)).toBe(false);
});
$ npx vitest run src/users.test.ts --reporter=verbose 2>&1 | grep -v "Start at\|Duration"

 RUN  v5.0.3 /root/ts/testing

 ✓ src/users.test.ts > admin deletes member 2ms
 ✓ src/users.test.ts > member deletes member 0ms
 ✓ src/users.test.ts > inactive admin 0ms
 ✓ src/users.test.ts > admin cannot delete themselves 0ms

 Test Files  1 passed (1)
      Tests  4 passed (4)

When User gains a required field, only makeUser needs updating — and tsc will point at it. makeUser({ role: "owner" }) is a compile error, because Partial<User> keeps each field’s type.

Mocking Modules

vi.mock replaces a module’s exports with mock functions. vi.mocked gives the replaced function a mock type that still knows the original signature.

import type { User } from "./users.js";

export async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`https://api.example.com/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<User>;
}
import { fetchUser } from "./user-api.js";

export async function greeting(id: number): Promise<string> {
  try {
    const user = await fetchUser(id);
    return user.active ? `Welcome back, ${user.name}` : `Account ${user.email} is inactive`;
  } catch {
    return "Welcome, guest";
  }
}
import { it, expect, vi, beforeEach } from "vitest";
import { greeting } from "./greeting.js";
import { fetchUser } from "./user-api.js";
import { makeUser } from "./fixtures.js";

vi.mock("./user-api.js");
const mockFetchUser = vi.mocked(fetchUser);

beforeEach(() => mockFetchUser.mockReset());

it("greets an active user by name", async () => {
  mockFetchUser.mockResolvedValue(makeUser({ name: "Ada" }));
  expect(await greeting(1)).toBe("Welcome back, Ada");
  expect(mockFetchUser).toHaveBeenCalledWith(1);
});

it("falls back to guest when the API fails", async () => {
  mockFetchUser.mockRejectedValue(new Error("HTTP 503"));
  expect(await greeting(2)).toBe("Welcome, guest");
});
$ npx vitest run src/greeting.test.ts --reporter=verbose 2>&1 | grep -v "Start at\|Duration"

 RUN  v5.0.3 /root/ts/testing

 ✓ src/greeting.test.ts > greets an active user by name 4ms
 × src/greeting.test.ts > falls back to guest when the API fails 4ms
   → HTTP 503

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/greeting.test.ts > falls back to guest when the API fails
Error: HTTP 503
 ❯ src/greeting.test.ts:18:35
     16|
     17| it("falls back to guest when the API fails", async () => {
     18|   mockFetchUser.mockRejectedValue(new Error("HTTP 503"));
       |                                   ^
     19|   expect(await greeting(2)).toBe("Welcome, guest");
     20| });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)

The first test passed without any network request — fetchUser was replaced before greeting.ts imported it. The second failed even though greeting catches errors, and the arrow points at the rejected value, not at an assertion. The culprit is beforeEach(() => mockFetchUser.mockReset()): mockReset() returns the mock itself, the arrow function returns that, and Vitest treats a function returned from beforeEach as a cleanup hook to call after the test. So after the second test, Vitest called the mock — which returned the rejected promise, failing the test. The types allow it, because a cleanup function is a legitimate return value. Braces fix it:

import { it, expect, vi, beforeEach } from "vitest";
import { greeting } from "./greeting.js";
import { fetchUser } from "./user-api.js";
import { makeUser } from "./fixtures.js";

vi.mock("./user-api.js");
const mockFetchUser = vi.mocked(fetchUser);

beforeEach(() => {
  mockFetchUser.mockReset();
});

it("greets an active user by name", async () => {
  mockFetchUser.mockResolvedValue(makeUser({ name: "Ada" }));
  expect(await greeting(1)).toBe("Welcome back, Ada");
  expect(mockFetchUser).toHaveBeenCalledWith(1);
});

it("falls back to guest when the API fails", async () => {
  mockFetchUser.mockRejectedValue(new Error("HTTP 503"));
  expect(await greeting(2)).toBe("Welcome, guest");
});
$ npx vitest run src/greeting.test.ts --reporter=verbose 2>&1 | grep -v "Start at\|Duration"

 RUN  v5.0.3 /root/ts/testing

 ✓ src/greeting.test.ts > greets an active user by name 4ms
 ✓ src/greeting.test.ts > falls back to guest when the API fails 0ms

 Test Files  1 passed (1)
      Tests  2 passed (2)

The typing is the point of vi.mocked: the mock’s resolved value must be a User.

import { it, vi } from "vitest";
import { fetchUser } from "./user-api.js";

vi.mock("./user-api.js");

it("returns a malformed user", () => {
  vi.mocked(fetchUser).mockResolvedValue({ name: "Ada" });
  fetchUser.mockResolvedValue({ name: "Ada" });
});
$ npx tsc
src/bad-mock.test.ts(7,42): error TS2345: Argument of type '{ name: string; }' is not assignable to parameter of type 'User'.
  Type '{ name: string; }' is missing the following properties from type 'User': id, email, role, active
src/bad-mock.test.ts(8,13): error TS2339: Property 'mockResolvedValue' does not exist on type '(id: number) => Promise<User>'.

The first error is the useful one: a stale mock that no longer matches the real type is caught at compile time. The second shows why vi.mocked exists at all: the import is typed as the original function, which has no mockResolvedValue method.

Spies

vi.spyOn wraps an existing method, recording calls while keeping (or replacing) the implementation.

import { it, expect, vi } from "vitest";

const audit = {
  log(event: string, userId: number): void {
    console.log(`[audit] ${event} by ${userId}`);
  },
};

function deleteAccount(userId: number) {
  audit.log("account.deleted", userId);
}

it("audits deletions", () => {
  const spy = vi.spyOn(audit, "log").mockImplementation(() => {});
  deleteAccount(7);
  expect(spy).toHaveBeenCalledExactlyOnceWith("account.deleted", 7);
  expect(spy.mock.calls[0]).toEqual(["account.deleted", 7]);
  spy.mockRestore();
});
$ npx vitest run src/logger.test.ts --reporter=verbose 2>&1 | grep -v "Start at\|Duration"

 RUN  v5.0.3 /root/ts/testing

 ✓ src/logger.test.ts > audits deletions 4ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

mockImplementation(() => {}) silenced the real console.log, so nothing was printed. spy.mock.calls is typed as [event: string, userId: number][], and vi.spyOn(audit, "lg") would be a compile error because "lg" isn’t a method name.

Async Tests and Fake Timers

Return or await promises in tests; rejects asserts on a failure. Fake timers make time-based code instant and deterministic.

export async function retry<T>(fn: () => Promise<T>, attempts: number, delayMs: number): Promise<T> {
  for (let i = 1; ; i++) {
    try {
      return await fn();
    } catch (err) {
      if (i >= attempts) throw err;
      await new Promise((r) => setTimeout(r, delayMs));
    }
  }
}
import { it, expect, vi } from "vitest";
import { retry } from "./retry.js";

it("succeeds on the third attempt", async () => {
  vi.useFakeTimers();
  const fn = vi.fn<() => Promise<string>>()
    .mockRejectedValueOnce(new Error("1"))
    .mockRejectedValueOnce(new Error("2"))
    .mockResolvedValue("ok");
  const result = retry(fn, 3, 60_000);
  await vi.runAllTimersAsync();
  await expect(result).resolves.toBe("ok");
  expect(fn).toHaveBeenCalledTimes(3);
  vi.useRealTimers();
});

it("gives up after the last attempt", async () => {
  const fn = vi.fn<() => Promise<string>>().mockRejectedValue(new Error("down"));
  await expect(retry(fn, 2, 1)).rejects.toThrow("down");
});
$ npx vitest run src/retry.test.ts --reporter=verbose 2>&1 | grep -v "Start at\|Duration"

 RUN  v5.0.3 /root/ts/testing

 ✓ src/retry.test.ts > succeeds on the third attempt 6ms
 ✓ src/retry.test.ts > gives up after the last attempt 3ms

 Test Files  1 passed (1)
      Tests  2 passed (2)

Two one-minute delays passed instantly under fake timers. vi.fn<() => Promise<string>>() gives the mock a signature, so mockResolvedValue(42) would be a type error.

Type-Level Tests

Some behaviour lives entirely in types — inference, overloads, generic constraints. expectTypeOf asserts on types, and vitest --typecheck runs those assertions through the compiler. Type tests go in *.test-d.ts files.

export function pick<T extends object, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K> {
  const out = {} as Pick<T, K>;
  for (const key of keys) out[key] = obj[key];
  return out;
}
import { it, expectTypeOf } from "vitest";
import { pick } from "./pick.js";

const user = { id: 1, name: "Ada", email: "ada@example.com" };

it("narrows the result to the picked keys", () => {
  expectTypeOf(pick(user, "id", "name")).toEqualTypeOf<{ id: number; name: string }>();
});

it("rejects unknown keys", () => {
  // @ts-expect-error — "age" is not a key of user
  pick(user, "age");
});

it("keeps literal types (deliberately wrong expectation)", () => {
  expectTypeOf(pick({ role: "admin" as const }, "role")).toEqualTypeOf<{ role: string }>();
});
$ npx vitest run --typecheck.only --reporter=verbose 2>&1 | grep -v "Start at\|Duration\|experimental\|Breaking changes"

 RUN  v5.0.3 /root/ts/testing

 ✓ src/pick.test-d.ts:6 > narrows the result to the picked keys
 ✓ src/pick.test-d.ts:10 > rejects unknown keys
 × src/pick.test-d.ts:15 > keeps literal types (deliberately wrong expectation)
   → Type '{ role string; }' does not satisfy the constraint '{ role "Expected string, Actual never"; }'.
  Types of property 'role' are incompatible.
    Type 'string' is not assignable to type '"Expected string, Actual never"'.

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/pick.test-d.ts:15 > keeps literal types (deliberately wrong expectation)
TypeCheckError: Type '{ role string; }' does not satisfy the constraint '{ role "Expected string, Actual never"; }'.
  Types of property 'role' are incompatible.
    Type 'string' is not assignable to type '"Expected string, Actual never"'.
 ❯ src/pick.test-d.ts:16:72
     14|
     15| it("keeps literal types (deliberately wrong expectation)", () => {
     16|   expectTypeOf(pick({ role: "admin" as const }, "role")).toEqualTypeOf…
       |                                                                        ^
     17| });
     18|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed | 2 passed (3)
Type Errors  no errors

The first two tests pass: pick returns exactly { id: number; name: string }, and @ts-expect-error requires the next line to be an error, so if pick ever accepted any string the test would fail. The third expectation is wrong on purpose: the actual type is { role: "admin" }, because as const kept the literal. Vitest reports it as a failing test. The message is cryptic — expectTypeOf reports mismatches through a generated constraint type, and Vitest drops the colons from tsc’s wording — but it names the property that differs, role, and the type that was expected. --typecheck.only runs just the type tests; plain --typecheck runs both kinds.

Jest with ts-jest

Jest is still common in existing codebases. With ts-jest, every test file is compiled — and type-checked — by TypeScript. Here’s a separate CommonJS project in jest-demo/:

{ "type": "commonjs", "scripts": { "test": "jest" } }
module.exports = { preset: "ts-jest", testEnvironment: "node" };
{ "compilerOptions": { "module": "commonjs", "types": ["jest", "node"] } }

module: "commonjs" and the "type": "commonjs" package keep Jest on its well-trodden CommonJS path; ts-jest’s ESM mode needs extra flags.

export function slugify(title: string): string {
  return title.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
import { slugify } from "./slug";

describe("slugify", () => {
  it.each([
    ["Hello World", "hello-world"],
    ["  TypeScript 6.0!  ", "typescript-6-0"],
  ])("%s -> %s", (input, expected) => {
    expect(slugify(input)).toBe(expected);
  });

  it("type-checks the test itself", () => {
    expect(slugify(42)).toBe("42");
  });
});
$ cd jest-demo && npx jest 2>&1 | grep -v "^Time:" | sed 's/\x1b\[[0-9;]*m//g'
FAIL ./slug.test.ts
  ● Test suite failed to run

    slug.test.ts:12:20 - error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

    12     expect(slugify(42)).toBe("42");
                          ~~

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Ran all test suites.

(The sed strips ts-jest’s colour codes.) Unlike Vitest, ts-jest refused to run the file with a type error in it (its diagnostics option controls this). The same jest.mock / jest.mocked / jest.spyOn APIs mirror the vi.* ones above. Remove the bad test and the suite passes:

$ cd jest-demo && npx jest 2>&1 | grep -v "^Time:"
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Ran all test suites.

Practice

1. Add a test to math.test.ts that clamp(5, 5, 5) returns 5, and a type test that clamp’s return type is number.

Solution
import { it, expect } from "vitest";
import { clamp } from "./math.js";

it("handles an empty range", () => {
  expect(clamp(5, 5, 5)).toBe(5);
  expect(clamp(0, 5, 5)).toBe(5);
});
import { it, expectTypeOf } from "vitest";
import { clamp } from "./math.js";

it("returns a number", () => {
  expectTypeOf(clamp).returns.toEqualTypeOf<number>();
  expectTypeOf(clamp).parameters.toEqualTypeOf<[value: number, min: number, max: number]>();
});
$ npx vitest run src/clamp-extra.test.ts --reporter=verbose 2>&1 | grep -v "Start at\|Duration"

 RUN  v5.0.3 /root/ts/testing

 ✓ src/clamp-extra.test.ts > handles an empty range 2ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
$ npx vitest run --typecheck.only src/clamp.test-d.ts --reporter=verbose 2>&1 | grep -v "Start at\|Duration\|experimental\|Breaking changes"

 RUN  v5.0.3 /root/ts/testing

 ✓ src/clamp.test-d.ts:4 > returns a number

 Test Files  1 passed (1)
      Tests  1 passed (1)
Type Errors  no errors

min === max is a valid range, so no RangeError. Parameter names in the expected tuple are labels only — they don’t affect type equality.

2. Extend greeting.test.ts with a test for an inactive user, using mockResolvedValueOnce.

Solution
import { it, expect, vi } from "vitest";
import { greeting } from "./greeting.js";
import { fetchUser } from "./user-api.js";
import { makeUser } from "./fixtures.js";

vi.mock("./user-api.js");

it("tells inactive users their account is inactive", async () => {
  vi.mocked(fetchUser).mockResolvedValueOnce(makeUser({ active: false, email: "old@example.com" }));
  expect(await greeting(3)).toBe("Account old@example.com is inactive");
  expect(await greeting(3)).toBe("Welcome, guest");
});
$ npx vitest run src/greeting-inactive.test.ts --reporter=verbose 2>&1 | grep -v "Start at\|Duration"

 RUN  v5.0.3 /root/ts/testing

 ✓ src/greeting-inactive.test.ts > tells inactive users their account is inactive 2ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

After the Once value is used up, the automock returns undefined, so user.active throws inside greeting, which catches it and falls back to the guest message — a reminder that automocks return undefined unless told otherwise.

3. Write a type test proving that retry’s result type follows the function it retries.

Solution
import { it, expectTypeOf } from "vitest";
import { retry } from "./retry.js";

it("infers the resolved type", () => {
  expectTypeOf(retry(async () => 42, 3, 10)).toEqualTypeOf<Promise<number>>();
  expectTypeOf(retry(async () => ({ ok: true }), 3, 10)).resolves.toEqualTypeOf<{ ok: boolean }>();
});
$ npx vitest run --typecheck.only src/retry.test-d.ts --reporter=verbose 2>&1 | grep -v "Start at\|Duration\|experimental\|Breaking changes"

 RUN  v5.0.3 /root/ts/testing

 ✓ src/retry.test-d.ts:4 > infers the resolved type

 Test Files  1 passed (1)
      Tests  1 passed (1)
Type Errors  no errors

T is inferred from fn’s return type, so the wrapper stays exactly as typed as the function it wraps. Note { ok: true } in a return position widens to { ok: boolean }.

Frequently Asked Questions

Do I need ts-jest or can I use Babel to run TypeScript tests?
Either works with Jest. ts-jest type-checks each test file as it compiles it, so type errors fail the run; Babel only strips types, which is faster but lets type errors through. Vitest strips types too, so pair it with tsc --noEmit or vitest --typecheck in CI.
How do I mock a TypeScript module with Jest?
Call jest.mock('./module') at the top of the test file, then wrap the imported function with jest.mocked(fn) to get a typed mock. In Vitest the equivalents are vi.mock and vi.mocked. The wrapper keeps the original signature, so mockResolvedValue only accepts values of the real return type.
What is type-level testing?
Type-level testing verifies that your types are correct — that a function returns the right type, or that invalid calls produce type errors. Vitest's expectTypeOf and the expect-type library provide assertions that run at compile time, not runtime.
Should I use Vitest or Jest for a new TypeScript project?
Vitest is usually simpler for new projects: it runs TypeScript and ES modules without extra configuration and has a Jest-compatible API. Jest with ts-jest remains common in existing codebases, and its setup is well understood, especially for CommonJS projects.
Does Vitest check types when it runs tests?
No. Vitest transpiles test files without type checking, so a test with a type error can still pass. Run tsc --noEmit as a separate step, or run vitest with --typecheck, which type-checks *.test-d.ts files and reports expectTypeOf failures as test failures.