Testing JavaScript with Jest and Vitest
Learn how to write reliable unit tests in JavaScript using Jest and Vitest — from basic assertions to mocking, async tests, and code coverage.
Testing is how you prove your code works — and keeps working after changes. A test suite gives you the confidence to refactor aggressively, merge with less fear, and ship faster. This guide focuses on practical patterns you’ll use daily, not testing theory.
Why Test?
Tests solve three concrete problems that every project eventually runs into:
- Confidence in refactoring: change internals without fear of breaking behavior
- Documentation: tests show how code is meant to be used and what edge cases exist
- Catch regressions: a bug fixed with a test stays fixed
The testing pyramid gives you a practical allocation: write many fast unit tests, fewer integration tests, and even fewer end-to-end tests. Unit tests are cheap — fast to run, easy to debug, and highly focused. They’re the foundation everything else rests on.
Setup
# Jest
npm install --save-dev jest @types/jest
# Vitest (for Vite projects)
npm install --save-dev vitest
Add to package.json:
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest --coverage"
}
}
Test Structure: describe / it / expect
The describe / it / expect pattern gives tests a natural language structure. describe groups related tests; it states what the behavior should be; expect asserts that it is. A well-named test reads like a specification: “divide throws on division by zero” tells you what the function does without reading the implementation.
// math.js
export function add(a, b) { return a + b; }
export function divide(a, b) {
if (b === 0) throw new Error("Division by zero");
return a / b;
}
// math.test.js
import { add, divide } from "./math.js";
describe("add", () => {
it("adds two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
it("handles negative numbers", () => {
expect(add(-1, -1)).toBe(-2);
});
});
describe("divide", () => {
it("divides correctly", () => {
expect(divide(10, 2)).toBe(5);
});
it("throws on division by zero", () => {
expect(() => divide(10, 0)).toThrow("Division by zero");
});
});
Common Matchers
Matchers are the vocabulary of assertions. Knowing the right matcher makes tests more readable and gives better failure messages than a generic toBe(true).
// Equality
expect(value).toBe(5); // strict === equality — use for primitives
expect(obj).toEqual({ a: 1 }); // deep equality — use for objects and arrays
expect(obj).toStrictEqual(...); // also checks undefined properties and array holes
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers — use toBeCloseTo for floating point to avoid 0.1 + 0.2 !== 0.3 failures
expect(0.1 + 0.2).toBeCloseTo(0.3, 5);
expect(5).toBeGreaterThan(3);
// Strings
expect("hello world").toMatch(/world/);
expect("hello").toContain("ell");
// Arrays
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
// Objects — partial match: only checks specified keys
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 });
// Errors
expect(() => fn()).toThrow();
expect(() => fn()).toThrow(TypeError);
expect(() => fn()).toThrow("specific message");
Mocking Functions
Mocks let you isolate the unit under test from its dependencies. If you’re testing greetUser, you don’t want the test to make a real HTTP request — you want to control what fetchUser returns and verify that greetUser uses the result correctly. This makes tests fast, deterministic, and focused on a single unit of behavior.
// service.js — the dependency we want to control in tests
export async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// userGreeter.js — the unit we're actually testing
import { fetchUser } from "./service.js";
export async function greetUser(id) {
const user = await fetchUser(id);
return `Hello, ${user.name}!`;
}
// userGreeter.test.js
import { greetUser } from "./userGreeter.js";
import { fetchUser } from "./service.js";
// Replace the entire module with auto-mocked versions
vi.mock("./service.js");
describe("greetUser", () => {
it("greets the user by name", async () => {
// Configure the mock's return value for this specific test
fetchUser.mockResolvedValue({ id: 1, name: "Alice" });
const greeting = await greetUser(1);
expect(greeting).toBe("Hello, Alice!");
expect(fetchUser).toHaveBeenCalledWith(1); // verify it was called correctly
expect(fetchUser).toHaveBeenCalledTimes(1); // verify it was called exactly once
});
it("propagates fetch errors", async () => {
fetchUser.mockRejectedValue(new Error("Network error"));
await expect(greetUser(1)).rejects.toThrow("Network error");
});
});
Mocking Individual Functions with vi.fn() / jest.fn()
When you don’t need to mock an entire module, vi.fn() creates a standalone mock function. This is useful for testing callbacks, event handlers, and any function passed as a dependency:
// Testing a function that accepts a callback
function processItems(items, callback) {
return items.filter((item) => callback(item));
}
it("calls callback for each item and filters correctly", () => {
const mockCallback = vi.fn((item) => item > 2);
const result = processItems([1, 2, 3, 4], mockCallback);
expect(result).toEqual([3, 4]);
expect(mockCallback).toHaveBeenCalledTimes(4); // called once per item
expect(mockCallback).toHaveBeenNthCalledWith(1, 1); // first call received 1
});
Spying on Methods
Spies let you intercept calls to existing methods — verifying they were called with the right arguments — while optionally replacing their behavior. Use spy.mockRestore() to put the original function back after the test:
import * as fs from "node:fs";
it("reads the config file", () => {
// Replace fs.readFileSync for this test — don't actually read a file
const spy = vi.spyOn(fs, "readFileSync").mockReturnValue('{ "port": 3000 }');
const config = loadConfig("/etc/app/config.json");
expect(spy).toHaveBeenCalledWith("/etc/app/config.json", "utf8");
expect(config.port).toBe(3000);
spy.mockRestore(); // restore the original fs.readFileSync after the test
});
Async Tests
Testing async code requires either returning a Promise or using async/await. If you forget to await, the test completes before the assertions run — and silently passes even when behavior is broken.
// Option 1: async/await — preferred for readability
it("fetches and transforms data", async () => {
const data = await fetchData("https://api.example.com/items");
expect(data).toHaveLength(10);
});
// Option 2: returning a Promise directly
it("resolves with the correct value", () => {
return expect(Promise.resolve(42)).resolves.toBe(42);
});
// Testing rejection
it("rejects on bad input", async () => {
await expect(validateEmail("not-an-email")).rejects.toThrow("Invalid email");
});
// Fake timers — test time-dependent code without actually waiting
it("debounce only fires once after delay", () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
debounced();
debounced(); // only the last call should result in fn being called
expect(fn).not.toHaveBeenCalled(); // timer hasn't fired yet
vi.advanceTimersByTime(300); // fast-forward 300ms
expect(fn).toHaveBeenCalledTimes(1); // fired exactly once
vi.useRealTimers();
});
Setup and Teardown
beforeAll / afterAll run once around the entire test suite. beforeEach / afterEach run around every individual test. Use beforeEach to reset state between tests — shared state is the most common cause of flaky tests that pass in isolation but fail when run together.
describe("database tests", () => {
let db;
beforeAll(async () => {
db = await createTestDatabase(); // expensive setup — do once
});
afterAll(async () => {
await db.close(); // always clean up resources
});
beforeEach(async () => {
await db.seed(); // fresh, known data before each test
});
afterEach(async () => {
await db.truncate(); // clean slate after each test
});
it("inserts a record", async () => {
await db.insert({ name: "Alice" });
const rows = await db.query("SELECT * FROM users");
expect(rows).toHaveLength(1);
});
});
Snapshot Testing
Snapshots capture the serialized output of a function and automatically fail if it changes on a subsequent run. They’re useful for testing rendered HTML, JSON API shapes, and CLI output — cases where you want to detect unexpected changes. They’re not a substitute for logic tests: a snapshot just tells you something changed, not whether the change was correct.
it("renders the user card correctly", () => {
const html = renderUserCard({ name: "Alice", role: "admin" });
expect(html).toMatchInlineSnapshot(`
"<div class="card">
<h2>Alice</h2>
<span class="badge">admin</span>
</div>"
`);
});
Run vitest --update (or jest --updateSnapshot) to regenerate snapshots after intentional changes.
Code Coverage
Coverage reports show which lines, branches, and functions executed during your test suite. They’re useful for spotting completely untested paths, but the number itself is not the goal — a test that just calls a function without asserting anything raises coverage without adding any confidence.
vitest --coverage
# or
jest --coverage
Focus on covering critical paths, error branches, and edge cases. An 80% coverage score with high-confidence tests beats 100% with trivial ones.
Jest vs Vitest Quick Reference
| Feature | Jest | Vitest |
|---|---|---|
| Config reuse | Separate config | Reuses vite.config.ts |
| ESM support | Requires transform | Native |
| Speed | Good | Faster (esbuild) |
| API | jest.fn() | vi.fn() |
| Watch mode | --watch | --watch (smarter with HMR) |
| Built-in coverage | Istanbul | Istanbul (or v8) |
The APIs are almost identical. Switching between them usually means a global find/replace of jest → vi.
Common Pitfalls
- Forgetting to await async tests: if you forget
await, the test passes before the assertion runs — a false positive that hides real failures. - Sharing state between tests: always reset mocks in
beforeEachor setclearMocks: truein config. Tests that depend on each other’s execution order are fragile. - Testing implementation details: test behavior (outputs, side effects), not internal variable names or which private method was called. Tests tied to implementation break on every refactor.
- Over-mocking: if you mock everything, you’re not testing real integration. Mock at the boundary — external services, filesystem, time — and let real application code run.