JavaScriptTesting in JavaScript

JavaScript Testing

Tests are executable proof that your code works — and keeps working after every refactor, dependency upgrade, and teammate's change. In the JavaScript world, testing is a first-class citizen: the ecosystem gives you fast test runners (Jest, Vitest), DOM testing utilities (Testing Library), and full browser automation (Playwright, Cypress).

This page covers the testing pyramid, the core Jest/Vitest API, mocking, async tests, DOM testing, coverage, and the habits that make test suites a joy rather than a chore.

The testing pyramid

Not all tests are equal. The classic testing pyramid says: write many small fast tests, fewer medium tests, and only a handful of slow full-system tests.

Level

What it tests

Speed

Typical tools

Unit

One function or module in isolation

Milliseconds

Jest, Vitest, node:test

Integration

Several units working together (component + store, API + DB)

Fast-ish

Jest/Vitest + Testing Library, supertest

End-to-end (E2E)

The whole app in a real browser, like a user

Seconds per test

Playwright, Cypress

  • Unit tests are your foundation — cheap to write, instant to run, precise when they fail.

  • Integration tests catch the bugs that live between units: wrong wiring, mismatched contracts.

  • E2E tests give the highest confidence but are the slowest and most brittle — reserve them for critical user flows like sign-up and checkout.

Pyramid, not ice-cream cone
A suite with hundreds of E2E tests and no unit tests (the inverted pyramid) is slow, flaky, and painful to debug. Push tests as low in the pyramid as they can go while still catching the bug you care about.
Jest basics: describe, it, expect

Jest popularized the zero-config testing experience and its API became the de facto standard. Install it and point it at files ending in .test.js:

Bash
npm install --save-dev jest
# package.json: { "scripts": { "test": "jest" } }
npm test

A test file groups related tests with describe, defines each test with it (or test), and asserts with expect:

JS
// cart.js
export function totalPrice(items, taxRate = 0) {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0)
  return subtotal * (1 + taxRate)
}

// cart.test.js
import { totalPrice } from './cart.js'

describe('totalPrice', () => {
  it('sums price times quantity', () => {
    const items = [
      { price: 10, qty: 2 },
      { price: 5, qty: 1 },
    ]
    expect(totalPrice(items)).toBe(25)
  })

  it('applies the tax rate', () => {
    expect(totalPrice([{ price: 100, qty: 1 }], 0.2)).toBeCloseTo(120)
  })

  it('returns 0 for an empty cart', () => {
    expect(totalPrice([])).toBe(0)
  })
})
PASS  ./cart.test.js
  totalPrice
    ✓ sums price times quantity (2 ms)
    ✓ applies the tax rate
    ✓ returns 0 for an empty cart

Tests: 3 passed, 3 total
Matchers you will actually use

Matcher

Checks

Example

toBe(x)

Strict equality (===)

expect(2 + 2).toBe(4)

toEqual(obj)

Deep structural equality

expect(user).toEqual({ name: 'Ada' })

toBeCloseTo(n)

Floating-point equality

expect(0.1 + 0.2).toBeCloseTo(0.3)

toContain(x)

Array/string contains item

expect(tags).toContain('js')

toMatch(regex)

String matches pattern

expect(id).toMatch(/^usr_/)

toBeTruthy / toBeNull

Truthiness / null checks

expect(result).not.toBeNull()

toThrow(msg)

Function throws

expect(() => parse('')).toThrow()

toHaveBeenCalledWith

Mock was called with args

expect(spy).toHaveBeenCalledWith(42)

Negate any matcher
Chain .not before any matcher to invert it, for exampleexpect(list).not.toContain(item).
Vitest: the modern alternative

Vitest is a newer runner built on Vite. It's Jest-compatible (same describe/it/expect API) but with native ES modules and TypeScript support, near-instant watch mode, and it reuses your existing vite.config. For new projects — especially anything already using Vite — Vitest is usually the better choice.

Bash
npm install --save-dev vitest
npx vitest        # watch mode by default
npx vitest run    # single run (CI)

JS
// Same API — just import the globals (or enable globals: true in config)
import { describe, it, expect, vi } from 'vitest'

describe('math', () => {
  it('adds', () => {
    expect(1 + 1).toBe(2)
  })
})

Jest

Vitest

ESM & TypeScript

Needs transform config (babel/ts-jest)

Native, zero config

Speed

Good

Very fast watch mode via Vite

Ecosystem

Huge, very mature

Growing fast, Jest-compatible

Mock API

jest.fn(), jest.mock()

vi.fn(), vi.mock() — same shape

Best for

Existing Jest codebases, React Native

New projects, Vite apps

Mocking: fn, spyOn, and module mocks

Unit tests should isolate the code under test from slow or unpredictable dependencies (network, time, random numbers). Mocks stand in for those dependencies and record how they were used.

1. fn() — a standalone fake function:

JS
it('calls the callback once per item', () => {
  const callback = jest.fn()          // vi.fn() in Vitest

  ;[1, 2, 3].forEach(callback)

  expect(callback).toHaveBeenCalledTimes(3)
  expect(callback).toHaveBeenNthCalledWith(1, 1, 0, [1, 2, 3])
})

2. spyOn — wrap a real method so you can observe or override it:

JS
it('logs a warning for expired tokens', () => {
  const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})

  checkToken({ expiresAt: Date.now() - 1000 })

  expect(warnSpy).toHaveBeenCalledWith('Token expired')
  warnSpy.mockRestore()   // put the real console.warn back
})

3. Module mocks — replace an entire import:

JS
// api.js exports fetchUser(); we mock the whole module
jest.mock('./api.js', () => ({
  fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'Ada' }),
}))

import { fetchUser } from './api.js'
import { greetUser } from './greeting.js'

it('greets the fetched user', async () => {
  await expect(greetUser(1)).resolves.toBe('Hello, Ada!')
  expect(fetchUser).toHaveBeenCalledWith(1)
})
Mock sparingly
Every mock is an assumption about how a dependency behaves. Over-mocked tests can pass while the real system is broken. Mock the boundaries (network, filesystem, clock) — not your own business logic.
Testing asynchronous code

The golden rule: return or await the promise, otherwise the test finishes before the assertion runs and silently passes.

JS
// ✅ async/await — the clearest style
it('fetches the user', async () => {
  const user = await fetchUser(1)
  expect(user.name).toBe('Ada')
})

// ✅ resolves / rejects helpers
it('rejects for unknown ids', async () => {
  await expect(fetchUser(-1)).rejects.toThrow('Not found')
})

// ✅ fake timers — test timeouts without waiting
it('retries after 5 seconds', () => {
  jest.useFakeTimers()
  const retry = jest.fn()
  scheduleRetry(retry)

  jest.advanceTimersByTime(5000)
  expect(retry).toHaveBeenCalledTimes(1)
  jest.useRealTimers()
})
DOM testing with Testing Library

For UI code, Testing Library encourages testing what the user sees — text, roles, labels — instead of implementation details like CSS classes or component internals. It runs in a simulated DOM (jsdom or happy-dom), so no browser is needed:

JS
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Counter from './Counter.jsx'

it('increments when the button is clicked', async () => {
  const user = userEvent.setup()
  render(<Counter />)

  expect(screen.getByText('Count: 0')).toBeInTheDocument()

  await user.click(screen.getByRole('button', { name: /increment/i }))

  expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
Query priority
Prefer queries in this order: getByRole getByLabelTextgetByText getByTestId as a last resort. If your test can find the element by role and accessible name, real users (and screen readers) can too.
Code coverage

Coverage measures which lines, branches, and functions your tests execute:

Bash
npx jest --coverage
# or
npx vitest run --coverage
----------|---------|----------|---------|---------|
File      | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
All files |   92.31 |    83.33 |     100 |   92.31 |
 cart.js  |     100 |      100 |     100 |     100 |
 api.js   |   84.62 |    66.67 |     100 |   84.62 |
----------|---------|----------|---------|---------|
Coverage is a flashlight, not a target
100% coverage proves lines were executed — not that they were meaningfully asserted. Chasing a coverage number produces shallow tests. Use coverage to find untested branches, and aim for a pragmatic threshold (often around 80%) on critical code.
End-to-end testing: Playwright and Cypress

E2E tools drive a real browser against your running app:

JS
// Playwright example
import { test, expect } from '@playwright/test'

test('user can sign in', async ({ page }) => {
  await page.goto('https://example.com/login')
  await page.getByLabel('Email').fill('ada@example.com')
  await page.getByLabel('Password').fill('secret123')
  await page.getByRole('button', { name: 'Sign in' }).click()

  await expect(page.getByText('Welcome back, Ada')).toBeVisible()
})

Playwright

Cypress

Browsers

Chromium, Firefox, WebKit

Chromium-family, Firefox, WebKit (experimental)

Architecture

Drives browsers externally, true parallelism

Runs inside the browser

Language

JS/TS, Python, Java, C#

JS/TS only

Strengths

Speed, multi-tab, API testing

Interactive runner, time-travel debugging

The TDD workflow

Test-driven development flips the order: write the test first, watch it fail, then write just enough code to pass.

  1. Red — write a failing test that describes the behavior you want.

  2. Green — write the simplest code that makes the test pass. No gold-plating.

  3. Refactor — clean up the implementation with the test as your safety net.

You don't have to practice strict TDD everywhere, but the red-green-refactor loop shines for bug fixes: first write a test that reproduces the bug (red), then fix it (green). That bug can never silently return.

Best practices
  • Test behavior, not implementation — assert on outputs and effects, not private internals, so refactors don’t break tests

  • One concept per test — a failing test name should tell you exactly what broke

  • Arrange, Act, Assert — structure every test in three visible phases

  • Keep tests independent — no shared mutable state; any test must pass alone or in any order

  • Make failure messages readable — descriptive it(...) names read like documentation

  • Run tests in CI on every push — a test that only runs on your machine protects nobody

The payoff
A trustworthy test suite turns refactoring from a risky gamble into a routine task. When the suite is green, you ship — and sleep — with confidence.