JavaScript Testing with Vitest: Complete Guide
Set up Vitest, write fast unit tests with it, and run them with zero config. Covers test blocks, matchers, async testing, mock functions, and coverage reporting.
Vitest is a JavaScript testing framework built on Vite. It runs tests fast, shares your existing Vite configuration, and has an API that is compatible with Jest. If your project already uses Vite, adding Vitest takes one install command and zero configuration.
Setup
Getting Vitest running takes two steps: install the package, then add a script so the rest of the team can run tests the same way. Start by installing Vitest as a dev dependency:
npm install -D vitestThen add a test script to your package.json so the command is easy to run and share with the rest of the team:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run"
}
}The plain vitest command starts in watch mode, rerunning tests automatically whenever a file changes. vitest run runs the suite once and exits, which is the form you want in CI, and adding the --coverage flag to either one turns on code coverage reporting.
That is all the setup. Vitest reads your vite.config.ts automatically. If you need test-specific settings, create a separate vitest.config.ts instead:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true, // use test(), expect(), etc. without importing
environment: "node",
},
});Your first test
Create a test file next to the code it tests with a .test.js or .spec.js extension. Vitest finds these automatically without any manual registration.
// math.js
export function sum(a, b) {
return a + b;
}The test file imports both Vitest's helpers and the function under test, then makes an assertion with expect to describe what a correct result should look like:
// math.test.js
import { expect, test } from "vitest";
import { sum } from "./math.js";
test("adds two positive numbers", () => {
expect(sum(2, 3)).toBe(5);
});Running npm test prints a summary showing each test name with a checkmark, along with a final pass count for the file. A failing assertion shows the expected and actual values side by side instead, which is usually enough to spot the bug without adding a single console.log.
Organizing tests with describe blocks
describe groups related tests together, and a common pattern is to nest describe blocks by function or by scenario:
import { describe, expect, test } from "vitest";
import { formatDate } from "./date-utils.js";
describe("formatDate", () => {
test('returns "YYYY-MM-DD" format', () => {
expect(formatDate("2026-07-15")).toBe("2026-07-15");
});
});Nesting a second describe block for the invalid-input scenarios keeps those related failures grouped together in the output, separate from the valid-input cases above:
describe("formatDate", () => {
describe("with invalid input", () => {
test("throws a TypeError for non-string input", () => {
expect(() => formatDate(123)).toThrow(TypeError);
});
test("throws for an empty string", () => {
expect(() => formatDate("")).toThrow("Date string cannot be empty");
});
});
});The output shows a clear hierarchy. When a test fails, you see the full path, such as formatDate > with invalid input > throws for an empty string, so you know exactly which scenario broke without reading the test body.
Essential matchers
expect provides matchers for every kind of comparison. Here are the ones you will use most:
| Matcher | What it checks |
|---|---|
| toBe(value) | Strict equality (===) |
| toEqual(value) | Deep equality for objects and arrays |
| toBeTruthy() / toBeFalsy() | Value is truthy or falsy |
| toBeNull() / toBeUndefined() | Value is exactly null or undefined |
| toContain(item) | Array or string contains an item |
| toThrow() | Function throws when called |
Use toBe for primitives like numbers and strings, since === is exactly what you want there. Use toEqual for objects and arrays, because it recursively compares each property instead of checking reference identity:
expect({ name: "Alice", age: 30 }).toEqual({ name: "Alice", age: 30 });expect.objectContaining lets a test check only the fields it cares about, ignoring any extra properties on the actual object, which is useful when a function returns more data than the test needs to verify:
expect({ name: "Alice", age: 30, city: "NYC" }).toEqual(
expect.objectContaining({ name: "Alice" })
);Testing async code
For async functions, make the test function async and await the result, the same way you would in any other async code:
async function fetchUserName(api, id) {
const response = await api.get(`/users/${id}`);
return response.data.name;
}A mock object stands in for the real API client, so the test never makes a network call and always sees the same predictable response:
test("returns the user name from the API response", async () => {
const mockApi = {
get: async () => ({ data: { name: "Charlie" } }),
};
const name = await fetchUserName(mockApi, 1);
expect(name).toBe("Charlie");
});For a rejected promise, expect(promise).rejects lets you assert on the rejection directly instead of wrapping the call in a manual try...catch block:
test("throws when the API call fails", async () => {
const mockApi = {
get: async () => { throw new Error("Network error"); },
};
await expect(fetchUserName(mockApi, 1)).rejects.toThrow("Network error");
});Mock functions with vi.fn()
vi.fn creates a mock function that tracks every call made to it, including the arguments passed each time.
function processItems(items, callback) {
return items.map(callback);
}
test("calls the callback once per item", () => {
const callback = vi.fn((item) => item * 2);
processItems([1, 2, 3], callback);
expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenCalledWith(2);
});Because vi.fn wraps a real implementation here, the mock both tracks its calls and still returns a usable value, which is what lets a second test check the actual transformed output:
test("returns transformed items", () => {
const callback = vi.fn((item) => item * 2);
const result = processItems([1, 2, 3], callback);
expect(result).toEqual([2, 4, 6]);
});| Matcher | What it checks |
|---|---|
| toHaveBeenCalled() | Function was called at least once |
| toHaveBeenCalledTimes(n) | Function was called exactly n times |
| toHaveBeenCalledWith(a, b) | Last call received these arguments |
Mocking modules with vi.mock()
To replace an entire module with a mock, use vi.mock. This is useful when the function under test imports a database or API module you do not want to touch in a unit test.
vi.mock("./database.js", () => ({
query: vi.fn(),
}));
import { query } from "./database.js";
import { getUser } from "./user-service.js";With the module mocked, the test controls exactly what the database layer returns and can assert on how it was called:
test("calls database query with the correct SQL", async () => {
query.mockResolvedValue([{ id: 1, name: "Dana" }]);
const user = await getUser(1);
expect(query).toHaveBeenCalledWith("SELECT * FROM users WHERE id = ?", [1]);
expect(user).toEqual({ id: 1, name: "Dana" });
});vi.mock is hoisted to the top of the file automatically, so it runs before the imports that follow it. You can define the mock inline, as shown here, or point it at a separate factory function.
Code coverage
Run vitest run --coverage to generate a coverage report showing which lines of code your tests actually executed:
File | % Stmts | % Branch | % Funcs | % Lines
-------------------|---------|----------|---------|---------
src/math.js | 100 | 100 | 100 | 100
src/date-utils.js | 85.71 | 75 | 80 | 85.71The four columns measure different things: statements executed, conditional branches like if and switch that were tested, functions that were called, and overall lines executed. Coverage is a helpful signal but not a goal.
100% coverage means every line ran at least once, not that every edge case is tested or that every assertion is correct. Use coverage to find untested code, not as a number to chase for its own sake.
The feedback loop is tight: write a function, write a test, run, repeat. Watch mode reruns tests on every save, so results appear in under a second.
For the fundamentals of what to test and how to structure tests, see the unit testing guide. For setting breakpoints inside tests, see debugging with breakpoints.
Rune AI
Key Insights
- Install Vitest with
npm install -D vitestand add atestscript to package.json. - Use
test()orit()to define individual tests; usedescribe()to group related tests. expect(value).toBe(expected)checks strict equality;toEqualchecks deep equality for objects and arrays.- Test async code by making the test function
asyncand awaiting the result. - Use
vi.fn()to create mock functions; assert calls with.toHaveBeenCalled()and.toHaveBeenCalledWith(). - Run
vitest --coverageto see which lines of code your tests cover.
Frequently Asked Questions
How is Vitest different from Jest?
Do I need to create a config file for Vitest?
How do I mock a module with Vitest?
Conclusion
Vitest gives you a fast, modern test runner that feels familiar if you have used Jest but integrates naturally with Vite projects. Write tests with describe blocks, assert with expect matchers, handle async code with async/await, mock dependencies with vi.fn, and check coverage with the --coverage flag. The zero-config setup means you can add tests to any Vite project in under a minute.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.