Testing JavaScript
Automated tests are the safety net that lets you refactor, upgrade dependencies, and ship features without manually re-checking everything by hand. A good test suite documents how your code is meant to behave and fails loudly the moment a change breaks that contract. This page covers the test runners you will actually use—Jest and Vitest—plus the core concepts (assertions, mocking, coverage) and where browser and end-to-end tools fit in.
The anatomy of a test
Nearly every JavaScript test framework shares the same vocabulary. You group related tests with describe, declare an individual case with it (or its alias test), and make assertions with expect. The pattern reads almost like a sentence: describe the thing, it should do something, you expect a result.
// math.js
export const add = (a, b) => a + b;
// math.test.js
import { describe, it, expect } from 'vitest';
import { add } from './math.js';
describe('add', () => {
it('sums two numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negatives', () => {
expect(add(-4, 1)).toBe(-3);
});
});
A test passes when every assertion inside it holds. If expect(...).toBe(...) fails, the runner reports which case broke, the expected value, and the actual value.
Output:
✓ math.test.js (2)
✓ add (2)
✓ sums two numbers
✓ handles negatives
Test Files 1 passed (1)
Tests 2 passed (2)
With Vitest, the
describe/it/expectglobals are off by default—either import them as above or setglobals: truein your config. Jest exposes them globally with no import.
Unit vs integration tests
Tests fall on a spectrum based on how much of the system they exercise.
- Unit tests verify one function or module in isolation, replacing its collaborators with fakes. They are fast and pinpoint failures precisely.
- Integration tests check that several real units work together—for example a service that calls a database layer—catching wiring bugs a unit test would miss.
- End-to-end (e2e) tests drive the whole app like a user would, in a real browser.
You want many fast unit tests, fewer integration tests, and a handful of e2e tests covering critical flows. This “testing pyramid” keeps the suite fast and the failures meaningful.
Common matchers
Matchers are the assertions chained after expect. The same set works in both Jest and Vitest.
| Matcher | Passes when |
|---|---|
toBe(value) | Strict === equality (primitives, same reference) |
toEqual(value) | Deep structural equality (objects, arrays) |
toBeTruthy() / toBeFalsy() | Value is truthy / falsy |
toContain(item) | Array or string includes the item |
toThrow(msg?) | The wrapped function throws |
toHaveBeenCalledWith(...) | A mock was called with those args |
resolves / rejects | Awaits a promise before matching |
import { expect, it } from 'vitest';
it('compares objects by value', () => {
expect({ id: 1, tags: ['a'] }).toEqual({ id: 1, tags: ['a'] });
});
it('asserts on rejected promises', async () => {
const failing = Promise.reject(new Error('boom'));
await expect(failing).rejects.toThrow('boom');
});
Use toBe for primitives and reference identity; reach for toEqual whenever you compare the contents of objects or arrays.
Mocking
Mocking replaces a real dependency with a controllable stand-in so a unit test stays isolated, deterministic, and fast. You can spy on calls, fake return values, and avoid hitting the network, the clock, or the filesystem.
import { describe, it, expect, vi } from 'vitest';
const fetchUser = async (id, http) => {
const res = await http(`/users/${id}`);
return res.name;
};
describe('fetchUser', () => {
it('calls the API and returns the name', async () => {
const http = vi.fn().mockResolvedValue({ name: 'Ada' });
const name = await fetchUser(7, http);
expect(name).toBe('Ada');
expect(http).toHaveBeenCalledWith('/users/7');
expect(http).toHaveBeenCalledTimes(1);
});
});
vi.fn() (or jest.fn() in Jest) creates a mock function that records every call. You can also replace an entire module with vi.mock('./module.js') and freeze time with vi.useFakeTimers(). The APIs are intentionally near-identical between the two runners.
Coverage
Coverage measures which lines, branches, and functions your tests actually execute. It is a useful gap-finder—not a quality score.
# Vitest
vitest run --coverage
# Jest
jest --coverage
Output:
% Coverage report from v8
-----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------|---------|----------|---------|---------|
All files | 92.3 | 84.6 | 100.0 | 92.3 |
math.js | 100.0 | 100.0 | 100.0 | 100.0 |
-----------|---------|----------|---------|---------|
Chasing 100% coverage often means writing brittle tests for trivial code. Aim for high coverage on logic that matters, and treat low branch coverage as a hint about untested edge cases.
Jest vs Vitest
Both are full-featured runners with assertions, mocking, snapshots, and coverage built in. Jest is the long-standing default in the React/Node world; Vitest is the modern, Vite-native runner with a Jest-compatible API and noticeably faster runs.
| Aspect | Jest | Vitest |
|---|---|---|
| Config | Standalone, separate transform setup | Reuses your existing vite.config |
| Speed | Fast | Faster (esbuild + smart watch) |
| ESM support | Workable but historically fiddly | Native first-class ESM |
| API | jest.fn(), describe/it/expect | Same names via vi.*, drop-in compatible |
| Best for | Existing/CRA/Next.js apps | Vite projects, greenfield, ESM/TS |
If you already build with Vite, Vitest is the natural choice—zero duplicate config and a near-identical API. Choose Jest when your toolchain or framework already standardizes on it.
UI and end-to-end testing
For components that touch the DOM, pair your runner with Testing Library (@testing-library/react, /vue, /dom), which encourages querying the UI the way a user would—by role, label, or text—rather than by implementation detail.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter.jsx';
it('increments on click', async () => {
render(<Counter />);
await userEvent.click(screen.getByRole('button', { name: /add/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
For true end-to-end flows across a real browser—login, checkout, navigation—use Playwright (or Cypress). These launch Chromium, Firefox, and WebKit, interact with your live app, and catch issues no unit test can.
Best Practices
- Test behavior and public APIs, not private implementation details, so refactors do not break tests needlessly.
- Keep tests fast and isolated—mock the network, timers, and randomness rather than depending on the real world.
- Give each test one clear reason to fail; prefer several small
itblocks over one sprawling case. - Follow the pyramid: lots of unit tests, some integration tests, a few high-value e2e tests.
- Use Testing Library queries by role and text to keep UI tests resilient and accessible.
- Run tests in watch mode while coding and in CI on every push to catch regressions early.
- Treat coverage as a guide to find untested branches, not as a target to game.