In this guide
Your current testing process: change code, refresh browser, click around, hope nothing else broke. It works until the project grows past a few files — then every change is scary, because you cannot manually re-verify everything.
Automated tests change that: you write down what the code should do, and the computer re-verifies it in seconds, forever. Jest is the standard way to do this in JavaScript. This guide takes you from zero to a genuinely useful test suite.
What Jest is
Jest is a JavaScript testing framework (originally from Facebook/Meta) that bundles everything you need: a test runner, an assertion library, and mocking — no assembly required. It works with plain Node.js, React, and (with a little config) TypeScript.
npm install --save-dev jest
Tests live in files ending .test.js next to your code, or in a __tests__ folder. Run them with npx jest — or add "test": "jest" to your package.json scripts and run npm test.
Your first test
Say you have a utility function:
// utils/discount.js
function calculateDiscount(price, percent) {
if (price < 0 || percent < 0 || percent > 100) {
throw new Error('Invalid input');
}
return price - (price * percent) / 100;
}
module.exports = { calculateDiscount };
The test file:
// utils/discount.test.js
const { calculateDiscount } = require('./discount');
test('applies 10% discount correctly', () => {
expect(calculateDiscount(1000, 10)).toBe(900);
});
test('handles zero discount', () => {
expect(calculateDiscount(500, 0)).toBe(500);
});
test('rejects invalid percent', () => {
expect(() => calculateDiscount(500, 150)).toThrow('Invalid input');
});
The anatomy: test(name, function) describes one behavior; expect(value) wraps the actual result; matchers like .toBe(), .toEqual(), .toThrow() state what you expect. Jest runs each test and reports pass/fail with a clear diff when something mismatches.
Note:
.toBe()checks exact identity (like===) — right for numbers and strings..toEqual()checks deep equality — right for objects and arrays. Using.toBe()on two different-but-identical objects is a classic beginner failure.
Organizing tests: describe blocks
Group related tests with describe, and share setup with beforeEach:
describe('shopping cart', () => {
let cart;
beforeEach(() => {
cart = createCart(); // fresh cart for every test
});
test('starts empty', () => {
expect(cart.items).toEqual([]);
});
test('adds items', () => {
cart.add({ id: 1, price: 100 });
expect(cart.total()).toBe(100);
});
});
beforeEach runs before every test in the block — each test gets a fresh cart, so tests cannot pollute each other. Test pollution (one test's leftovers breaking another) is a miserable debugging experience; fresh fixtures prevent it.
Mocking: testing without the real world
Your function sends an email via an API. You do not want tests actually sending emails. Mocks replace real dependencies with controlled fakes:
const { checkout } = require('./checkout');
const emailService = require('./emailService');
jest.mock('./emailService'); // auto-mock the whole module
test('checkout sends confirmation email', async () => {
emailService.send.mockResolvedValue({ ok: true });
await checkout({ userId: 42, items: [{ id: 1, price: 100 }] });
expect(emailService.send).toHaveBeenCalledTimes(1);
expect(emailService.send).toHaveBeenCalledWith(
expect.objectContaining({ to: expect.any(String) })
);
});
jest.mock() replaces the module; .mockResolvedValue() controls what the fake returns; toHaveBeenCalledWith verifies how it was called. You test your logic, not the email provider's uptime.
Mock sparingly. Mock at the boundary (network, database, filesystem) — not your own business logic. If you mock everything, your tests verify the mocks, not your code.
Testing async code
Most real code is async. Jest handles promises and async/await naturally — just make the test function async and await:
test('fetches user profile', async () => {
const profile = await getUserProfile(42);
expect(profile.name).toBeDefined();
});
For timers and delays, use fake timers so tests do not actually wait:
jest.useFakeTimers();
test('debounce waits 300ms', () => {
const fn = jest.fn();
const debounced = debounce(fn, 300);
debounced();
debounced();
jest.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});
What to test (and what not to)
| Test this | Skip this |
|---|---|
| Business logic (pricing, validation, permissions) | Third-party libraries (they have their own tests) |
| Edge cases and error paths | Trivial getters/setters |
| Bug regressions (write a test for every bug you fix) | Implementation details that change often |
| Integration points (does the API route call the right service?) | Exact HTML structure of components (brittle) |
The bug-regression habit is the highest-value practice in this guide: every time you fix a bug, write a failing test first, then fix it. Your test suite becomes a permanent record of every bug you have ever fixed — none of them can silently return.
Coverage: useful signal, terrible goal
Jest reports coverage with --coverage: what percentage of your lines/branches ran during tests. Treat it as a discovery tool ("this error branch is never tested") not a target. Chasing 100% coverage produces tests that execute code without verifying behavior — motion without meaning. Aim for meaningful tests on important logic; let coverage inform, not dictate.
Testing React components
Jest pairs with Testing Library for component tests. Its guiding principle: test what the user experiences, not implementation details.
import { render, screen, fireEvent } from '@testing-library/react';
import LoginForm from './LoginForm';
test('shows error on empty submit', () => {
render(<LoginForm onSubmit={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
Notice what the test does not do: no checking internal state, no calling component methods directly, no asserting on CSS class names. It renders, interacts like a user (find by role, click), and asserts on visible text. Refactor the component's internals freely — this test only breaks when user-visible behavior changes, which is exactly when you want to know.
Go easy on snapshots. Snapshot tests (serializing rendered output) catch any change including intentional ones, training developers to blindly update snapshots. A few snapshots for stable presentational components are fine; behavior assertions are the real tests.
Common mistakes
- Testing implementation instead of behavior. Asserting "function X called helper Y twice" breaks when you refactor. Assert on outcomes: inputs → outputs.
- No test for the bug you just fixed. The bug will return. Write the regression test.
- Shared mutable state between tests. Use
beforeEachfor fresh fixtures; never rely on test execution order. - Mocking your own code under test. Mocks belong at boundaries (APIs, DB, filesystem).
- Async tests without awaiting. A test that does not await its promise passes vacuously — Jest finishes before the assertion runs. Always return/await promises.
- Only testing the happy path. The error branches are where production bugs live — invalid input, failed API calls, timeouts.
Quick checklist
- Jest installed;
npm testruns the suite - Tests colocated with code (
.test.js) or in__tests__ -
describe/beforeEachorganize tests with fresh fixtures - External boundaries mocked; business logic tested for real
- Async tests properly awaited; timers faked where needed
- Every fixed bug has a regression test
- Suite runs in CI on every push (see the GitHub Actions guide)
Where to go from here
- Unit tests verify functions; E2E Testing with Cypress verifies the whole app in a real browser.
- Writing tests for typed code: TypeScript for JavaScript Developers.
- Run the suite automatically: GitHub Actions: Build a CI/CD Pipeline.
- Test the app you deploy: how to host a website free as a student.
- More frontend topics in the Web Development branch hub.