TypeScript · lesson 16 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Also 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 }.