In the previous tutorial, we learned about TypeScript with Next.js. Now let’s learn about testing — how to write typed tests, mock dependencies, test async code, and even test your types.
By the end of this tutorial, you will know how to set up Vitest, write tests with TypeScript, mock functions and modules, and use expectTypeOf to test types themselves.
Why Vitest?
Vitest is the modern choice for testing TypeScript projects. It has:
- Zero config for TypeScript — no need for
ts-jestor Babel - Fast — uses Vite’s transform pipeline
- Compatible with Jest API —
describe,it,expectwork the same - Type testing —
expectTypeOflets you test types at compile time - Watch mode — re-runs tests when files change
Setting Up Vitest
npm install --save-dev vitest
Add a test script to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
That’s it. No configuration file needed for basic usage. Vitest reads your tsconfig.json automatically.
Writing Your First Test
Create a file with the .test.ts extension:
// src/math.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;
}
// src/math.test.ts
import { describe, it, expect } from "vitest";
import { add, divide } from "./math";
describe("add", () => {
it("adds two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
it("adds negative numbers", () => {
expect(add(-1, -2)).toBe(-3);
});
it("adds zero", () => {
expect(add(5, 0)).toBe(5);
});
});
describe("divide", () => {
it("divides two numbers", () => {
expect(divide(10, 2)).toBe(5);
});
it("throws on division by zero", () => {
expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
});
});
Run the tests:
npx vitest run
Common Matchers
// Equality
expect(value).toBe(5); // strict equality (===)
expect(value).toEqual({ a: 1 }); // deep equality for objects
expect(value).not.toBe(10); // negation
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeLessThan(10);
expect(value).toBeCloseTo(0.3, 5); // floating point
// Strings
expect(value).toMatch(/pattern/);
expect(value).toContain("substring");
// Arrays
expect(array).toContain("item");
expect(array).toHaveLength(3);
// Errors
expect(() => fn()).toThrow();
expect(() => fn()).toThrow("message");
expect(() => fn()).toThrowError(CustomError);
Testing Objects and Arrays
interface User {
id: number;
name: string;
email: string;
}
function createUser(name: string, email: string): User {
return { id: Math.floor(Math.random() * 1000), name, email };
}
describe("createUser", () => {
it("creates a user with the given name and email", () => {
const user = createUser("Alex", "alex@example.com");
// Check specific properties
expect(user.name).toBe("Alex");
expect(user.email).toBe("alex@example.com");
expect(user.id).toBeDefined();
// Check partial object match
expect(user).toMatchObject({
name: "Alex",
email: "alex@example.com",
});
});
});
Mocking Functions
vi.fn() — Create a Mock Function
import { describe, it, expect, vi } from "vitest";
it("tracks function calls", () => {
const mockFn = vi.fn();
mockFn("hello");
mockFn("world");
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith("hello");
expect(mockFn).toHaveBeenLastCalledWith("world");
});
Typed Mock Functions
// Type the mock using the function signature (current Vitest API)
const mockGetUser = vi.fn<(id: number) => User>();
mockGetUser.mockReturnValue({ id: 1, name: "Alex", email: "alex@example.com" });
const user = mockGetUser(1); // TypeScript knows this returns User
expect(user.name).toBe("Alex");
Mock Implementations
// Type the mock with the full function type
const mockFetch = vi.fn<(url: string) => Promise<User>>();
mockFetch.mockResolvedValue({ id: 1, name: "Alex", email: "alex@example.com" });
const user = await mockFetch("/api/users/1");
expect(user.name).toBe("Alex");
Mocking Modules
vi.mock — Replace Entire Modules
// src/user-service.ts
import { fetchUser } from "./api";
export async function getUserName(id: number): Promise<string> {
const user = await fetchUser(id);
return user.name;
}
// src/user-service.test.ts
import { describe, it, expect, vi } from "vitest";
import { getUserName } from "./user-service";
// Mock the entire api module
vi.mock("./api", () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alex", email: "alex@example.com" }),
}));
describe("getUserName", () => {
it("returns the user name", async () => {
const name = await getUserName(1);
expect(name).toBe("Alex");
});
});
Spy on Functions
vi.spyOn watches a method without replacing it:
const spy = vi.spyOn(console, "log");
myFunction(); // calls console.log internally
expect(spy).toHaveBeenCalledWith("Expected message");
spy.mockRestore(); // restore original behavior
Testing Async Code
Async/Await
async function fetchData(): Promise<string> {
const response = await fetch("/api/data");
const data = await response.json();
return data.message;
}
it("fetches data", async () => {
const result = await fetchData();
expect(result).toBe("Hello");
});
Testing Rejected Promises
async function failingFetch(): Promise<never> {
throw new Error("Network error");
}
it("handles errors", async () => {
await expect(failingFetch()).rejects.toThrow("Network error");
});
Testing with Timers
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
function delayedGreeting(callback: (msg: string) => void): void {
setTimeout(() => callback("Hello"), 1000);
}
describe("delayedGreeting", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
// Restore real timers so they don't leak into other tests
vi.useRealTimers();
});
it("calls callback after 1 second", () => {
const callback = vi.fn();
delayedGreeting(callback);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledWith("Hello");
});
});
Setup and Teardown
import { describe, it, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
describe("database tests", () => {
beforeAll(async () => {
// Runs once before all tests in this block
await connectDatabase();
});
afterAll(async () => {
// Runs once after all tests in this block
await disconnectDatabase();
});
beforeEach(async () => {
// Runs before each test
await clearTables();
});
afterEach(() => {
// Runs after each test
vi.restoreAllMocks();
});
it("creates a record", async () => {
// test code
});
});
Type Testing with expectTypeOf
Vitest can test types with expectTypeOf.
Important:
expectTypeOfassertions only work during typecheck mode. Runvitest --typecheckor addtypecheck: { enabled: true }to your Vitest config. Without typecheck, the assertions are no-ops at runtime.
// Run with: vitest --typecheck
import { describe, it, expectTypeOf } from "vitest";
import { add } from "./math";
interface User {
id: number;
name: string;
}
describe("type tests", () => {
it("add returns a number", () => {
expectTypeOf(add).returns.toBeNumber();
});
it("add accepts two numbers", () => {
expectTypeOf(add).parameters.toEqualTypeOf<[number, number]>();
});
it("User has correct shape", () => {
expectTypeOf<User>().toHaveProperty("id");
expectTypeOf<User>().toHaveProperty("name");
expectTypeOf<User["id"]>().toBeNumber();
expectTypeOf<User["name"]>().toBeString();
});
});
Testing Generic Types
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
it("Result type works correctly", () => {
type SuccessResult = Extract<Result<string, Error>, { ok: true }>;
expectTypeOf<SuccessResult["value"]>().toBeString();
type ErrorResult = Extract<Result<string, Error>, { ok: false }>;
expectTypeOf<ErrorResult["error"]>().toEqualTypeOf<Error>();
});
Test Coverage
Run tests with coverage:
npx vitest run --coverage
Install the coverage provider:
npm install --save-dev @vitest/coverage-v8
Configure in vitest.config.ts (optional):
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts"],
},
},
});
Testing Patterns
Arrange-Act-Assert
it("formats user name", () => {
// Arrange
const user: User = { id: 1, name: "alex smith", email: "alex@example.com" };
// Act
const result = formatName(user);
// Assert
expect(result).toBe("Alex Smith");
});
Test Each (Parameterized Tests)
it.each([
{ input: "hello", expected: "Hello" },
{ input: "world", expected: "World" },
{ input: "typescript", expected: "Typescript" },
])("capitalizes '$input' to '$expected'", ({ input, expected }) => {
expect(capitalize(input)).toBe(expected);
});
What’s Next?
In the next tutorial, we will learn about Zod — runtime validation that generates TypeScript types from schemas.