Vitest is the modern, fast, Jest-compatible test framework for JavaScript and TypeScript. It ships with Vite, runs in watch mode, and uses the same describe/it/expect API. This lesson covers the test structure, assertions, async tests, mocking, and how to integrate with your project.
Your first test
Vitest discovers test files matching *.test.js, *.spec.js, or files inside a tests folder. A test file imports the code under test and uses describe to group related cases and it (or test) to define a single case. The expect function gives you the matchers.
// math.js
export function add(a, b) { return a + b; }
export function divide(a, b) { if (b === 0) throw new Error("divide by zero"); return a / b; }
// math.test.js
import { describe, it, expect } from "vitest";
import { add, divide } from "./math.js";
describe("math", () => {
it("adds two numbers", () => {
expect(add(2, 3)).toBe(5);
});
it("throws on divide by zero", () => {
expect(() => divide(1, 0)).toThrow("divide by zero");
});
});
The matchers you will use most
toBe checks reference equality, toEqual checks deep equality, toBeCloseTo compares floats, toContain checks arrays and strings, toMatchObject checks a subset, and toThrow catches errors. For async, use resolves and rejects.
import { describe, it, expect } from "vitest";
describe("matchers", () => {
it("deep equality", () => {
expect({ name: "Asha", age: 28 }).toEqual({ name: "Asha", age: 28 });
});
it("floating point", () => {
expect(0.1 + 0.2).toBeCloseTo(0.3, 5);
});
it("contains", () => {
expect([1, 2, 3]).toContain(2);
expect("JavaScript").toContain("Script");
});
it("subset", () => {
expect({ name: "Asha", age: 28, role: "admin" }).toMatchObject({ name: "Asha" });
});
});
Async tests
An async test returns a promise. Vitest awaits it. Use the resolves and rejects matchers to assert on a promise's outcome without await.
import { describe, it, expect } from "vitest";
async function fetchUser() {
return { id: 1, name: "Asha" };
}
describe("async", () => {
it("resolves with a user", async () => {
const user = await fetchUser();
expect(user.name).toBe("Asha");
});
it("rejects when the API fails", async () => {
await expect(fetchUser()).resolves.toEqual({ id: 1, name: "Asha" });
});
});
Mocking modules and functions
vi.mock replaces a module for the whole file. vi.fn creates a spy you can assert was called. vi.spyOn wraps an existing method. Use mocks to isolate the code under test from I/O and other modules.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendWelcome } from "./notify.js";
import { register } from "./users.js";
vi.mock("./notify.js", () => ({
sendWelcome: vi.fn(),
}));
describe("users.register", () => {
beforeEach(() => {
sendWelcome.mockClear();
});
it("calls sendWelcome on success", async () => {
await register({ email: "[email protected]" });
expect(sendWelcome).toHaveBeenCalledWith("[email protected]");
});
});
Vitest config and scripts
Add a test script to package.json. Vitest reads vite.config.js for resolver settings and aliases. Run in watch mode during development, run once in CI.
// package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
Summary
Vitest gives you describe, it, and expect in a fast, Jest-compatible API. Use the matchers toBe, toEqual, toContain, toMatchObject, resolves, and rejects. Mock modules and functions with vi.mock and vi.fn. Run vitest in watch mode during development and once in CI. The next lesson covers the final project patterns that pull everything together.
Coverage and what to measure
Vitest can collect coverage with the --coverage flag, which uses v8 or istanbul under the hood. The output shows lines, branches, functions, and statements covered. Coverage is a useful signal, but a high coverage number does not guarantee good tests. Aim for tests that exercise realistic paths and edge cases, not just lines. Use coverage to find untested code, then decide whether the untested code is worth testing at all. Pure utilities deserve 100 percent; UI glue code rarely benefits from chasing the last 5 percent.
# Run with coverage
vitest run --coverage
# Output sample
# File | % Stmts | % Branch | % Funcs | % Lines
# math.js | 100 | 100 | 100 | 100
# utils.js | 75 | 50 | 80 | 75
Setup, teardown, and test fixtures
Vitest provides beforeAll, afterAll, beforeEach, and afterEach hooks. Use beforeEach to reset shared state between tests. Use beforeAll for one-time setup like seeding a database. Keep the body of a hook minimal so failures are easy to diagnose. For data fixtures, build them inside each test rather than sharing globally; shared fixtures cause confusing failures when one test mutates the data another test relies on.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
let store;
describe("store", () => {
beforeEach(() => {
store = new Map(); // fresh per test
store.set("a", 1);
store.set("b", 2);
});
afterEach(() => {
store.clear();
});
it("reads", () => {
expect(store.get("a")).toBe(1);
});
it("writes", () => {
store.set("c", 3);
expect(store.size).toBe(3);
});
});
Comparison with Python
Python has several mature test frameworks. pytest is the most popular and uses a similar describe/it style with fixtures and parametrization. unittest is built into the standard library and uses class-based tests with setUp and tearDown. Python's unittest.mock covers the same ground as Vitest's vi.fn and vi.mock, with patch and MagicContext. The biggest practical difference is that Python tests are usually run with the pytest CLI directly, while JavaScript tests typically run through a bundler-aware tool like Vitest that understands ESM, TypeScript, and CSS imports out of the box. Both ecosystems have hundreds of plugins, fixtures, and assertion libraries; both are mature.