Vitest — Fast Unit Testing for Modern JavaScript Projects
A practical guide to Vitest — setup, writing tests, mocking, coverage, and why it's replacing Jest in Vite-based projects.
Jest has been the default JavaScript testing framework for years. It works. It's stable. It's also slow if your project uses ESM, TypeScript, or modern bundling — because Jest was built for CommonJS and requires transforms for everything else. Vitest is what Jest would look like if it were built today, on top of Vite's transform pipeline.
If you're using Vite, Vitest shares your existing Vite config — same plugins, same transforms, same path aliases. No duplicate configuration. Tests run using the same pipeline that serves your dev server, which means ESM, TypeScript, JSX, and CSS modules all work without extra setup.
Setup
bun add -d vitest
# Optional: UI, coverage
bun add -d @vitest/ui @vitest/coverage-v8
// vitest.config.ts (or just add 'test' to your vite.config.ts)
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true, // Use describe/test/expect without imports
environment: "node", // or "jsdom" for browser-like testing
include: ["src//.test.ts", "src//.spec.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
},
},
});
// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
Writing Tests
The API is Jest-compatible. If you've written Jest tests, you already know Vitest.
// src/utils/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function percentage(value: number, total: number): number {
if (total === 0) throw new Error("Total cannot be zero");
return (value / total) * 100;
}
// src/utils/math.test.ts
import { describe, test, expect } from "vitest";
import { add, clamp, percentage } from "./math";
describe("add", () => {
test("adds two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
test("handles negative numbers", () => {
expect(add(-1, -1)).toBe(-2);
});
test("handles zero", () => {
expect(add(0, 0)).toBe(0);
});
});
describe("clamp", () => {
test("clamps value above max", () => {
expect(clamp(150, 0, 100)).toBe(100);
});
test("clamps value below min", () => {
expect(clamp(-10, 0, 100)).toBe(0);
});
test("returns value within range unchanged", () => {
expect(clamp(50, 0, 100)).toBe(50);
});
});
describe("percentage", () => {
test("calculates percentage correctly", () => {
expect(percentage(25, 100)).toBe(25);
expect(percentage(1, 3)).toBeCloseTo(33.33, 1);
});
test("throws on zero total", () => {
expect(() => percentage(10, 0)).toThrow("Total cannot be zero");
});
});
vitest # Watch mode (re-runs on file change)
vitest run # Single run
vitest math # Filter by filename
Mocking
Vitest has built-in mocking that matches Jest's API.
// Mocking functions
import { vi, describe, test, expect } from "vitest";
describe("callbacks", () => {
test("mock function tracks calls", () => {
const fn = vi.fn();
fn("hello");
fn("world");
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenCalledWith("hello");
expect(fn).toHaveBeenLastCalledWith("world");
});
test("mock function with return value", () => {
const fn = vi.fn()
.mockReturnValueOnce(1)
.mockReturnValueOnce(2)
.mockReturnValue(99);
expect(fn()).toBe(1);
expect(fn()).toBe(2);
expect(fn()).toBe(99);
expect(fn()).toBe(99);
});
});
// Mocking modules
import { vi, describe, test, expect, beforeEach } from "vitest";
// Mock an entire module
vi.mock("./database", () => ({
getUser: vi.fn(),
saveUser: vi.fn(),
}));
import { getUser, saveUser } from "./database";
import { updateUserEmail } from "./user-service";
describe("updateUserEmail", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("updates email for existing user", async () => {
vi.mocked(getUser).mockResolvedValue({ id: 1, name: "Alice", email: "old@example.com" });
vi.mocked(saveUser).mockResolvedValue(undefined);
await updateUserEmail(1, "new@example.com");
expect(saveUser).toHaveBeenCalledWith({
id: 1,
name: "Alice",
email: "new@example.com",
});
});
test("throws if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null);
await expect(updateUserEmail(1, "new@example.com"))
.rejects.toThrow("User not found");
});
});
// Mocking timers
import { vi, describe, test, expect, beforeEach, afterEach } from "vitest";
describe("debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllTimers();
});
test("delays execution", () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledOnce();
});
});
Async Testing
// Testing async code
describe("API client", () => {
test("fetches users", async () => {
const users = await fetchUsers();
expect(users).toHaveLength(10);
expect(users[0]).toHaveProperty("name");
});
test("handles network errors", async () => {
vi.spyOn(global, "fetch").mockRejectedValueOnce(new Error("Network error"));
await expect(fetchUsers()).rejects.toThrow("Network error");
vi.restoreAllMocks();
});
});
// Testing with fake fetch responses
describe("GitHub API", () => {
test("parses user data", async () => {
const mockResponse = {
login: "octocat",
id: 1,
name: "The Octocat",
};
vi.spyOn(global, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify(mockResponse), {
headers: { "Content-Type": "application/json" },
})
);
const user = await getGitHubUser("octocat");
expect(user.login).toBe("octocat");
expect(user.name).toBe("The Octocat");
});
});
Snapshot Testing
import { describe, test, expect } from "vitest";
describe("config generator", () => {
test("generates default config", () => {
const config = generateConfig({ projectName: "my-app" });
expect(config).toMatchInlineSnapshot(
{
"name": "my-app",
"version": "1.0.0",
"scripts": {
"build": "vite build",
"dev": "vite",
"test": "vitest",
},
}
);
});
// File snapshots — stored in __snapshots__/
test("generates complex config", () => {
const config = generateFullConfig();
expect(config).toMatchSnapshot();
});
});
Testing React Components
bun add -d @testing-library/react @testing-library/jest-dom jsdom
// vitest.config.ts
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
// src/components/Counter.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, test, expect } from "vitest";
import { Counter } from "./Counter";
describe("Counter", () => {
test("renders initial count", () => {
render(<Counter initialCount={5} />);
expect(screen.getByText("Count: 5")).toBeInTheDocument();
});
test("increments on click", () => {
render(<Counter initialCount={0} />);
fireEvent.click(screen.getByRole("button", { name: /increment/i }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /increment/i }));
expect(screen.getByText("Count: 2")).toBeInTheDocument();
});
test("calls onChange when count changes", () => {
const onChange = vi.fn();
render(<Counter initialCount={0} onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: /increment/i }));
expect(onChange).toHaveBeenCalledWith(1);
});
});
Vitest vs Jest
| Feature | Vitest | Jest |
|---|---|---|
| Speed (with TS/ESM) | Fast (native Vite transforms) | Slow (requires babel/swc transforms) |
| Configuration | Shares Vite config | Separate jest.config |
| ESM support | Native | Experimental, often broken |
| TypeScript | Native via Vite | Requires ts-jest or @swc/jest |
| Watch mode | Instant (Vite HMR) | Full re-transform on change |
| API | Jest-compatible | The original |
| UI | Built-in browser UI | Third-party tools |
| In-source testing | Supported | Not supported |
| Migration from Jest | Minimal changes | N/A |
- Your project uses Vite
- You want fast TypeScript/ESM testing without configuration
- You're starting a new project
- Large existing Jest test suite (migration cost isn't worth it)
- You need specific Jest plugins not yet ported to Vitest
In-Source Testing
Vitest supports writing tests directly in your source files — stripped out in production builds:
// src/utils/format.ts
export function formatCurrency(cents: number): string {
return $${(cents / 100).toFixed(2)};
}
export function formatDate(date: Date): string {
return date.toISOString().split("T")[0];
}
// Tests — only included when running vitest
if (import.meta.vitest) {
const { describe, test, expect } = import.meta.vitest;
describe("formatCurrency", () => {
test("formats cents to dollars", () => {
expect(formatCurrency(1099)).toBe("$10.99");
expect(formatCurrency(500)).toBe("$5.00");
expect(formatCurrency(0)).toBe("$0.00");
});
});
describe("formatDate", () => {
test("formats date to ISO date string", () => {
expect(formatDate(new Date("2026-03-15T10:30:00Z"))).toBe("2026-03-15");
});
});
}
Enable it in your config:
export default defineConfig({
test: {
includeSource: ["src/*/.ts"],
},
define: {
"import.meta.vitest": "undefined", // Strip tests in production
},
});
This pattern works well for utility functions where the tests are small and co-locating them makes the code easier to maintain.
Vitest is the testing framework the JavaScript ecosystem was waiting for — Jest's API with Vite's speed and modern defaults. If you're on Vite, switching is a no-brainer. If you're starting fresh, it should be your default. More testing strategies and tooling guides at CodeUp.