jsguides

Unit testing with Jest: a hands-on guide to JavaScript test automation

Jest is a zero-configuration unit testing framework developed by Meta (formerly Facebook). It ships with everything you need: test runner, assertion library, mocking capabilities, and code coverage tools. In this tutorial, you will learn how to write effective unit tests with Jest.

Installing Jest

Jest works in any JavaScript environment, Node.js, browsers, or React apps. The fastest way to add it to a project is via npm:

npm install --save-dev jest

The output shown in the next block confirms that the code works as intended, but it is worth comparing the result structure across both examples. Small differences in the return value or log format often reveal important assumptions about how the underlying API behaves. Noticing those details now saves debugging time later.

The npm command shown here installs dependencies or executes scripts defined in your project configuration. The output confirms whether the operation completed successfully.

Add a test script to your package.json:

{
  "scripts": {
    "test": "jest"
  }
}

Both blocks demonstrate related techniques, but they differ in an important detail. The first example shows the basic pattern, while the one that follows adds a layer of complexity that you will encounter in real projects. Pay attention to how the setup differs between the two, because that difference determines when to use each approach.

The npm command shown here installs dependencies or executes scripts defined in your project configuration. The output confirms whether the operation completed successfully.

Or run Jest directly with npx:

npx jest

The following code builds on the concepts introduced above, adding another layer of functionality. Seeing the progression from simple to more complex helps clarify when to introduce each technique.

Jest defaults to finding files matching these patterns: *.test.js, *.spec.js, or in a __tests__ folder.

Writing your first test

Jest uses describe blocks to group related tests and test (or it) functions for individual test cases:

// math.js
function add(a, b) {
  return a + b;
}

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

module.exports = { add, subtract };

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

This assertion checks a specific return value. The examples ahead demonstrate additional matchers for different data types, giving you precise control over what each test verifies.

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

describe('Math functions', () => {
  test('adds two numbers correctly', () => {
    expect(add(2, 3)).toBe(5);
  });

  test('subtracts two numbers correctly', () => {
    expect(subtract(10, 4)).toBe(6);
  });
});

The example shown above covers one specific scenario, but the code you will write in production often needs to handle additional edge cases. The block that follows extends the pattern to a related situation, showing how the same approach adapts when the inputs or expected outputs change.

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Run your tests:

npx jest

Looking at these two examples side by side helps you decide which pattern to use in your own code. The first shows the minimal version that gets the job done, while the second adds error handling or edge-case coverage that you will want in any non-trivial application. Choose based on how much safety your code needs.

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Output:

PASS  ./math.test.js
  Math functions
    ✓ adds two numbers correctly
    ✓ subtracts two numbers correctly

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Understanding Matchers

Jest’s expect object provides matchers to assert values. Here are the most useful ones.

Equality Matchers

expect(value).toBe(5)          // Strict equality (===)
expect(value).toEqual(obj)    // Deep equality (for objects/arrays)
expect(value).toBeNull()       // Strict null check
expect(value).toBeUndefined()  // Strict undefined check
expect(value).toBeDefined()    // Opposite of toBeUndefined
expect(value).toBeTruthy()     // Truthy check
expect(value).toBeFalsy()      // Falsy check

The output shown in the next block confirms that the code works as intended, but it is worth comparing the result structure across both examples. Small differences in the return value or log format often reveal important assumptions about how the underlying API behaves. Noticing those details now saves debugging time later.

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Numeric Matchers

expect(num).toBeGreaterThan(10)   // >
expect(num).toBeGreaterThanOrEqual(10) // >=
expect(num).toBeLessThan(10)      // <
expect(num).toBeLessThanOrEqual(10) // <=
expect(num).toBeCloseTo(0.1, 5)   // Floating point comparison

Both blocks demonstrate related techniques, but they differ in an important detail. The first example shows the basic pattern, while the one that follows adds a layer of complexity that you will encounter in real projects. Pay attention to how the setup differs between the two, because that difference determines when to use each approach.

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

String Matchers

expect(str).toMatch(/pattern/)    // Regex match
expect(str).toContain('子')       // Substring check

The example shown above covers one specific scenario, but the code you will write in production often needs to handle additional edge cases. The block that follows extends the pattern to a related situation, showing how the same approach adapts when the inputs or expected outputs change.

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Array Matchers

expect(arr).toContain(item)       // Array contains item
expect(arr).toHaveLength(3)      // Array length check

Looking at these two examples side by side helps you decide which pattern to use in your own code. The first shows the minimal version that gets the job done, while the second adds error handling or edge-case coverage that you will want in any non-trivial application. Choose based on how much safety your code needs.

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Use .not to invert any matcher:

expect(result).not.toBeNull();
expect(items).not.toContain('bad');

The following code builds on the concepts introduced above, adding another layer of functionality. Seeing the progression from simple to more complex helps clarify when to introduce each technique.

Setup and Teardown

Often you need to run code before or after each test, or before and after all tests in a block:

describe('Database operations', () => {
  let db;

  // Run once before all tests
  beforeAll(async () => {
    db = await connectToDatabase();
  });

  // Run after all tests complete
  afterAll(async () => {
    await db.disconnect();
  });

  // Run before each individual test
  beforeEach(() => {
    db.clear();
  });

  // Run after each test
  afterEach(() => {
    db.resetMocks();
  });

  test('saves a user', async () => {
    const user = await db.saveUser({ name: 'Alice' });
    expect(user.id).toBeDefined();
  });
});

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Testing asynchronous code

Jest supports multiple patterns for testing async code.

Using async/await

test('fetches user data', async () => {
  const user = await fetchUser(1);

  expect(user).toEqual({
    id: 1,
    name: 'Alice',
    email: 'alice@example.com'
  });
});

The output shown in the next block confirms that the code works as intended, but it is worth comparing the result structure across both examples. Small differences in the return value or log format often reveal important assumptions about how the underlying API behaves. Noticing those details now saves debugging time later.

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Using Promises

test('fetches user data', () => {
  return fetchUser(1).then(user => {
    expect(user.name).toBe('Alice');
  });
});

Both blocks demonstrate related techniques, but they differ in an important detail. The first example shows the basic pattern, while the one that follows adds a layer of complexity that you will encounter in real projects. Pay attention to how the setup differs between the two, because that difference determines when to use each approach.

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Using resolves/rejects

test('fetches user data', () => {
  return expect(fetchUser(1)).resolves.toHaveProperty('name', 'Alice');
});

test('handles errors', () => {
  return expect(fetchUser(999)).rejects.toThrow('User not found');
});

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Mocking Functions

Jest makes it easy to mock functions, modules, and even timers.

Mocking a single function

const fetchData = require('./fetchData');

test('mocks a function', () => {
  const mockFn = jest.fn();
  mockFn.mockReturnValue(42);

  expect(mockFn()).toBe(42);
  expect(mockFn).toHaveBeenCalled();
  expect(mockFn).toHaveBeenCalledTimes(1);
});

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Mocking module dependencies

When your code imports a module, you can mock the entire module:

// api.js
const axios = require('axios');

module.exports = {
  getUser: (id) => axios.get(`/users/${id}`)
};

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

jest.mock('axios');

test('fetches user successfully', async () => {
  axios.get.mockResolvedValue({
    data: { id: 1, name: 'Alice' }
  });

  const user = await api.getUser(1);

  expect(user.data.name).toBe('Alice');
  expect(axios.get).toHaveBeenCalledWith('/users/1');
});

The example shown above covers one specific scenario, but the code you will write in production often needs to handle additional edge cases. The block that follows extends the pattern to a related situation, showing how the same approach adapts when the inputs or expected outputs change.

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Mock Implementations

const mockFn = jest.fn()
  .mockImplementationOnce(() => 'first call')
  .mockImplementationOnce(() => 'second call');

console.log(mockFn()); // 'first call'
console.log(mockFn()); // 'second call'
console.log(mockFn()); // undefined (fallback)

The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.

Mocking Timers

For code that uses setTimeout or setInterval, Jest can fake the timers:

// timer.js
function delayedCallback(callback) {
  setTimeout(() => {
    callback('done');
  }, 1000);
}

// timer.test.js
test('delayed callback', () => {
  jest.useFakeTimers();

  const callback = jest.fn();
  delayedCallback(callback);

  // Timer has not fired yet
  expect(callback).not.toHaveBeenCalled();

  // Fast-forward time
  jest.advanceTimersByTime(1000);

  // Now callback should have fired
  expect(callback).toHaveBeenCalledWith('done');

  jest.useRealTimers();
});

Test organization tips

  1. Follow the AAA pattern: Arrange (setup), Act (execute), Assert (verify)
  2. One expectation per test when possible, makes debugging easier
  3. Use descriptive test names: adds positive numbers not test1
  4. Keep tests isolated: each test should be independent
  5. Test edge cases: empty arrays, null values, negative numbers

Summary

Building a useful test mindset

The best Jest suites focus on behavior instead of implementation details. A test should tell you what the code is supposed to do when the important inputs change. That makes the suite more stable during refactors because the assertions stay tied to outcomes, not to internal variable names or private helper calls. When a test breaks, you want the failure to point to a real change in behavior, not to a harmless rewrite of the surrounding code.

That mindset also helps with setup. If a test file needs a lot of shared state, try splitting the code so each part has a smaller surface area. Tests should usually create only the objects they need for the behavior under test. When the fixture grows large, the real signal gets buried under noise. A small amount of duplication is often better than one elaborate factory that nobody wants to touch.

Mocking without losing trust

Mocks are useful because they let you isolate one unit of code, but they are easy to overuse. If everything is mocked, the test may only prove that the mocks were called in the expected order. That can hide a mismatch between the test double and the real dependency. A healthy suite mocks the outside world when needed, but still leaves enough real code in place to catch integration mistakes. That usually means mocking network calls, time, and random data, while leaving pure logic untouched.

When you mock a module, try to preserve the contract that the real module exposes. The closer the mock stays to the real return shape, the less surprising the test becomes. It also helps to reset mocks between tests so one case does not affect the next. If a test starts to depend on call order alone, consider checking the output or state change first and the mock call second. That keeps the test focused on what the user would notice.

Keeping assertions clear

Assertions work best when they are easy to read at a glance. A single test can check several things, but each expectation should support the same behavior. If a test name says it validates login failure, the assertions should stay in that lane and avoid drifting into unrelated side effects. Clear names and focused assertions make the suite easier to scan when a CI job fails at the end of a long day.

For async code, prefer promise-aware assertions and await the result that matters most. For timers, fake time only for the slice of code that truly depends on it. For module mocks, reset state after each test so the next case starts clean. These habits do not just make the suite pass today — they make it easier to change tomorrow without fear that one small edit will break half the run.

Small suite habits

It is often worth reading the test file in the same order a future maintainer will read it: setup, act, assert, then cleanup. That rhythm makes it easier to spot missing pieces and to tell whether a failure is coming from the test arrangement or the code under test. It also makes it easier to trim the file later if the feature changes and some of the old setup is no longer needed.

Jest is at its best when the suite stays specific. Use a focused helper when it keeps the test clearer, but avoid helper layers that hide the behavior completely. A good test file should answer one question at a time. That discipline keeps the suite readable when the project grows and helps the test output stay useful when a failure shows up in CI.

You have learned the fundamentals of testing with Jest:

  • Installing and configuring Jest
  • Writing tests with describe and test
  • Using matchers like toBe, toEqual, toMatch
  • Setup and teardown with beforeEach, afterAll, etc.
  • Testing async code with async/await
  • Mocking functions, modules, and timers

In the next tutorial, we will explore Unit Testing with Vitest, which offers a Jest-compatible API with better performance and native ESM support.

See Also