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.

8 min read

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:

texttext
npm install -D vitest

Then add a test script to your package.json so the command is easy to run and share with the rest of the team:

jsonjson
{
  "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:

typescripttypescript
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.

javascriptjavascript
// 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:

javascriptjavascript
// 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:

javascriptjavascript
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:

javascriptjavascript
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:

MatcherWhat 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:

javascriptjavascript
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:

javascriptjavascript
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:

javascriptjavascript
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:

javascriptjavascript
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:

javascriptjavascript
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.

javascriptjavascript
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:

javascriptjavascript
test("returns transformed items", () => {
  const callback = vi.fn((item) => item * 2);
  const result = processItems([1, 2, 3], callback);
 
  expect(result).toEqual([2, 4, 6]);
});
MatcherWhat 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.

javascriptjavascript
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:

javascriptjavascript
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:

texttext
File               | % Stmts | % Branch | % Funcs | % Lines
-------------------|---------|----------|---------|---------
src/math.js        |     100 |      100 |     100 |     100
src/date-utils.js  |   85.71 |       75 |      80 |   85.71

The 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.

Vitest testing workflow

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

Rune AI

Key Insights

  • Install Vitest with npm install -D vitest and add a test script to package.json.
  • Use test() or it() to define individual tests; use describe() to group related tests.
  • expect(value).toBe(expected) checks strict equality; toEqual checks deep equality for objects and arrays.
  • Test async code by making the test function async and awaiting the result.
  • Use vi.fn() to create mock functions; assert calls with .toHaveBeenCalled() and .toHaveBeenCalledWith().
  • Run vitest --coverage to see which lines of code your tests cover.
RunePowered by Rune AI

Frequently Asked Questions

How is Vitest different from Jest?

Vitest is built on Vite, so it starts faster and shares your Vite config. Its API is almost identical to Jest, which makes migration easy. Vitest also supports native ESM, TypeScript out of the box, and hot module replacement in watch mode.

Do I need to create a config file for Vitest?

No. Vitest reads your existing vite.config.ts by default. You only need a vitest.config.ts if you want test-specific settings that differ from your build config.

How do I mock a module with Vitest?

Use vi.mock('module-path') to mock an entire module, or vi.fn() to create a mock function. Vitest automatically hoists vi.mock() calls to the top of the file so mocks are in place before imports run.

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.