What you'll learn
Quick Answer
Vitest is a test runner built on top of Vite. It runs tests through the same transform pipeline as your app, so ES modules, TypeScript, JSX, and path aliases work with no extra configuration. The API -describe,test,expect,vi.fn- is close enough to Jest that most tests move over unchanged, and watch mode reruns only the tests affected by each edit.
Why Vitest instead of Jest
Jest was built in the CommonJS era. To test modern code with it you configure Babel or ts-jest separately from your build, and keep the two in sync by hand - a common source of "works in the app, breaks in tests" bugs.
Vitest reuses your vite.config.js. The same plugins, path aliases, and environment variables that build your app also transform your tests, so there is nothing to keep in sync. It runs ES modules and TypeScript natively, and its watch mode uses Vite's module graph to rerun only the tests touched by a change, which keeps the feedback loop fast.
The API deliberately mirrors Jest, so describe, it, expect, and most matchers behave identically. If your project is not on Vite, Vitest still works standalone - it bundles its own Vite instance - but the config sharing is the main reason to pick it.
Setup and your first test
Install Vitest and add two scripts:
npm install -D vitest// package.json
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}Write a test file next to the code, named *.test.js or *.test.ts:
import { describe, it, expect } from "vitest";
import { add } from "./math.js";
describe("add", () => {
it("sums two numbers", () => {
expect(add(2, 3)).toBe(5);
});
});it and test are the same function; describe groups related tests. If you would rather not import describe, it, and expect in every file, set test: { globals: true } in your Vitest config and they become available globally, Jest-style. Vitest reads that config from a vitest.config.ts file, or from a test key inside an existing vite.config.ts - you do not need both. Run npm test for a single pass, or npm run test:watch while developing so tests rerun on save.
Matchers you will use constantly
The one that catches everyone: toBe compares with Object.is, which is identity, not structure. It works for primitives but fails for objects and arrays even when their contents are identical:
expect(2 + 3).toBe(5); // passes
expect({ id: 1 }).toBe({ id: 1 }); // FAILS - different references
expect({ id: 1 }).toEqual({ id: 1 }); // passes - compares contentsUse toEqual for objects and arrays. toStrictEqual is stricter still - it also fails if one side has an undefined property the other lacks, or if the types differ (a class instance versus a plain object).
toThrow needs a function, not a value:
expect(() => parseConfig("bad")).toThrow("invalid"); // correct
expect(parseConfig("bad")).toThrow(); // wrongIn the wrong version, parseConfig("bad") runs immediately and its error propagates out before expect gets a chance to catch it, so the test errors instead of asserting. Wrap the call in an arrow function so Vitest controls when it runs. Other everyday matchers: toContain, toHaveLength, toMatchObject, toBeNull, and toHaveBeenCalledWith for mocks.
Testing async code
The clean way is async/await plus Vitest's promise matchers:
import { it, expect } from "vitest";
import { fetchUser } from "./api.js";
it("resolves with a user", async () => {
await expect(fetchUser(1)).resolves.toMatchObject({ id: 1 });
});
it("rejects on a bad id", async () => {
await expect(fetchUser(-1)).rejects.toThrow("invalid id");
});The trap is asserting inside a .then() without returning the promise:
it("checks the name", () => {
fetchUser(1).then((user) => {
expect(user.name).toBe("Wrong Name"); // never fails the test
});
});The test function returns before the promise settles, so the assertion runs after Vitest has already marked the test passed. Vitest prints the AssertionError to the console, but the test stays green. Either return fetchUser(1).then(...) or, better, use async/await - and keep the leading await on the whole expect(...).rejects chain, since it is easy to drop. As a backstop, add expect.assertions(1) at the top of a test with an async branch; it fails the test if that many assertions did not run.
Mocking with vi
vi.fn() creates a standalone mock function that records how it was called:
import { vi, expect, it } from "vitest";
it("calls the callback for each item", () => {
const cb = vi.fn();
[10, 20].forEach(cb);
expect(cb).toHaveBeenCalledTimes(2);
expect(cb).toHaveBeenCalledWith(10, 0, [10, 20]);
});Control its return value with cb.mockReturnValue(42) or cb.mockResolvedValue(data) for an async result.
vi.spyOn(object, "method") wraps a real method so you can assert on it and optionally replace it:
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
doSomethingThatWarns();
expect(spy).toHaveBeenCalledOnce();
spy.mockRestore(); // put the real console.warn backvi.mock("./path") replaces an entire module - useful for stubbing a network client. It is hoisted above the imports in the file, so it applies even to modules imported at the top. For fake time, vi.useFakeTimers() with vi.advanceTimersByTime(1000) lets you test a setTimeout without actually waiting.
Config gotchas that waste an afternoon
Bare vitest starts watch mode. Run vitest in a terminal and it runs your tests, then stays open watching for changes - it never exits. In CI that means the job hangs until it times out. Vitest does switch to a single run automatically when it detects a non-interactive terminal or the CI environment variable, but the reliable fix is to be explicit: "test": "vitest run" for CI and npm test, "test:watch": "vitest" for development.
Mock state is not reset between tests. A vi.fn() keeps its call history for the life of the file:
const save = vi.fn();
it("first", () => { save(); expect(save).toHaveBeenCalledTimes(1); });
it("second", () => { expect(save).toHaveBeenCalledTimes(1); }); // still 1, not 0The second test sees the call from the first. Set test: { clearMocks: true } in your config to reset call history before every test, or restoreMocks: true to also restore original implementations that spyOn replaced. Or call vi.clearAllMocks() in a beforeEach.
The default environment is node. There is no document or window, so component tests fail with "document is not defined". Set test: { environment: "jsdom" } (after npm install -D jsdom) globally, or add // @vitest-environment jsdom as the first line of a specific test file.
