articles / A Practical Approach to JavaScript Testing

A Practical Approach to JavaScript Testing

A Practical Approach to JavaScript Testing

Automated tests exist to answer one question quickly: did this change break anything that used to work? A good test suite turns that question from a manual afternoon of clicking into a few seconds of running a command. The hard part is not the syntax, which is small, but knowing what is worth testing and at what level.

The three common levels

Tests are usually grouped by scope. Unit tests check one function or module in isolation and run in milliseconds. Integration tests check that several pieces work together, such as a component reading from a data layer. End-to-end tests drive the real application in a browser and verify a full user flow. Each level catches different bugs and costs a different amount to run and maintain.

A sound default is many fast unit tests, fewer integration tests, and a small set of end-to-end tests for the flows that matter most. Inverting that ratio produces a slow suite that people stop running.
The testing pyramid A pyramid with three layers. The wide base is unit tests, the most numerous and fastest. The middle is integration tests. The narrow top is end-to-end tests, the fewest and slowest. End-to-end Integration Unit many · fast · cheap few · slow · costly fewer, slower more, faster
A sound default: many fast unit tests, fewer integration tests, a small set of end-to-end tests for the flows that matter most.

Anatomy of a test

Most test frameworks share the same shape: a function that names the case, an action, and an assertion about the result. The arrange-act-assert structure keeps each test readable.

import { describe, it, expect } from 'vitest';
import { clamp } from './math.js';

describe('clamp', () => {
  it('caps a value at the maximum', () => {
    expect(clamp(120, 0, 100)).toBe(100);
  });

  it('raises a value to the minimum', () => {
    expect(clamp(-5, 0, 100)).toBe(0);
  });
});

Each it block is one case with a clear name. When it fails, the name alone often tells you what broke, which is why naming cases as a sentence pays off.

Testing the unhappy paths

The cases that catch real bugs are usually the awkward ones: empty input, a value at the boundary, a rejected promise, an unexpected type. Writing a test for the boundary of clamp above is more valuable than testing a comfortable middle value, because the middle rarely breaks.

it('rejects when the user is missing', async () => {
  await expect(loadUser(999)).rejects.toThrow('Request failed');
});

Test doubles, used sparingly

Some code talks to things that are slow, unpredictable, or have side effects: a network call, a clock, a database. A test double stands in for one of those during a test so the result is fast and deterministic. A stub returns a canned value; a mock also records how it was called so the test can assert on it.

import { vi, it, expect } from 'vitest';

it('retries once on failure', async () => {
  const api = vi.fn()
    .mockRejectedValueOnce(new Error('flaky'))
    .mockResolvedValueOnce({ ok: true });

  const result = await withRetry(api);
  expect(result.ok).toBe(true);
  expect(api).toHaveBeenCalledTimes(2);
});

The risk is overuse. A test that mocks everything ends up checking that the code calls the functions the test told it to call, which proves little. The useful rule is to replace only the awkward boundary, such as the network, and let the real logic run.

The feedback loop is the point

A modern runner watches files and re-runs only the affected tests as code is saved, so the result appears within a second of a change. That speed is what makes testing a habit rather than a chore. The same runner produces a coverage report showing which lines ran during the suite, which is useful for spotting untested branches, though a high percentage is not a goal in itself.

Start small. A handful of unit tests around the trickiest function in a project delivers most of the safety, and the suite can grow from there. Tests that are fast and focused get run; slow, brittle ones get ignored, which is the same as having none.

Common questions

What is the difference between unit, integration, and end-to-end tests?

Unit tests check one function in isolation and are very fast. Integration tests check that several pieces work together. End-to-end tests drive the whole application in a browser to verify a complete user flow. Each catches different problems.

How much test coverage should I aim for?

Coverage is a diagnostic, not a target. It is useful for finding untested branches, but a high percentage does not guarantee good tests. Focus on covering the tricky logic and the failure paths rather than chasing a number.

What should I test first in an existing project?

Start with the most complex or most frequently changed function, and write tests for its boundary cases and error paths. A few focused unit tests there deliver most of the safety for the least effort.

Why do fast tests matter so much?

A suite that runs in seconds gets run on every change, so it catches regressions immediately. A slow suite gets skipped, which provides no protection. Speed is what turns testing into a reliable habit.