MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Unit Testing with Jest: A Complete Guide

Master Jest: matchers, mocking, snapshots, code coverage, and async testing.

JestTestingUnit TestsJavaScript

By MinhVo

Introduction

Unit testing is the foundation of reliable software development, and Jest has emerged as the dominant testing framework in the JavaScript ecosystem. Created by Facebook and used by companies like Airbnb, Twitter, and Spotify, Jest provides a comprehensive, batteries-included testing experience that requires minimal configuration. Its zero-config philosophy, powerful assertion library, built-in mocking capabilities, and snapshot testing make it the go-to choice for testing React applications, Node.js services, and everything in between.

What sets Jest apart from other testing frameworks like Mocha, Jasmine, or AVA is its focus on developer experience. Jest runs tests in parallel, provides clear error messages, includes code coverage out of the box, and offers snapshot testing for UI components. It's designed to scale from small utility libraries to massive monorepos with thousands of tests.

In this comprehensive guide, we'll cover everything from basic setup to advanced testing patterns. You'll learn how to write effective unit tests, mock complex dependencies, test asynchronous code, and achieve high code coverage with confidence.

Testing Framework

Understanding Jest: Core Concepts

Jest operates on several core concepts that form the foundation of its testing philosophy. Understanding these concepts is essential for writing effective tests.

Test Suites and Test Cases

A test suite is a collection of related test cases, typically grouped by the module or component being tested. In Jest, test suites are defined using describe blocks, and individual test cases use test or it functions. This hierarchical structure keeps your tests organized and makes it easy to run specific subsets of tests.

Matchers

Matchers are functions that let you validate different types of values. Jest provides a rich set of built-in matchers for common assertions:

  • toBe(value) - Strict equality (===)
  • toEqual(value) - Deep equality for objects and arrays
  • toBeTruthy() / toBeFalsy() - Boolean assertions
  • toBeGreaterThan(number) - Numeric comparisons
  • toContain(item) - Array/string containment
  • toThrow(error) - Exception assertions
  • toHaveBeenCalled() - Mock function assertions

Mocking

Mocking is the practice of replacing real implementations with fake ones during testing. This is crucial for isolating the unit under test from its dependencies. Jest provides three levels of mocking:

  1. Manual mocks: You create fake implementations yourself
  2. Jest.fn(): Creates a mock function that records calls
  3. Jest.mock(): Automatically mocks entire modules

Snapshot Testing

Snapshot testing captures the output of a component or function and compares it against a stored reference. If the output changes, the test fails and shows a diff. This is particularly valuable for testing React components, where rendering output can be complex.

Testing Concepts

Architecture and Design Patterns

Test Organization Patterns

Well-structured tests follow the Arrange-Act-Assert (AAA) pattern. In the arrange phase, you set up the test conditions and mock dependencies. In the act phase, you execute the function or action being tested. In the assert phase, you verify the expected outcome. This pattern makes tests readable and maintainable.

Test File Structure

Jest automatically discovers test files in several ways:

  • Files with .test.js or .spec.js suffixes
  • Files in __tests__ directories
  • Configuration in jest.config.js

The most common convention is to place test files next to the source files they test, with a .test.js suffix. This co-location makes it easy to find tests and keeps them close to the implementation.

Mock Module Pattern

When testing modules that depend on external services, databases, or APIs, you need to mock those dependencies. The dependency injection pattern makes mocking straightforward—pass dependencies as arguments rather than importing them directly.

Architecture

Step-by-Step Implementation

Setting Up Jest

// Installation
// npm install --save-dev jest
 
// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}
 
// jest.config.js
module.exports = {
  testEnvironment: 'node',
  coverageDirectory: 'coverage',
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/index.js',
    '!src/**/*.test.js',
  ],
  setupFilesAfterSetup: ['./jest.setup.js'],
};

Writing Your First Test

// math.js
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('Division 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', () => {
    test('should add two positive numbers', () => {
      expect(add(2, 3)).toBe(5);
    });
 
    test('should handle negative numbers', () => {
      expect(add(-1, 1)).toBe(0);
      expect(add(-5, -3)).toBe(-8);
    });
 
    test('should handle zero', () => {
      expect(add(0, 5)).toBe(5);
    });
  });
 
  describe('divide', () => {
    test('should divide two numbers', () => {
      expect(divide(10, 2)).toBe(5);
    });
 
    test('should throw on division by zero', () => {
      expect(() => divide(10, 0)).toThrow('Division by zero');
    });
  });
});

Testing Async Functions

// api.js
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error(`User not found: ${id}`);
  }
  return response.json();
}
 
async function fetchUserPosts(userId) {
  const user = await fetchUser(userId);
  const response = await fetch(`/api/users/${userId}/posts`);
  const posts = await response.json();
  return { ...user, posts };
}
 
module.exports = { fetchUser, fetchUserPosts };
 
// api.test.js
const { fetchUser, fetchUserPosts } = require('./api');
 
// Mock the global fetch function
global.fetch = jest.fn();
 
describe('API functions', () => {
  beforeEach(() => {
    fetch.mockClear();
  });
 
  describe('fetchUser', () => {
    test('should fetch user successfully', async () => {
      const mockUser = { id: 1, name: 'John Doe' };
      fetch.mockResolvedValueOnce({
        ok: true,
        json: async () => mockUser,
      });
 
      const user = await fetchUser(1);
 
      expect(user).toEqual(mockUser);
      expect(fetch).toHaveBeenCalledWith('/api/users/1');
    });
 
    test('should throw on failed request', async () => {
      fetch.mockResolvedValueOnce({
        ok: false,
        status: 404,
      });
 
      await expect(fetchUser(999)).rejects.toThrow('User not found: 999');
    });
  });
});

Mocking Modules

// emailService.js
const nodemailer = require('nodemailer');
 
const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: process.env.SMTP_PORT,
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});
 
async function sendWelcomeEmail(user) {
  return transporter.sendMail({
    from: 'noreply@example.com',
    to: user.email,
    subject: 'Welcome!',
    html: `<h1>Welcome, ${user.name}!</h1>`,
  });
}
 
module.exports = { sendWelcomeEmail };
 
// emailService.test.js
jest.mock('nodemailer');
 
const nodemailer = require('nodemailer');
const { sendWelcomeEmail } = require('./emailService');
 
describe('Email Service', () => {
  let mockTransporter;
 
  beforeEach(() => {
    mockTransporter = {
      sendMail: jest.fn().mockResolvedValue({ messageId: '123' }),
    };
    nodemailer.createTransport.mockReturnValue(mockTransporter);
  });
 
  test('should send welcome email', async () => {
    const user = { name: 'John', email: 'john@example.com' };
    await sendWelcomeEmail(user);
 
    expect(mockTransporter.sendMail).toHaveBeenCalledWith({
      from: 'noreply@example.com',
      to: 'john@example.com',
      subject: 'Welcome!',
      html: '<h1>Welcome, John!</h1>',
    });
  });
});

Snapshot Testing

// UserCard.js
const React = require('react');
 
function UserCard({ user }) {
  return React.createElement('div', { className: 'user-card' },
    React.createElement('h2', null, user.name),
    React.createElement('p', null, user.email),
    React.createElement('span', { className: 'role' }, user.role)
  );
}
 
module.exports = UserCard;
 
// UserCard.test.js
const React = require('react');
const renderer = require('react-test-renderer');
const UserCard = require('./UserCard');
 
describe('UserCard', () => {
  test('should render correctly', () => {
    const user = { name: 'John Doe', email: 'john@example.com', role: 'Admin' };
    const tree = renderer.create(React.createElement(UserCard, { user })).toJSON();
    expect(tree).toMatchSnapshot();
  });
 
  test('should render different roles', () => {
    const user = { name: 'Jane Smith', email: 'jane@example.com', role: 'User' };
    const tree = renderer.create(React.createElement(UserCard, { user })).toJSON();
    expect(tree).toMatchSnapshot();
  });
});

Mocking Timers

// debounce.js
function debounce(fn, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delay);
  };
}
 
module.exports = { debounce };
 
// debounce.test.js
const { debounce } = require('./debounce');
 
describe('debounce', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });
 
  afterEach(() => {
    jest.useRealTimers();
  });
 
  test('should delay function execution', () => {
    const fn = jest.fn();
    const debouncedFn = debounce(fn, 1000);
 
    debouncedFn();
    expect(fn).not.toHaveBeenCalled();
 
    jest.advanceTimersByTime(500);
    expect(fn).not.toHaveBeenCalled();
 
    jest.advanceTimersByTime(500);
    expect(fn).toHaveBeenCalledTimes(1);
  });
 
  test('should reset timer on subsequent calls', () => {
    const fn = jest.fn();
    const debouncedFn = debounce(fn, 1000);
 
    debouncedFn();
    jest.advanceTimersByTime(500);
    debouncedFn();
    jest.advanceTimersByTime(500);
    expect(fn).not.toHaveBeenCalled();
 
    jest.advanceTimersByTime(500);
    expect(fn).toHaveBeenCalledTimes(1);
  });
});

Real-World Use Cases and Case Studies

Use Case 1: Testing a REST API Controller

In a real Node.js application, controllers handle HTTP requests and coordinate between services. Testing controllers requires mocking the request/response objects and verifying that the correct service methods are called with the right arguments. Jest's mock functions make this straightforward—you can create mock response objects that record what methods were called and with what arguments.

Use Case 2: Testing React Custom Hooks

React custom hooks encapsulate complex stateful logic. Testing them requires a special utility from @testing-library/react-hooks that lets you render hooks in a test environment. You can test that hooks return the correct values, call APIs at the right times, and handle errors gracefully.

Use Case 3: Testing Database Repositories

Repository classes that interact with databases should be tested with mocked database clients. This lets you verify that the correct SQL queries are constructed, the right parameters are passed, and results are properly transformed. You never want to hit a real database in unit tests—that's the job of integration tests.

Use Case 4: Testing Event-Driven Systems

Event emitters and message handlers are common in Node.js applications. Jest's mock functions can verify that events are emitted with the correct data and that handlers process events correctly. You can also test error handling by having mocks throw exceptions.

Best Practices for Production

  1. Write tests before fixing bugs: When you encounter a bug, write a failing test that reproduces it before fixing it. This ensures the bug won't regress and documents the exact scenario that caused it.

  2. Test behavior, not implementation: Focus on what a function does, not how it does it. Testing implementation details makes tests brittle—refactoring internals shouldn't break tests.

  3. Use descriptive test names: Test names should describe the scenario and expected outcome. "should return 404 when user not found" is much better than "test user endpoint."

  4. Keep tests independent: Each test should set up its own state and not depend on other tests. Use beforeEach to reset mocks and state between tests.

  5. Avoid testing third-party code: Don't write tests for library functions. Mock them and test that your code interacts with them correctly.

  6. Use test coverage as a guide, not a goal: 100% coverage doesn't mean your code is bug-free. Use coverage to identify untested code paths, but focus on testing critical business logic.

  7. Test edge cases: Empty arrays, null values, negative numbers, and boundary conditions are where bugs hide. Always test the edges.

  8. Use factories for test data: Create factory functions that generate test objects with sensible defaults. This reduces duplication and makes tests more readable.

Common Pitfalls and Solutions

PitfallImpactSolution
Testing implementation detailsBrittle tests that break on refactorsFocus on testing public API behavior
Not cleaning up mocksTests affect each other, flaky resultsUse beforeEach to clear mocks and state
Over-mockingTests pass but code is broken in productionMock only external boundaries, not internal functions
Ignoring async/awaitTests pass prematurely before async completesAlways await async operations in tests
Snapshot abuseHuge snapshots that nobody reviewsUse snapshots for small, stable components only
Flaky time-dependent testsRandom failures in CIUse jest.useFakeTimers() for time-dependent code

Performance Optimization

Jest runs tests in parallel by default, but you can optimize further:

// jest.config.js
module.exports = {
  // Run tests in parallel workers
  maxWorkers: '50%',
 
  // Cache test results
  cache: true,
 
  // Only run changed files
  changedSince: 'main',
 
  // Bail on first failure (for CI)
  bail: true,
 
  // Optimize module resolution
  moduleDirectories: ['node_modules', 'src'],
 
  // Transform only what's needed
  transformIgnorePatterns: [
    '/node_modules/(?!lodash-es)',
  ],
};

For large test suites, consider using jest --shard to split tests across CI machines. You can also use testPathIgnorePatterns to skip integration tests during unit test runs.

Comparison with Alternatives

FeatureJestMocha + ChaiVitestAVA
ConfigurationZero configManual setupMinimal configMinimal config
Built-in MockingYesNo (sinon)Yes (vi)No
Snapshot TestingYesNoYesNo
Code CoverageBuilt-inIstanbul/c8Built-inc8
Parallel ExecutionYesNo (needs workers)YesYes
TypeScript SupportVia transformVia ts-nodeNativeVia extension
SpeedFastModerateFastestFast
EcosystemLargestLargeGrowingSmall

Advanced Patterns

Custom Matchers

// Custom matcher for checking if a value is a valid UUID
expect.extend({
  toBeValidUUID(received) {
    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
    const pass = uuidRegex.test(received);
    return {
      pass,
      message: () =>
        `expected ${received} ${pass ? 'not ' : ''}to be a valid UUID`,
    };
  },
});
 
// Usage
test('should generate valid UUID', () => {
  const id = generateId();
  expect(id).toBeValidUUID();
});

Parameterized Tests

describe('isPalindrome', () => {
  test.each([
    ['racecar', true],
    ['hello', false],
    ['A man a plan a canal Panama', true],
    ['', true],
    ['a', true],
  ])('isPalindrome("%s") should return %s', (input, expected) => {
    expect(isPalindrome(input)).toBe(expected);
  });
});

Async Error Testing

describe('Async error handling', () => {
  test('should reject with specific error', async () => {
    await expect(asyncFunction()).rejects.toThrow('Specific error message');
  });
 
  test('should handle promise rejection', () => {
    return expect(promiseFunction()).rejects.toBeInstanceOf(CustomError);
  });
});

Testing Strategies

A comprehensive testing strategy includes multiple layers:

  1. Unit tests: Test individual functions and classes in isolation. These should be fast, deterministic, and cover all code paths.

  2. Integration tests: Test how multiple units work together. These verify that interfaces between modules are correct.

  3. End-to-end tests: Test the entire application from the user's perspective. These are slow and brittle, so keep them minimal.

For most JavaScript applications, you want a testing pyramid: many unit tests, fewer integration tests, and very few E2E tests. Jest excels at the unit and integration test levels.

// Example integration test
describe('User registration flow', () => {
  test('should register user and send welcome email', async () => {
    const mockDb = { users: { create: jest.fn().mockResolvedValue({ id: 1 }) } };
    const mockEmail = { send: jest.fn().mockResolvedValue(true) };
    const service = new UserService(mockDb, mockEmail);
 
    const result = await service.register({
      email: 'test@example.com',
      password: 'secure123',
    });
 
    expect(result.id).toBe(1);
    expect(mockDb.users.create).toHaveBeenCalled();
    expect(mockEmail.send).toHaveBeenCalledWith(
      'test@example.com',
      expect.stringContaining('Welcome')
    );
  });
});

Future Outlook

Jest continues to evolve with the JavaScript ecosystem. Recent developments include improved ESM support, better TypeScript integration, and performance optimizations through worker threads. The Jest team is also working on a new test runner architecture that will make it even faster.

With the rise of Vitest as a Vite-native alternative, Jest is responding with improved configuration options and faster execution. Both frameworks are pushing the boundaries of what's possible in JavaScript testing, which benefits developers regardless of which tool they choose.

Conclusion

Jest is a powerful, comprehensive testing framework that makes unit testing in JavaScript a pleasant experience. Its zero-config philosophy, built-in mocking, snapshot testing, and parallel execution make it the best choice for most JavaScript projects.

Key takeaways:

  1. Start with simple tests and build up complexity gradually
  2. Follow the AAA pattern: Arrange, Act, Assert
  3. Mock external dependencies, not internal functions
  4. Test behavior, not implementation details
  5. Use coverage to guide your testing, not as a goal
  6. Write tests for bugs before fixing them

The investment in learning Jest pays dividends in code confidence, faster debugging, and fearless refactoring. Start small, test the critical paths, and expand your test coverage as your codebase grows.

For further exploration, check out the official Jest documentation, Testing Library for React testing patterns, and the Jest community for custom matchers and utilities.