|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/testing
$cat docs/typescript-—-testing-with-vitest.md
updated Today·20 min read·published

TypeScript — Testing with Vitest

TypeScriptTestingVitestIntermediate🎯Free Tools
Introduction

Typed tests catch API drift before production. This guide covers Vitest with TypeScript, typed mocks, Testing Library patterns, and assertion helpers that stay honest under strict.

Goal: tests that fail to compile when production types change incompatibly — not just when runtime behavior changes.

info

Colocate *.test.ts with sources and include them in a dedicated tsconfig for the test runner.
Vitest + TypeScript Setup
install.sh
Bash
1pnpm add -D vitest @vitest/coverage-v8 typescript
2pnpm add -D @testing-library/react @testing-library/jest-dom jsdom
3
vitest.config.ts
TypeScript
1import { defineConfig } from "vitest/config";
2
3export default defineConfig({
4 test: {
5 globals: false,
6 environment: "node",
7 include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
8 coverage: { provider: "v8", reporter: ["text", "html"] },
9 },
10 resolve: {
11 alias: { "@": new URL("./src", import.meta.url).pathname },
12 },
13});
14
tsconfig.vitest.json
JSON
1{
2 "extends": "./tsconfig.json",
3 "compilerOptions": {
4 "types": ["vitest/globals", "node"],
5 "noEmit": true
6 },
7 "include": ["src/**/*.ts", "src/**/*.tsx", "vitest.config.ts"]
8}
9
📝

note

Prefer importing describe/it/expect from vitest over globals for clearer types.
Writing Typed Tests
sum.test.ts
TypeScript
1import { describe, it, expect } from "vitest";
2import { sum } from "./sum";
3
4describe("sum", () => {
5 it("adds numbers", () => {
6 const result = sum(1, 2);
7 expect(result).toBe(3);
8 // compile-time: result is number
9 const _check: number = result;
10 });
11
12 it("rejects wrong arg types at compile time", () => {
13 // @ts-expect-error string not assignable
14 sum("1", 2);
15 });
16});
17

Use @ts-expect-error (not ignore) to assert that invalid calls are rejected by the type system.

best practice

One @ts-expect-error per line with a short reason — CI fails if the error disappears.
Typed Mocks

vi.fn typing

mocks.ts
TypeScript
1import { describe, it, expect, vi } from "vitest";
2
3type FetchUser = (id: string) => Promise<{ id: string; name: string }>;
4
5describe("typed mock", () => {
6 it("mocks async fn", async () => {
7 const fetchUser: FetchUser = vi.fn(async (id) => ({
8 id,
9 name: "Ada",
10 }));
11
12 await expect(fetchUser("1")).resolves.toEqual({ id: "1", name: "Ada" });
13 expect(fetchUser).toHaveBeenCalledWith("1");
14 });
15});
16
17// Module mock
18vi.mock("./api", () => ({
19 fetchUser: vi.fn<FetchUser>(),
20}));
21

Partial mocks with satisfies

partial-mock.ts
TypeScript
1interface Repo {
2 get(id: string): Promise<User>;
3 list(): Promise<User[]>;
4 save(user: User): Promise<void>;
5}
6
7interface User { id: string; name: string }
8
9function mockRepo(partial: Partial<Repo> & Pick<Repo, "get">): Repo {
10 return {
11 list: async () => [],
12 save: async () => {},
13 ...partial,
14 };
15}
16
17const repo = mockRepo({
18 get: async (id) => ({ id, name: "Ada" }),
19});
20

warning

Avoid as unknown as Repo for mocks — you lose the guarantee that required methods exist.
Testing Library + TS
Button.test.tsx
TSX
1import { describe, it, expect, vi } from "vitest";
2import { render, screen } from "@testing-library/react";
3import userEvent from "@testing-library/user-event";
4import { Button } from "./Button";
5
6describe("Button", () => {
7 it("fires typed onClick", async () => {
8 const user = userEvent.setup();
9 const onClick = vi.fn<(e: React.MouseEvent<HTMLButtonElement>) => void>();
10 render(<Button onClick={onClick}>Save</Button>);
11 await user.click(screen.getByRole("button", { name: "Save" }));
12 expect(onClick).toHaveBeenCalledOnce();
13 });
14});
15
setup-tests.ts
TypeScript
1import "@testing-library/jest-dom/vitest";
2

For DOM matchers, ensure jest-dom types are referenced so toBeInTheDocument is typed.

Assertion Helpers
assert.ts
TypeScript
1export function assertDefined<T>(
2 value: T,
3 message = "expected defined",
4): asserts value is NonNullable<T> {
5 if (value == null) throw new Error(message);
6}
7
8export function assertOk<T, E>(
9 result: { ok: true; value: T } | { ok: false; error: E },
10): asserts result is { ok: true; value: T } {
11 if (!result.ok) throw new Error(String(result.error));
12}
13
14// in tests
15const user = await repo.get("1");
16assertDefined(user);
17expect(user.name).toBe("Ada"); // narrowed
18
expect-type.ts
TypeScript
1import { expectTypeOf } from "expect-type";
2import type { User } from "./user";
3
4expectTypeOf<User>().toHaveProperty("id");
5expectTypeOf(sum(1, 2)).toEqualTypeOf<number>();
6
🔥

pro tip

Libraries like expect-type or Vitest's expectTypeOf assert types without emit.
Async & Error Cases
errors.test.ts
TypeScript
1import { describe, it, expect } from "vitest";
2
3class NotFoundError extends Error {
4 readonly status = 404 as const;
5}
6
7it("maps errors", async () => {
8 await expect(Promise.reject(new NotFoundError("missing"))).rejects.toMatchObject({
9 status: 404,
10 });
11});
12
Practice Snippets

Snippet 1 — ts-expect-error

Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.

testing-1.ts
TypeScript
1// @ts-expect-error wrong args
2sum("a", 1);

Snippet 2 — vi.fn generic

Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.

testing-2.ts
TypeScript
1const fn = vi.fn<(x: number) => string>();
2fn.mockReturnValue("a");

Snippet 3 — assertDefined

Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.

testing-3.ts
TypeScript
1assertDefined(maybe);
2const s: string = maybe;

Snippet 4 — resolves

Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.

testing-4.ts
TypeScript
1await expect(p).resolves.toBe(1);

Snippet 5 — rejects

Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.

testing-5.ts
TypeScript
1await expect(p).rejects.toThrow(/no/);

Snippet 6 — partial Repo

Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.

testing-6.ts
TypeScript
1mockRepo({ get: async (id) => ({ id, name: "x" }) });

Snippet 7 — expectTypeOf

Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.

testing-7.ts
TypeScript
1expectTypeOf<User>().toMatchTypeOf<{ id: string }>();

Snippet 8 — userEvent

Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.

testing-8.ts
TypeScript
1await user.click(screen.getByRole("button"));

Snippet 9 — fake timers

Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.

testing-9.ts
TypeScript
1vi.useFakeTimers();
2vi.advanceTimersByTime(1000);

Snippet 10 — spy

Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.

testing-10.ts
TypeScript
1const spy = vi.spyOn(api, "get").mockResolvedValue(user);
📝

note

These snippets are intentionally dense. Prefer understanding one fully over skimming all.
Deep Dive — Vitest + TypeScript

This deep dive expands Vitest + TypeScript with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.

info

Keep npx tsc --noEmit green while experimenting — type errors are the curriculum.
Recipe — Factory fixtures

Recipe 1: Factory fixtures. Adapt names to your domain; keep the type relationships intact.

fixtures.ts
TypeScript
1export function makeUser(over: Partial<User> = {}): User {
2 return { id: "1", name: "Ada", email: "a@b.c", ...over };
3}
4
📝

note

Verify recipe 1 by hovering inferred types and attempting an intentional misuse.
Recipe — MSW typed handlers

Recipe 2: MSW typed handlers. Adapt names to your domain; keep the type relationships intact.

msw.ts
TypeScript
1import { http, HttpResponse } from "msw";
2http.get("/api/user/:id", ({ params }) => {
3 const id = String(params.id);
4 return HttpResponse.json({ id, name: "Ada" } satisfies User);
5});
6
📝

note

Verify recipe 2 by hovering inferred types and attempting an intentional misuse.
Recipe — Snapshot typing

Recipe 3: Snapshot typing. Adapt names to your domain; keep the type relationships intact.

snap.test.ts
TypeScript
1expect({ id: "1", name: "Ada" } satisfies User).toMatchSnapshot();
2
📝

note

Verify recipe 3 by hovering inferred types and attempting an intentional misuse.
Recipe — Concurrent tests

Recipe 4: Concurrent tests. Adapt names to your domain; keep the type relationships intact.

concurrent.test.ts
TypeScript
1describe.concurrent("api", () => {
2 it("a", async () => { await get("a"); });
3 it("b", async () => { await get("b"); });
4});
5
📝

note

Verify recipe 4 by hovering inferred types and attempting an intentional misuse.
FAQ

Does this replace runtime checks?

No. TypeScript erase at compile time. Pair with Zod or hand validation at boundaries.

How do I migrate gradually?

Enable strict flags one at a time, fix a package, then expand. See the migration guide.

What about performance?

Prefer project references and narrow include globs. Avoid enormous declaration merges in hot paths of the checker.

QuestionShort answer
Runtime cost of brands/variance annotations?Zero — compile-time only
Need emit?Often noEmit / bundler handles emit
CI gate?tsc --noEmit + ESLint type-checked
Checklist
ItemStatus
Strict tsconfig enabled
Boundaries validated at runtime
Public generics documented for variance
Lint type-checked rules on
No casual any / ts-ignore

best practice

Turn this checklist into a PR template for TypeScript-heavy changes.
Summary

You covered Vitest + TypeScript end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.

🔥

pro tip

Teach teammates the failure modes first — correct code is easier after you have seen the bugs.
Deep Dive — testing

This deep dive expands testing with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.

info

Keep npx tsc --noEmit green while experimenting — type errors are the curriculum.
Recipe — Core pattern

Recipe 1: Core pattern. Adapt names to your domain; keep the type relationships intact.

testing-a.ts
TypeScript
1export type Flag = "on" | "off";
2export function toggle(f: Flag): Flag {
3 return f === "on" ? "off" : "on";
4}
5
📝

note

Verify recipe 1 by hovering inferred types and attempting an intentional misuse.
Recipe — Boundary parse

Recipe 2: Boundary parse. Adapt names to your domain; keep the type relationships intact.

testing-b.ts
TypeScript
1export function parse(input: unknown): string {
2 if (typeof input !== "string") throw new Error("string");
3 return input;
4}
5
📝

note

Verify recipe 2 by hovering inferred types and attempting an intentional misuse.
Recipe — Exhaustive

Recipe 3: Exhaustive. Adapt names to your domain; keep the type relationships intact.

testing-c.ts
TypeScript
1function assertNever(x: never): never { throw new Error(String(x)); }
2type K = "a" | "b";
3export function f(k: K) {
4 switch (k) {
5 case "a": return 1;
6 case "b": return 2;
7 default: return assertNever(k);
8 }
9}
10
📝

note

Verify recipe 3 by hovering inferred types and attempting an intentional misuse.
Recipe — Readonly params

Recipe 4: Readonly params. Adapt names to your domain; keep the type relationships intact.

testing-d.ts
TypeScript
1export function sum(nums: readonly number[]): number {
2 return nums.reduce((a, b) => a + b, 0);
3}
4
📝

note

Verify recipe 4 by hovering inferred types and attempting an intentional misuse.
FAQ

Does this replace runtime checks?

No. TypeScript erase at compile time. Pair with Zod or hand validation at boundaries.

How do I migrate gradually?

Enable strict flags one at a time, fix a package, then expand. See the migration guide.

What about performance?

Prefer project references and narrow include globs. Avoid enormous declaration merges in hot paths of the checker.

QuestionShort answer
Runtime cost of brands/variance annotations?Zero — compile-time only
Need emit?Often noEmit / bundler handles emit
CI gate?tsc --noEmit + ESLint type-checked
Checklist
ItemStatus
Strict tsconfig enabled
Boundaries validated at runtime
Public generics documented for variance
Lint type-checked rules on
No casual any / ts-ignore

best practice

Turn this checklist into a PR template for TypeScript-heavy changes.
Summary

You covered testing end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.

🔥

pro tip

Teach teammates the failure modes first — correct code is easier after you have seen the bugs.
Worked Example

End-to-end worked example for testing. Copy into a scratch project with strict: true.

testing-worked.ts
TypeScript
1export type Flag = "on" | "off";
2export function toggle(f: Flag): Flag {
3 return f === "on" ? "off" : "on";
4}
5

Step-by-step

1. Paste the snippet. 2. Introduce a deliberate type error. 3. Fix it without assertions. 4. Add a second consumer that should fail if variance/brands/refs are wrong.

5. Run npx tsc --noEmit. 6. Commit the learning as a unit test or type test with @ts-expect-error.

info

If the example feels large, extract types into a dedicated file and keep runtime logic thin.
StepPass criteria
Compiles under strictNo errors
Intentional misuse fails@ts-expect-error lights up
Runtime path testedVitest or node assert
Docs updatedREADME / PR note
testing-type-test.ts
TypeScript
1import type { Expect, Equal } from "./type-tests";
2
3// Local helpers if you lack a type-test lib:
4type Equal<A, B> =
5 (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
6type Expect<T extends true> = T;
7
8type _smoke = Expect<Equal<true, true>>;
9// Topic: testing
10

best practice

Keep type tests in CI alongside unit tests — they are documentation that cannot rot silently.
Production Notes

Shipping notes teams hit in production when adopting these patterns: version skew between editor TypeScript and CI, incomplete include globs, and silent any from third-party DefinitelyTyped stubs.

verify.sh
Bash
1npx tsc --noEmit
2npx eslint .
3git diff --exit-code # ensure no accidental emit
4

warning

Never commit skipLibCheck: false flips without measuring CI time — but do not use skipLibCheck to hide broken local types.

Document peer dependency TypeScript ranges for libraries. For apps, pin the TypeScript version in the workspace and enable the workspace SDK in VS Code/Cursor.

package-engines.json
JSON
1{
2 "devDependencies": {
3 "typescript": "~5.8.0"
4 },
5 "packageManager": "pnpm@9"
6}
7

Rollback plan

If a strict flag floods errors, revert the flag, keep fixed files, and re-enable on a smaller glob via a nested tsconfig. Progress should be monotonic even when flags temporarily retreat.

🔥

pro tip

Track error counts in CI comments so migrations stay visible to the whole team.
$Blueprint — Engineering Documentation·Section ID: TS-TESTING·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.