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-jest or Babel
  • Fast — uses Vite’s transform pipeline
  • Compatible with Jest API — describe, it, expect work the same
  • Type testingexpectTypeOf lets 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: expectTypeOf assertions only work during typecheck mode. Run vitest --typecheck or add typecheck: { 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.