Vitest Setup

beginner

Unit and integration testing with Vitest and React Testing Library.

testingvitestunit-tests
Tested on201619TS5.9
$ bunx sinew add testing/vitest-setup
Interactive demo coming soon

1The Problem

Testing Next.js apps requires:

  • Fast test execution
  • TypeScript support without configuration
  • Mocking of Next.js internals
  • Component testing with React Testing Library
  • Coverage reporting

2The Solution

Vitest provides a modern, fast testing experience with excellent TypeScript support.

3Files

vitest.config.ts

vitest.config.tsTypeScript
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./vitest.setup.ts"],
    include: ["**/*.test.{ts,tsx}"],
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      exclude: ["node_modules/", "**/*.config.*", "**/*.d.ts"],
    },
  },
});

vitest.setup.ts

vitest.setup.tsTypeScript
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";

// Cleanup after each test
afterEach(() => {
  cleanup();
});

// Mock Next.js router
vi.mock("next/navigation", () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    prefetch: vi.fn(),
    back: vi.fn(),
  }),
  usePathname: () => "/",
  useSearchParams: () => new URLSearchParams(),
}));

// Mock Next.js Image
vi.mock("next/image", () => ({
  default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => {
    // eslint-disable-next-line @next/next/no-img-element
    return <img {...props} alt={props.alt} />;
  },
}));

lib/test-utils.tsx

lib/test-utils.tsxTypeScript
import { render, RenderOptions } from "@testing-library/react";
import { ReactElement, ReactNode } from "react";

// Add providers here if needed
function AllProviders({ children }: { children: ReactNode }) {
  return <>{children}</>;
}

function customRender(
  ui: ReactElement,
  options?: Omit<RenderOptions, "wrapper">
) {
  return render(ui, { wrapper: AllProviders, ...options });
}

export * from "@testing-library/react";
export { customRender as render };

components/__tests__/button.test.tsx

components/__tests__/button.test.tsxTypeScript
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@/lib/test-utils";
import userEvent from "@testing-library/user-event";
import { Button } from "../button";

describe("Button", () => {
  it("renders with text", () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole("button")).toHaveTextContent("Click me");
  });

  it("calls onClick when clicked", async () => {
    const user = userEvent.setup();
    const onClick = vi.fn();

    render(<Button onClick={onClick}>Click me</Button>);
    await user.click(screen.getByRole("button"));

    expect(onClick).toHaveBeenCalledTimes(1);
  });

  it("is disabled when disabled prop is true", () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

lib/__tests__/utils.test.ts

lib/__tests__/utils.test.tsTypeScript
import { describe, it, expect } from "vitest";
import { formatDate, slugify } from "../utils";

describe("formatDate", () => {
  it("formats a date correctly", () => {
    const date = new Date("2025-01-15");
    expect(formatDate(date)).toBe("January 15, 2025");
  });
});

describe("slugify", () => {
  it("converts string to slug", () => {
    expect(slugify("Hello World")).toBe("hello-world");
  });

  it("handles special characters", () => {
    expect(slugify("Hello! World?")).toBe("hello-world");
  });
});

package.json

package.jsonJSON
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

4Dependencies

$ bun add -D vitest @testing-library/react

5Configuration

Environment Variables in Tests

// vitest.config.ts
export default defineConfig({
  test: {
    env: {
      DATABASE_URL: "postgresql://test:test@localhost:5432/test",
      NEXT_PUBLIC_APP_URL: "http://localhost:3000",
    },
  },
});
TypeScript

Test Filtering

# Run specific test file
bun test button.test.tsx

# Run tests matching pattern
bun test --testNamePattern="formats date"

# Run tests in watch mode
bun test --watch
Bash

6Usage

Testing Async Code

import { describe, it, expect } from "vitest";
import { fetchUser } from "../api";

describe("fetchUser", () => {
  it("returns user data", async () => {
    const user = await fetchUser("123");
    expect(user).toHaveProperty("id", "123");
    expect(user).toHaveProperty("email");
  });
});
TypeScript

Mocking Modules

import { vi, describe, it, expect } from "vitest";

vi.mock("@/lib/db", () => ({
  prisma: {
    user: {
      findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Test" }),
    },
  },
}));

describe("getUser", () => {
  it("returns user from database", async () => {
    const user = await getUser("1");
    expect(user).toEqual({ id: "1", name: "Test" });
  });
});
TypeScript

Testing Hooks

import { renderHook, act } from "@testing-library/react";
import { useCounter } from "../use-counter";

describe("useCounter", () => {
  it("increments count", () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });
});
TypeScript

7Troubleshooting

Tests running slowly

  • Use --pool=forks for CPU-intensive tests
  • Run tests in parallel with --pool=threads
  • Skip slow tests in watch mode with .skip

Module resolution errors

  • Ensure tsconfigPaths plugin is configured
  • Check that path aliases match tsconfig.json
  • Clear Vitest cache: bun test --clearCache

Related patterns