Skip to content
Codeloom
Testing

Vitest Basics: Fast Unit Tests for JavaScript

A practical introduction to Vitest — why it exists, installation, describe and it, expect matchers, watch mode, and a small mock example for testing modern JavaScript.

·9 min read · By Codeloom
Beginner 10 min read

What you'll learn

  • What Vitest is and why teams choose it over Jest
  • How to install Vitest in any modern JavaScript project
  • The describe / it / expect syntax and common matchers
  • How watch mode keeps the feedback loop tight
  • How to test a simple module end to end
  • How to mock a dependency with vi.mock

Prerequisites

  • Comfortable writing modern JavaScript (ES modules)
  • Familiarity with npm and the command line
  • Optional: What Is Automated Testing? for background

Vitest is a fast, modern JavaScript test runner built on top of Vite. If you have written Jest tests before, the syntax will feel almost identical — but Vitest starts instantly, runs in watch mode without complaint, and handles ESM, TypeScript, and JSX without configuration. This post walks through the parts you will actually use.

If you have not read What Is Automated Testing? yet, that post covers the why. This one is the how for JavaScript.

Why Vitest

For most of the 2010s, Jest was the default. It still works, but it predates modern ESM and has gathered a lot of configuration weight along the way. Vitest came along with three meaningful advantages:

  • Native ESM and TypeScript. No Babel, no transform config, no esbuild-jest. It just runs your source.
  • Fast startup and watch mode. The first test run feels instant; subsequent runs are diffed and re-run intelligently.
  • Jest-compatible API. describe, it, expect, vi.mock — the shape is identical, so migration is mostly find-and-replace.

If you are starting a new project today, Vitest is the safer default. Jest still wins on a couple of niche features (snapshot serialisers, some legacy mocking patterns), but the gap is small.

Install

Inside any npm project:

npm install -D vitest

Add a script to package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "coverage": "vitest run --coverage"
  }
}

vitest starts the interactive watcher. vitest run runs the suite once and exits — what you want in CI.

No config file is required for a basic setup. If you do want one, create vitest.config.ts:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,        // enable describe/it/expect without imports
    environment: "node",  // or "jsdom" for browser-ish APIs
  },
});

With globals: true, you do not need to import describe, it, and expect in every test file.

Your first test

Create src/math.js:

export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

Create src/math.test.js next to it:

import { describe, it, expect } from "vitest";
import { add, multiply } from "./math.js";

describe("add", () => {
  it("returns the sum of two numbers", () => {
    expect(add(2, 3)).toBe(5);
  });

  it("handles negatives", () => {
    expect(add(-1, 1)).toBe(0);
  });
});

describe("multiply", () => {
  it("returns the product", () => {
    expect(multiply(4, 5)).toBe(20);
  });
});

Run:

npm test

You should see three green ticks. Edit a test, save, and Vitest re-runs only the affected files automatically.

describe, it, and expect

The shape never gets more complicated than this:

  • describe(name, fn) groups related tests. You can nest them.
  • it(name, fn) declares one test. You can also write test(name, fn) — they are aliases.
  • expect(value).matcher(args) asserts something about a value.

A nested example:

describe("Cart", () => {
  describe("when empty", () => {
    it("has a total of zero", () => {
      expect(new Cart().total()).toBe(0);
    });
  });

  describe("with one item", () => {
    it("returns the item price as the total", () => {
      const cart = new Cart();
      cart.add({ price: 10 });
      expect(cart.total()).toBe(10);
    });
  });
});

The nesting reads like a sentence: “Cart, when empty, has a total of zero.” That readability pays off when a test fails six months later.

Common matchers

The vast majority of tests use ten or so matchers. Worth memorising:

expect(x).toBe(5);                 // strict equality (===)
expect(obj).toEqual({ a: 1 });     // deep equality
expect(x).not.toBe(5);             // negation

expect(x).toBeTruthy();
expect(x).toBeFalsy();
expect(x).toBeNull();
expect(x).toBeUndefined();
expect(x).toBeDefined();

expect(n).toBeGreaterThan(10);
expect(n).toBeLessThanOrEqual(100);
expect(n).toBeCloseTo(0.3, 5);     // floating-point tolerance

expect(str).toContain("hello");
expect(str).toMatch(/^hello/);

expect(arr).toHaveLength(3);
expect(arr).toContain("apple");

expect(obj).toHaveProperty("user.name", "Ada");

expect(() => doBadThing()).toThrow("not allowed");

toBe checks reference equality and is right for primitives. For objects and arrays, use toEqual so the contents are compared, not the references.

Try it yourself. Create a slugify(title) function that lowercases, trims, and replaces spaces with hyphens. Write five tests: a plain title, a title with extra whitespace, an all-caps title, an empty string, and a title with punctuation. Use toBe for the string comparisons and toEqual if you batch results into an array.

Async tests

If the code under test returns a promise, mark the test async and await it:

import { fetchUser } from "./api.js";

it("returns the user", async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe("Ada");
});

For testing rejections:

await expect(fetchUser(-1)).rejects.toThrow("Invalid id");

For more on async patterns, see JavaScript Async/Await.

Setup and teardown

beforeEach and afterEach run around every test in a describe block. beforeAll and afterAll run once for the block.

describe("Cart", () => {
  let cart;

  beforeEach(() => {
    cart = new Cart();
  });

  it("starts empty", () => {
    expect(cart.items).toEqual([]);
  });

  it("accepts an item", () => {
    cart.add({ price: 5 });
    expect(cart.items).toHaveLength(1);
  });
});

Each it gets a fresh cart. No state leaks between tests.

Watch mode

Run npm test (without run) to enter watch mode. Vitest watches your files and re-runs only the tests affected by your changes. The terminal becomes interactive:

  • Press a to run all tests
  • Press f to run only failed tests
  • Press p to filter by file path
  • Press t to filter by test name
  • Press q to quit

Watch mode plus a fast test suite is the single biggest productivity boost in modern JavaScript development. Save a file; see green or red within a second.

Mocking a dependency

Real code calls out to other modules — a database, an HTTP client, a logger. To test the wrapper without hitting the real thing, mock the dependency.

// src/notify.js
import { sendEmail } from "./email.js";

export async function notifyUser(user) {
  await sendEmail({
    to: user.email,
    subject: "Welcome",
    body: `Hi ${user.name}!`,
  });
}

A test that mocks sendEmail:

// src/notify.test.js
import { describe, it, expect, vi } from "vitest";
import { notifyUser } from "./notify.js";
import { sendEmail } from "./email.js";

vi.mock("./email.js", () => ({
  sendEmail: vi.fn().mockResolvedValue(undefined),
}));

describe("notifyUser", () => {
  it("sends a welcome email", async () => {
    await notifyUser({ name: "Ada", email: "ada@example.com" });

    expect(sendEmail).toHaveBeenCalledTimes(1);
    expect(sendEmail).toHaveBeenCalledWith({
      to: "ada@example.com",
      subject: "Welcome",
      body: "Hi Ada!",
    });
  });
});

What is happening:

  • vi.mock(path, factory) replaces the module before the test imports it
  • vi.fn() creates a fake function with tracking
  • mockResolvedValue makes it return a resolved promise
  • toHaveBeenCalledWith asserts the exact arguments

Mock only what you need to. Over-mocking creates tests that pass when the real code is broken. A common rule: mock at the network or filesystem boundary, not in the middle of your own code.

Spies

When you do not want to replace a function but do want to observe it, use a spy:

import { describe, it, expect, vi } from "vitest";
import * as logger from "./logger.js";

it("logs an info message", () => {
  const spy = vi.spyOn(logger, "info").mockImplementation(() => {});

  doSomethingThatLogs();

  expect(spy).toHaveBeenCalledWith("Doing something");
  spy.mockRestore();
});

Spies are useful for verifying that a side effect happened without asserting on globals or capturing stdout.

Try it yourself. Write a chargeCustomer(user, amount) function that calls a paymentClient.charge() dependency. Mock the payment client with vi.fn() and write three tests: one for a successful charge, one for a failed charge (the client rejects), and one that verifies the exact arguments passed to charge().

Code coverage

Vitest has built-in coverage reporting via V8 or Istanbul:

npm install -D @vitest/coverage-v8
npm run coverage

You will get a report showing which lines, branches, and functions are covered. Coverage is a guide, not a goal — 100% covered code can still be buggy, and 60% covered code that targets the risky parts can be excellent. Use coverage to find untested files, not to chase a number.

A small project layout

A typical Vitest project:

my-app/
├── package.json
├── vitest.config.ts
└── src/
    ├── cart.js
    ├── cart.test.js
    ├── pricing.js
    └── pricing.test.js

Co-locating tests next to the code (the *.test.js convention) keeps related files together and makes it easy to spot files without tests. Some teams prefer a parallel tests/ folder; either works.

What to skip for now

Vitest, like Jest, has a deep API. For your first few projects, you can ignore:

  • Snapshot tests (use sparingly — they tend to be re-approved without thought)
  • Custom matchers (the built-ins cover almost everything)
  • The browser mode (use Playwright for real browser testing)
  • Test reporters beyond the default

The core — describe, it, expect, vi.mock, watch mode — is enough for the first year of serious test-writing.

Recap

You now know:

  • Vitest is a fast, ESM-native test runner with a Jest-compatible API
  • describe groups tests; it declares them; expect(...)... asserts
  • A small set of matchers — toBe, toEqual, toThrow, toHaveBeenCalled — covers most needs
  • Watch mode re-runs affected tests on every save
  • vi.mock replaces a module so you can test code without its real dependencies

Next steps

Write tests for a real module in a project of yours — start with a pure function, add five tests, and watch them run. From there, expand outward into modules with dependencies and learn vi.mock as you need it.

For a Python equivalent, see Pytest Basics. For the broader picture, see What Is Automated Testing?.

Related: JavaScript Async/Await.

Questions or feedback? Email codeloomdevv@gmail.com.