TypeScript — Testing with Vitest
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
| 1 | pnpm add -D vitest @vitest/coverage-v8 typescript |
| 2 | pnpm add -D @testing-library/react @testing-library/jest-dom jsdom |
| 3 |
| 1 | import { defineConfig } from "vitest/config"; |
| 2 | |
| 3 | export 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 |
| 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
| 1 | import { describe, it, expect } from "vitest"; |
| 2 | import { sum } from "./sum"; |
| 3 | |
| 4 | describe("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
vi.fn typing
| 1 | import { describe, it, expect, vi } from "vitest"; |
| 2 | |
| 3 | type FetchUser = (id: string) => Promise<{ id: string; name: string }>; |
| 4 | |
| 5 | describe("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 |
| 18 | vi.mock("./api", () => ({ |
| 19 | fetchUser: vi.fn<FetchUser>(), |
| 20 | })); |
| 21 |
Partial mocks with satisfies
| 1 | interface Repo { |
| 2 | get(id: string): Promise<User>; |
| 3 | list(): Promise<User[]>; |
| 4 | save(user: User): Promise<void>; |
| 5 | } |
| 6 | |
| 7 | interface User { id: string; name: string } |
| 8 | |
| 9 | function mockRepo(partial: Partial<Repo> & Pick<Repo, "get">): Repo { |
| 10 | return { |
| 11 | list: async () => [], |
| 12 | save: async () => {}, |
| 13 | ...partial, |
| 14 | }; |
| 15 | } |
| 16 | |
| 17 | const repo = mockRepo({ |
| 18 | get: async (id) => ({ id, name: "Ada" }), |
| 19 | }); |
| 20 |
warning
| 1 | import { describe, it, expect, vi } from "vitest"; |
| 2 | import { render, screen } from "@testing-library/react"; |
| 3 | import userEvent from "@testing-library/user-event"; |
| 4 | import { Button } from "./Button"; |
| 5 | |
| 6 | describe("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 |
| 1 | import "@testing-library/jest-dom/vitest"; |
| 2 |
For DOM matchers, ensure jest-dom types are referenced so toBeInTheDocument is typed.
| 1 | export 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 | |
| 8 | export 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 |
| 15 | const user = await repo.get("1"); |
| 16 | assertDefined(user); |
| 17 | expect(user.name).toBe("Ada"); // narrowed |
| 18 |
| 1 | import { expectTypeOf } from "expect-type"; |
| 2 | import type { User } from "./user"; |
| 3 | |
| 4 | expectTypeOf<User>().toHaveProperty("id"); |
| 5 | expectTypeOf(sum(1, 2)).toEqualTypeOf<number>(); |
| 6 |
pro tip
| 1 | import { describe, it, expect } from "vitest"; |
| 2 | |
| 3 | class NotFoundError extends Error { |
| 4 | readonly status = 404 as const; |
| 5 | } |
| 6 | |
| 7 | it("maps errors", async () => { |
| 8 | await expect(Promise.reject(new NotFoundError("missing"))).rejects.toMatchObject({ |
| 9 | status: 404, |
| 10 | }); |
| 11 | }); |
| 12 |
Snippet 1 — ts-expect-error
Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // @ts-expect-error wrong args |
| 2 | sum("a", 1); |
Snippet 2 — vi.fn generic
Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.
| 1 | const fn = vi.fn<(x: number) => string>(); |
| 2 | fn.mockReturnValue("a"); |
Snippet 3 — assertDefined
Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.
| 1 | assertDefined(maybe); |
| 2 | const s: string = maybe; |
Snippet 4 — resolves
Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.
| 1 | await expect(p).resolves.toBe(1); |
Snippet 5 — rejects
Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.
| 1 | await expect(p).rejects.toThrow(/no/); |
Snippet 6 — partial Repo
Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.
| 1 | mockRepo({ get: async (id) => ({ id, name: "x" }) }); |
Snippet 7 — expectTypeOf
Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.
| 1 | expectTypeOf<User>().toMatchTypeOf<{ id: string }>(); |
Snippet 8 — userEvent
Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.
| 1 | await user.click(screen.getByRole("button")); |
Snippet 9 — fake timers
Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.
| 1 | vi.useFakeTimers(); |
| 2 | vi.advanceTimersByTime(1000); |
Snippet 10 — spy
Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.
| 1 | const spy = vi.spyOn(api, "get").mockResolvedValue(user); |
note
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
Recipe 1: Factory fixtures. Adapt names to your domain; keep the type relationships intact.
| 1 | export function makeUser(over: Partial<User> = {}): User { |
| 2 | return { id: "1", name: "Ada", email: "a@b.c", ...over }; |
| 3 | } |
| 4 |
note
Recipe 2: MSW typed handlers. Adapt names to your domain; keep the type relationships intact.
| 1 | import { http, HttpResponse } from "msw"; |
| 2 | http.get("/api/user/:id", ({ params }) => { |
| 3 | const id = String(params.id); |
| 4 | return HttpResponse.json({ id, name: "Ada" } satisfies User); |
| 5 | }); |
| 6 |
note
Recipe 3: Snapshot typing. Adapt names to your domain; keep the type relationships intact.
| 1 | expect({ id: "1", name: "Ada" } satisfies User).toMatchSnapshot(); |
| 2 |
note
Recipe 4: Concurrent tests. Adapt names to your domain; keep the type relationships intact.
| 1 | describe.concurrent("api", () => { |
| 2 | it("a", async () => { await get("a"); }); |
| 3 | it("b", async () => { await get("b"); }); |
| 4 | }); |
| 5 |
note
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.
| Question | Short 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 |
| Item | Status |
|---|---|
| Strict tsconfig enabled | ☐ |
| Boundaries validated at runtime | ☐ |
| Public generics documented for variance | ☐ |
| Lint type-checked rules on | ☐ |
| No casual any / ts-ignore | ☐ |
best practice
You covered Vitest + TypeScript end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.
pro tip
This deep dive expands testing with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.
info
Recipe 1: Core pattern. Adapt names to your domain; keep the type relationships intact.
| 1 | export type Flag = "on" | "off"; |
| 2 | export function toggle(f: Flag): Flag { |
| 3 | return f === "on" ? "off" : "on"; |
| 4 | } |
| 5 |
note
Recipe 2: Boundary parse. Adapt names to your domain; keep the type relationships intact.
| 1 | export function parse(input: unknown): string { |
| 2 | if (typeof input !== "string") throw new Error("string"); |
| 3 | return input; |
| 4 | } |
| 5 |
note
Recipe 3: Exhaustive. Adapt names to your domain; keep the type relationships intact.
| 1 | function assertNever(x: never): never { throw new Error(String(x)); } |
| 2 | type K = "a" | "b"; |
| 3 | export 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
Recipe 4: Readonly params. Adapt names to your domain; keep the type relationships intact.
| 1 | export function sum(nums: readonly number[]): number { |
| 2 | return nums.reduce((a, b) => a + b, 0); |
| 3 | } |
| 4 |
note
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.
| Question | Short 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 |
| Item | Status |
|---|---|
| Strict tsconfig enabled | ☐ |
| Boundaries validated at runtime | ☐ |
| Public generics documented for variance | ☐ |
| Lint type-checked rules on | ☐ |
| No casual any / ts-ignore | ☐ |
best practice
You covered testing end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.
pro tip
End-to-end worked example for testing. Copy into a scratch project with strict: true.
| 1 | export type Flag = "on" | "off"; |
| 2 | export 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
| Step | Pass criteria |
|---|---|
| Compiles under strict | No errors |
| Intentional misuse fails | @ts-expect-error lights up |
| Runtime path tested | Vitest or node assert |
| Docs updated | README / PR note |
| 1 | import type { Expect, Equal } from "./type-tests"; |
| 2 | |
| 3 | // Local helpers if you lack a type-test lib: |
| 4 | type Equal<A, B> = |
| 5 | (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false; |
| 6 | type Expect<T extends true> = T; |
| 7 | |
| 8 | type _smoke = Expect<Equal<true, true>>; |
| 9 | // Topic: testing |
| 10 |
best practice
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.
| 1 | npx tsc --noEmit |
| 2 | npx eslint . |
| 3 | git diff --exit-code # ensure no accidental emit |
| 4 |
warning
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.
| 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
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.