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.
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:
npm install --save-dev jest
# package.json: { "scripts": { "test": "jest" } }
npm testA test file groups related tests with describe, defines each test with it (or test), and asserts with expect:
// 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 totalMatchers you will actually use
Matcher | Checks | Example |
|---|---|---|
| Strict equality ( |
|
| Deep structural equality |
|
| Floating-point equality |
|
| Array/string contains item |
|
| String matches pattern |
|
| Truthiness / null checks |
|
| Function throws |
|
| Mock was called with args |
|
.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.
npm install --save-dev vitest npx vitest # watch mode by default npx vitest run # single run (CI)
// 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 |
|
|
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:
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:
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:
// 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)
})Testing asynchronous code
The golden rule: return or await the promise, otherwise the test finishes before the assertion runs and silently passes.
// ✅ 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:
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()
})getByRole → getByLabelText →getByText → 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:
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 | ----------|---------|----------|---------|---------|
End-to-end testing: Playwright and Cypress
E2E tools drive a real browser against your running app:
// 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.
Red — write a failing test that describes the behavior you want.
Green — write the simplest code that makes the test pass. No gold-plating.
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 documentationRun tests in CI on every push — a test that only runs on your machine protects nobody