{ } JEST • TESTING • TDD
Testing

Unit Testing in JavaScript with Jest

Mayur Dabhi
Mayur Dabhi
March 27, 2026
22 min read

You change one helper function, push, and a page you didn't even touch breaks in production. Or you want to refactor a messy module but you're scared to, because the only way to know it still works is clicking through the app by hand. Unit tests fix both problems, and in JavaScript the tool most teams reach for is Jest, the test runner originally built at Facebook (now Meta). It ships with the runner, assertions, mocks and coverage in one package, so most projects need little or no config to start.

This post walks through setting Jest up, writing tests, the matchers you'll use daily, mocking, async tests, snapshots, coverage and a bit of TDD, plus a few gotchas that tend to bite people the first time.

What You'll Learn
  • Setting up Jest in JavaScript and TypeScript projects
  • Writing effective test cases with describe and it blocks
  • Mastering Jest matchers for all assertion types
  • Mocking functions, modules, and API calls
  • Testing asynchronous code with async/await
  • Snapshot testing for UI components
  • Code coverage configuration and best practices
  • Test-Driven Development (TDD) workflow

Why Jest?

The main reason is that you install one thing. A classic Node testing setup meant wiring together Mocha, Chai, Sinon and Istanbul and keeping their versions happy with each other. Jest bundles the runner, assertions, mocking and coverage, and they all share the same config and CLI.

Jest vs Traditional Testing Stack Traditional Setup Mocha (Test Runner) Chai (Assertions) Sinon (Mocking) Istanbul (Coverage) Jest (All-in-One) Test Runner Built-in Assertions Powerful Mocking Code Coverage Snapshot Testing JEST

One dependency instead of four

Zero Config

Works out of the box for most JavaScript projects. Just install and run.

Fast Parallel Testing

Test files run in parallel across worker processes, so big suites finish faster.

Snapshot Testing

Capture component output and detect unexpected changes automatically.

Watch Mode

Reruns only the tests related to files you changed (uses git to figure that out).

Getting Started with Jest

Install it as a dev dependency. For a plain JavaScript project that's genuinely all you need.

Installation

Terminal
# Create a new project
mkdir my-project && cd my-project
npm init -y

# Install Jest
npm install --save-dev jest

# For TypeScript projects
npm install --save-dev jest @types/jest ts-jest typescript

Configuration

Add a test script to your package.json:

package.json
{
  "name": "my-project",
  "version": "1.0.0",
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "devDependencies": {
    "jest": "^29.7.0"
  }
}
Pro Tip

You can skip jest.config.js until you actually need it: TypeScript via ts-jest, a jsdom test environment for browser code, or path aliases like @/components. If your app uses aliases and Jest says Cannot find module '@/utils', you need a matching moduleNameMapper entry.

Writing Your First Test

Jest follows a simple naming convention: test files should be named *.test.js or *.spec.js, or placed in a __tests__ directory. Here's a small math module and its tests:

math.js
// A simple module to test
function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

function multiply(a, b) {
  return a * b;
}

function divide(a, b) {
  if (b === 0) {
    throw new Error('Cannot divide by zero');
  }
  return a / b;
}

module.exports = { add, subtract, multiply, divide };
math.test.js
const { add, subtract, multiply, divide } = require('./math');

describe('Math functions', () => {
  describe('add', () => {
    it('should add two positive numbers', () => {
      expect(add(2, 3)).toBe(5);
    });

    it('should add negative numbers', () => {
      expect(add(-1, -2)).toBe(-3);
    });

    it('should handle zero', () => {
      expect(add(5, 0)).toBe(5);
    });
  });

  describe('divide', () => {
    it('should divide two numbers', () => {
      expect(divide(10, 2)).toBe(5);
    });

    it('should throw error when dividing by zero', () => {
      expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
    });
  });
});

Run your tests with:

$ npm test

PASS ./math.test.js
Math functions
add
✓ should add two positive numbers (2 ms)
✓ should add negative numbers
✓ should handle zero
divide
✓ should divide two numbers (1 ms)
✓ should throw error when dividing by zero

Test Suites: 1 passed, 1 total
Tests: 5 passed, 5 total
Time: 0.892 s

Jest Matchers

Every assertion is expect(value) followed by a matcher. There are a lot of them, but in day-to-day work you'll mostly use a dozen or so. Here's how they group:

Jest Matchers Categories Common Matchers .toBe() .toEqual() .toBeNull() .toBeUndefined() .toBeDefined() Truthiness .toBeTruthy() .toBeFalsy() .not.toBe() .toBeNull() .toBeNaN() Numbers .toBeGreaterThan() .toBeLessThan() .toBeGreaterThanOrEqual() .toBeCloseTo() .toBeLessThanOrEqual() Strings .toMatch(/regex/) .toContain() .toHaveLength() .toMatchSnapshot() .toMatchInlineSnapshot() Arrays & Objects .toContain() .toContainEqual() .toHaveProperty() .toMatchObject() .toHaveLength() Exceptions .toThrow() .toThrowError() .toThrowErrorMatchingSnapshot() .rejects.toThrow() .resolves.toBe()

Matchers grouped by what they check

Common Matchers Examples

// toBe uses Object.is (strict equality)
expect(2 + 2).toBe(4);
expect('hello').toBe('hello');

// toEqual recursively checks object equality
expect({ name: 'John', age: 30 }).toEqual({ name: 'John', age: 30 });
expect([1, 2, 3]).toEqual([1, 2, 3]);

// toStrictEqual also checks for undefined properties
expect({ a: undefined, b: 2 }).not.toStrictEqual({ b: 2 });
// Truthiness matchers
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect('hello').toBeDefined();

// Boolean-like checks
expect(1).toBeTruthy();
expect(0).toBeFalsy();
expect('').toBeFalsy();
expect([]).toBeTruthy(); // Arrays are truthy!

// Negation with .not
expect(5).not.toBeNull();
expect('test').not.toBeFalsy();
// Numeric comparisons
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(5).toBeLessThan(10);
expect(5).toBeLessThanOrEqual(5);

// Floating point numbers (avoid toBe due to precision)
expect(0.1 + 0.2).toBeCloseTo(0.3, 5);

// Check for NaN
expect(NaN).toBeNaN();
expect(Number('abc')).toBeNaN();
// Array matchers
const fruits = ['apple', 'banana', 'orange'];

expect(fruits).toContain('banana');
expect(fruits).toHaveLength(3);
expect(fruits).not.toContain('grape');

// Check for object in array
const users = [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }];
expect(users).toContainEqual({ id: 1, name: 'John' });

// Object property checks
const user = { name: 'John', address: { city: 'NYC' } };
expect(user).toHaveProperty('name');
expect(user).toHaveProperty('address.city', 'NYC');

The mix-up I see most is using toBe on objects or arrays. Two objects with identical contents are still different references, so the test fails with a confusing message: Received: serializes to the same string. When you see that, switch to toEqual.

Mocking with Jest

A unit test that hits a real API is slow, flaky, and fails whenever the network or the other service has a bad day. Mocks let you swap those dependencies for fakes you control. Jest can replace single functions, whole modules, and even timers.

Mocking Architecture Without Mocking Your Code Real API Network (slow) With Mocking Your Code Mock (instant) ✓ Fast ✓ Reliable ✓ Controlled Types of Mocks jest.fn() Mock individual functions jest.mock() Mock entire modules jest.spyOn() Spy on existing methods jest.useFakeTimers() Mock setTimeout, setInterval

Function Mocks

mocking.test.js
// Creating a mock function
const mockCallback = jest.fn(x => x + 10);

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

// Checking how the mock was called
expect(mockCallback).toHaveBeenCalledTimes(3);
expect(mockCallback).toHaveBeenCalledWith(1);
expect(mockCallback).toHaveBeenLastCalledWith(3);

// Checking return values
expect(mockCallback.mock.results[0].value).toBe(11);
expect(mockCallback.mock.results[1].value).toBe(12);

// Mock implementation
const mockFn = jest.fn()
  .mockReturnValueOnce(10)
  .mockReturnValueOnce(20)
  .mockReturnValue(30);

console.log(mockFn()); // 10
console.log(mockFn()); // 20
console.log(mockFn()); // 30
console.log(mockFn()); // 30

Module Mocking

userService.test.js
// userService.js
const axios = require('axios');

async function getUser(id) {
  const response = await axios.get(`/api/users/${id}`);
  return response.data;
}

// Mock axios module
jest.mock('axios');

describe('getUser', () => {
  it('should fetch user by id', async () => {
    const mockUser = { id: 1, name: 'John Doe' };
    
    // Setup mock response
    axios.get.mockResolvedValue({ data: mockUser });
    
    const user = await getUser(1);
    
    expect(axios.get).toHaveBeenCalledWith('/api/users/1');
    expect(user).toEqual(mockUser);
  });

  it('should handle errors', async () => {
    axios.get.mockRejectedValue(new Error('Network error'));
    
    await expect(getUser(1)).rejects.toThrow('Network error');
  });
});

Two things to know here. First, jest.mock() calls are hoisted to the top of the file, above your imports, so you can't reference normal variables from the outer scope inside a mock factory (Jest throws an error unless the variable name starts with mock). Second, mocks keep their call history between tests by default. If a toHaveBeenCalledTimes check passes alone but fails in the full file, add clearMocks: true to your config or call jest.clearAllMocks() in a beforeEach.

Testing Asynchronous Code

Most real code waits on something: a fetch, a database query, a file read. Jest supports async/await, returned Promises, and old style callbacks. The rule that matters for all three: Jest has to know when the test is finished. If you forget to await or return the promise, the test ends early and passes even when the assertion would have failed.

Async/Await Pattern (Recommended)

// Using async/await - cleanest syntax
describe('Async operations', () => {
  it('should fetch data successfully', async () => {
    const data = await fetchData();
    expect(data).toBeDefined();
    expect(data.status).toBe('success');
  });

  it('should handle async errors', async () => {
    await expect(fetchBadData()).rejects.toThrow('Not found');
  });

  it('should resolve with expected value', async () => {
    await expect(Promise.resolve('value')).resolves.toBe('value');
  });
});

Promise Pattern

// Using Promises - return the promise
it('should resolve with data', () => {
  return fetchData().then(data => {
    expect(data.status).toBe('success');
  });
});

// Using .resolves/.rejects
it('should resolve with value', () => {
  return expect(fetchData()).resolves.toMatchObject({
    status: 'success'
  });
});

it('should reject with error', () => {
  return expect(failingRequest()).rejects.toThrow();
});

Callback Pattern (Legacy)

// Using done callback - for legacy callback-based APIs
it('should call the callback with data', done => {
  function callback(error, data) {
    try {
      expect(error).toBeNull();
      expect(data).toBe('success');
      done(); // Call done() when finished
    } catch (e) {
      done(e); // Pass error to done
    }
  }

  fetchWithCallback(callback);
});

Snapshot Testing

A snapshot test saves the output of your code the first time it runs and fails if that output changes later. It's handy for rendered components and any other serializable output where writing assertions by hand would be tedious.

component.test.js
import { render } from '@testing-library/react';
import UserCard from './UserCard';

describe('UserCard', () => {
  it('should match snapshot', () => {
    const user = { name: 'John', role: 'Developer' };
    const { container } = render(<UserCard user={user} />);
    
    // Creates/compares snapshot file
    expect(container).toMatchSnapshot();
  });

  it('should match inline snapshot', () => {
    const user = { name: 'Jane', email: 'jane@test.com' };
    
    // Snapshot stored inline in test file
    expect(user).toMatchInlineSnapshot(`
      Object {
        "email": "jane@test.com",
        "name": "Jane",
      }
    `);
  });
});
Snapshot Best Practices
  • Actually read snapshot diffs before committing; blindly pressing u defeats the point
  • Keep snapshots small and focused
  • Use inline snapshots for small, stable outputs
  • Don't snapshot large or frequently changing data
  • Update snapshots with jest --updateSnapshot when intentional

Code Coverage

Coverage tells you which lines and branches your tests never ran. Jest has it built in, and with coverageThreshold you can make CI fail when coverage drops below a number you pick.

jest.config.js
module.exports = {
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov', 'html'],
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.test.{js,jsx,ts,tsx}',
    '!src/index.js'
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};

Run tests with coverage:

$ npm test -- --coverage

----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
All files | 92.5 | 88.2 | 95.0 | 92.1 |
math.js | 100 | 100 | 100 | 100 |
user.js | 85.0 | 76.5 | 90.0 | 84.2 |
----------|---------|----------|---------|---------|

Understanding Coverage Metrics

Statements: % of statements executed

92%

Branches: % of if/else branches taken

85%

Functions: % of functions called

95%

Lines: % of lines executed

90%

Test-Driven Development (TDD)

With TDD you flip the usual order and write the test first. It runs in a loop of three steps: Red (write a test that fails), Green (write just enough code to pass it), and Refactor (clean up while the tests stay green). It works best when you already know the expected inputs and outputs, like a parser or a pricing rule.

TDD Cycle: Red → Green → Refactor RED Write failing test GREEN Make it pass REFACTOR Improve code

TDD in Practice

1

Write a Failing Test (Red)

Start by writing a test for the behavior you want to implement. Run it. It should fail, because the functionality doesn't exist yet.

2

Make It Pass (Green)

Write the minimum amount of code necessary to make the test pass. Don't worry about elegance or optimization at this stage.

3

Refactor

Now clean up while keeping every test green. Remove duplication, fix naming, tidy the logic. If you break something, the tests tell you right away.

Testing Best Practices

Practice Do Don't
Test Names Describe behavior: "should return empty array when no items" Vague names: "test1", "works"
Test Size One assertion per concept, small focused tests Giant tests with many unrelated assertions
Dependencies Mock external dependencies (APIs, databases) Test against real external services
Test Data Use factories or fixtures for consistent data Hardcode random data throughout tests
Isolation Each test should be independent Tests that depend on other tests' state
Coverage Focus on critical paths and edge cases Chase 100% coverage at all costs

The AAA Pattern

Structure your tests using the Arrange-Act-Assert pattern:

  • Arrange: Set up test data and conditions
  • Act: Execute the code being tested
  • Assert: Verify the results are as expected
aaa-pattern.test.js
describe('ShoppingCart', () => {
  it('should calculate total with discount', () => {
    // Arrange
    const cart = new ShoppingCart();
    cart.addItem({ name: 'Laptop', price: 1000 });
    cart.addItem({ name: 'Mouse', price: 50 });
    cart.applyDiscount(0.1); // 10% discount

    // Act
    const total = cart.calculateTotal();

    // Assert
    expect(total).toBe(945); // (1000 + 50) * 0.9
  });
});

Useful Jest CLI Options

Terminal Commands
# Run tests in watch mode
jest --watch

# Run only changed tests
jest --onlyChanged

# Run specific test file
jest user.test.js

# Run tests matching pattern
jest --testNamePattern="should validate"

# Run with coverage
jest --coverage

# Update snapshots
jest --updateSnapshot

# Run tests in band (sequentially)
jest --runInBand

# Verbose output
jest --verbose

# Clear cache
jest --clearCache

When I'd use Jest (and when I wouldn't)

For a Node backend, a React app built with webpack, or a shared utility library, Jest is still my default. It's well documented, nearly every tutorial and Stack Overflow answer assumes it, and the mocking API covers most of what you need without extra packages.

There are cases where I'd pick something else:

And whatever runner you use, don't treat the coverage number as the goal. A file can be at 100% line coverage while none of its assertions check anything meaningful. I'd rather have solid tests around the risky parts (money, auth, data transforms) than a high percentage from tests that just call functions.

Where to go from here

The Jest docs on custom matchers and fake timers are worth a read once the basics feel comfortable. For React, pair Jest with Testing Library so your tests query the page the way a user would (by label and role) instead of reaching into component internals.

Jest Testing JavaScript Unit Testing TDD Mocking
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and modern JavaScript.