Skip to content
JavaScript js tooling 5 min read

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/expect globals are off by default—either import them as above or set globals: true in 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.

MatcherPasses 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 / rejectsAwaits 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.

AspectJestVitest
ConfigStandalone, separate transform setupReuses your existing vite.config
SpeedFastFaster (esbuild + smart watch)
ESM supportWorkable but historically fiddlyNative first-class ESM
APIjest.fn(), describe/it/expectSame names via vi.*, drop-in compatible
Best forExisting/CRA/Next.js appsVite 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 it blocks 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.
Last updated June 1, 2026
Was this helpful?