Featured resource
Tech Upskilling Playbook 2025
Tech Upskilling Playbook

Build future-ready tech teams and hit key business milestones with seven proven plays from industry leaders.

Learn more
  • Labs icon Lab
  • Core Tech
Labs

Guided: JavaScript Unit Testing with Jest

Supercharge your JavaScript code with confidence! This guided Code Lab will walk you through the essentials of unit testing using Jest. You'll learn how to set up your testing environment, write effective tests for various scenarios, and leverage powerful features like mocking and spying to ensure the reliability and maintainability of your JavaScript applications.

Labs

Path Info

Level
Clock icon Beginner
Duration
Clock icon 45m
Last updated
Clock icon Jun 26, 2025

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.

Table of Contents

  1. Challenge

    Introduction

    Guided: JavaScript Unit Testing with Jest

    In this guided Code Lab, you'll learn the essentials of unit testing using Jest, one of the most popular JavaScript testing frameworks. Throughout the lab, you will develop a simplified checkout system for a web store using Test-Driven Development (TDD).

    You'll start by exploring how to set up a project with Jest, then write comprehensive tests for various scenarios, and finally learn how to use advanced features like mocks and spies to ensure your JavaScript applications are reliable and maintainable.

    All this while using TDD, a methodology that will help you write better code by writing tests first, and then implementing the code to make the tests pass. You'll learn how to use:

    • The Red-Green-Refactor cycle
    • The AAA pattern (Arrange, Act, Assert), AKA Given-When-Then
    • Test doubles: Mocks, Spies, and Stubs
    • Chicago vs London styles of TDD

    Prerequisites

    Before starting this Code Lab, you should have:

    • Basic understanding of JavaScript syntax and concepts (variables, functions, modules)
    • Familiarity with the command line interface (terminal) # Introduction to Jest and Automated Testing

    When you start a software project, you might be tempted to rely on manual testing: clicking through your application to verify it works correctly. This approach works well when the project is small, but it quickly becomes unmanageable as your codebase grows. Manual testing is error-prone, time-consuming, and difficult to repeat consistently with every code change.

    That's why you want to use automated testing, code that tests your code. There are several types of automated testing, each serving different purposes:

    • End-to-End (E2E) Tests follow complete user journeys through your application, simulating real user interactions from start to finish. These are comprehensive but slow and can be brittle.
    • Integration Tests verify that different components of your application work together correctly, testing the interactions between modules or services.
    • Unit Tests isolate a single class, file, or function to test it in complete isolation. They mock (more on this later) external dependencies to control their behavior, ensuring you're only testing the specific unit of code. Unit tests are fast, reliable, and provide immediate feedback during development.
    • Regression Tests ensure that changes to the codebase do not break existing functionality.
    • Snapshot tests capture the output of a component and compare it to a previous version, to catch unintended changes.

    Test-Driven Development (TDD) is a methodology that introduces tests from the very beginning of development. TDD follows a "Red-Green-Refactor" cycle: write a failing test, write minimal code to make it pass, then refactor while keeping tests passing. This approach ensures you write more testable, modular code and forces you to think about requirements before implementation.

    Behavior-Driven Development (BDD) is a methodology that focuses on describing the behavior of the system in a way that is easy for non-technical stakeholders to understand.

    Mocking is a technique that allows you to replace a real object with a fake one, to control its behavior and ensure that the unit under test is tested in complete isolation.

    Introduction to Jest

    Jest is a comprehensive JavaScript testing framework developed by Meta (Facebook). It has become the most popular choice for JavaScript testing because it provides:

    Key Features

    • Zero Configuration - Works out of the box with sensible defaults
    • Built-in Test Runner - No need for additional tools to run tests
    • Assertion Library - Rich set of matchers for making assertions
    • Mocking Capabilities - Powerful mocking and spying features
    • Code Coverage - Built-in coverage reports without additional setup
    • Snapshot Testing - Capture component output for regression testing
    • Parallel Execution - Runs tests in parallel for better performance

    Why Choose Jest?

    • Developer Experience - Excellent error messages and debugging support
    • Community Adoption - Widely used in the JavaScript ecosystem
    • Framework Agnostic - Works with React, Vue, Angular, Node.js, and vanilla JavaScript
    • Active Development - Regularly updated with new features and improvements
    • Comprehensive Documentation - Well-documented with extensive examples

    info> While working through this lab, if you get stuck or would like to compare solutions, a solution directory has been provided for you in the filetree.

  2. Challenge

    Step 0: Setting up the project

    Step 1: Setting up the project

    Installing Jest

    Note: In this lab environment, Jest is already installed and configured since the environment doesn't have internet access. This allows you to focus on learning testing concepts rather than setup procedures. However, the installation instructions are included for completeness.

    In a typical project, you would install Jest as a development dependency using npm:

    # Install Jest
    npm install --save-dev jest
    

    After installation, you would add a test script to your package.json:

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

    Then you could run tests using:

    npm run test
    
  3. Challenge

    Step 1: Basic Calculator with TDD

    Step 2: Basic Calculator with TDD

    What is TDD?

    Many developers use automated tests, but they write them after the code is written, which has a few limitations:

    1. It's easy to come up with code that's hard to test
    2. Some tests might not end up making meaningful assertions, or might not be testing edge cases or complete features.

    The idea behind TDD is that you use tests to guide the development of your code:

    1. Red: Write a test that fails. This is key to ensure you're using meaningful assertions. Never trust a test you haven't seen fail.
    2. Green: Write the simplest code that makes the test pass. This ensures that you do not add features that are not tested: to add a new complex feature you should break it down into smaller parts, each one covered by a test.
    3. Refactor: Improve the code without changing the behavior. This ensures that your code is readable and maintainable, and that you are not adding unnecessary complexity. Both tests and code evolve over time, tests will be deleted or updated as the code evolves.

    This is a cycle that you repeat until you have a working solution.

    Many developers use TDD, and many use variations of it. There are two main styles of TDD:

    • Chicago style (inside-out): start from the domain and work your way out to the edges.
    • London style (outside-in): start from the edges (API, UI, etc.) and work your way in to the domain, relying on mocks and stubs.

    Project Setup

    The project is already set up with Jest configured. You can run the tests with:

    npm test
    

    Basic Calculator

    In this step, you will start building a checkout system using the Chicago style of TDD. You will write a simple module to calculate the total price of a shopping cart, including taxes and discounts. info> You can create new files by clicking the three dots next to the desired folder and selecting New file. Alternatively you can navigate to your project folder via the Terminal tab and enter touch file_name. Please note, sometimes new files do not automatically appear in the filetree. If you are experiencing this, collapse the containing folder within the filetree and expand it to refresh it's contents.

  4. Challenge

    Step 2: London Style TDD and Test Doubles

    Step 2: London Style TDD and Test Doubles

    So far, you've been using the Chicago style of TDD (inside-out), where you start with the core domain logic and work your way out to the edges. Now you'll learn the London style of TDD (outside-in), where you start from the edges (like APIs, user interfaces, or service boundaries) and work your way in to the domain.

    What are Test Doubles?

    When testing code that depends on other components (like databases, APIs, or external services), you don't want your tests to actually use those real components. This is because:

    1. Real components are slow (database queries, network calls)
    2. Real components can fail (network issues, service downtime)
    3. Real components have side effects (sending emails, charging credit cards)
    4. Real components are hard to control (you can't easily simulate errors or ensure initial state)

    Test doubles are fake implementations that replace real components during testing. They allow you to:

    • Control the behavior of dependencies
    • Test different scenarios (success, failure, edge cases)
    • Make tests fast and reliable
    • Avoid side effects

    Types of Test Doubles

    • Mocks: Verify that methods are called with specific arguments
    • Spies: Track method calls without changing behavior
    • Stubs: Return predefined values without executing real logic
    • Fakes: Simplified implementations that work like the real thing

    What You'll Build

    You'll create a CheckoutService that uses your CartCalculator to process orders. This service will:

    1. Get cart items from a repository (which you'll mock)
    2. Calculate the total using your CartCalculator
    3. Save the order back to the repository

    This demonstrates how to test services that depend on other components using test doubles.

    Understanding Dependencies and Mocks

    Your CheckoutService will need to interact with other components:

    • A cart repository to get the user's cart items
    • A calculator to compute totals

    Instead of using real implementations, you'll use mocks - fake objects that simulate the behavior of real dependencies.

    Jest provides several ways to create mocks:

    Method 1: jest.fn() - creates a mock function

    const mockFunction = jest.fn();
    mockFunction.mockReturnValue("fake result");
    

    When you call mockFunction, it will return the value you set with mockReturnValue.

    Method 2: jest.fn() with implementation

    const mockCalculator = {
      calculateTotal: jest.fn().mockReturnValue(100),
    };
    

    This example creates a mock object, and uses jest.fn() to provide a mock implementation for the calculateTotal method. When you call mockCalculator.calculateTotal, it will return the value you set with mockReturnValue.

    Method 3: jest.spyOn() - spies on existing objects

    const realObject = { method: () => "real" };
    jest.spyOn(realObject, "method").mockReturnValue("fake");
    

    This example creates a real object, and uses jest.spyOn() to spy on the method method. When you call realObject.method, it will return the value you set with mockReturnValue. To reset the mock, you can use jest.restoreAllMocks().

    Asserting on mocks

    The advantage of using a mock or spy rather than implementing a fake object (for example with an arrow function that returns a value) is that you can assert on the mock or spy, to verify that the method was called with the correct arguments.

    Jest provides several matchers to assert on mocks and spies:

    • toHaveBeenCalledWith - checks if the mock or spy was called with specific arguments
    • toHaveBeenCalled - checks if the mock or spy was called
    • toHaveBeenCalledTimes - checks if the mock or spy was called a specific number of times
    • toHaveBeenLastCalledWith - checks if the mock or spy was called with the last set of arguments
    expect(mockFunction).toHaveBeenCalledWith(1, 2, 3);
    expect(mockFunction).toHaveBeenCalled();
    expect(mockFunction).toHaveBeenCalledTimes(1);
    expect(mockFunction).toHaveBeenLastCalledWith(1, 2, 3);
    ``` # A word on London style TDD
    
    Notice how you wrote the tests before implementing the service (TDD), and how you designed the service interface while writing the tests without needing concrete implementations of the dependencies. This demonstrates the power of London style TDD: you can mock dependencies like databases and APIs, or complex business logic components, and develop your service incrementally.
    
    This approach to mocking emerges naturally when you write tests before the service, and the service before its dependencies. Many developers criticize excessive mocking in testing, and they have a valid point: if you mock too much, you're testing the mocks rather than the real code. This problem typically occurs when you write tests after implementing the code and dependencies, and simply mock dependencies to simplify testing. In that scenario, you're not practicing TDD or London style TDD, and the mocks aren't helping you design the code.
    
    When practicing London style TDD, you mock dependencies while still making meaningful assertions about your service's implementation and its interactions with dependencies. Later, you can add integration tests using real dependencies to ensure your mocks accurately reflect the actual dependency interfaces.
    
    ## The Given-When-Then Pattern
    
    When writing tests, it's helpful to structure them in a way that clearly communicates the intent. The **Given-When-Then** pattern (also known as **Arrange-Act-Assert**) helps you organize your test code into three clear sections:
    
    - **Given** (Arrange): Set up the test data and preconditions
    - **When** (Act): Execute the code you're testing
    - **Then** (Assert): Verify the results and behavior
    
    This pattern makes tests more readable and helps you think about what you're actually testing. For example:
    
    ```javascript
    test("should calculate total for user's cart", () => {
      // Given (Arrange)
      const mockCartRepo = {
        getCart: jest.fn().mockReturnValue([{ price: 10, quantity: 2 }]),
      };
      const mockCalculator = {
        calculateTotal: jest.fn().mockReturnValue(25.5),
      };
      const checkoutService = new CheckoutService(mockCartRepo, mockCalculator);
    
      // When (Act)
      const total = checkoutService.checkout("user123", 10, 8.25);
    
      // Then (Assert)
      expect(total).toBe(25.5);
      expect(mockCartRepo.getCart).toHaveBeenCalledWith(userId);
      expect(mockCalculator.calculateTotal).toHaveBeenCalledWith(
        [{ price: 10, quantity: 2 }],
        10,
        8.25
      );
    });
    

    Testing Error Conditions

    In real applications, things don't always go as planned. Your code needs to handle errors gracefully, and your tests should verify this behavior.

    Jest provides several ways to test for exceptions:

    // Method 1: expect().toThrow() - tests that a function throws an error
    expect(() => {
      someFunctionThatThrows();
    }).toThrow("Expected error message");
    
    // Method 2: expect().toThrowError() - tests for specific error types
    expect(() => {
      someFunctionThatThrows();
    }).toThrowError(TypeError);
    
    // Method 3: expect().rejects.toThrow() - for async functions that throw
    await expect(asyncFunction()).rejects.toThrow("Error message");
    

    You can also test that functions do NOT throw errors:

    expect(() => {
      someFunctionThatShouldNotThrow();
    }).not.toThrow();
    

    If you need one of your mocks to throw an error, you can use mockImplementation to throw an error. This is useful for testing error handling:

    const mockFunction = jest.fn().mockImplementation(() => {
      throw new Error("some error message");
    });
    
  5. Challenge

    Step 3: Advanced Testing Techniques

    Step 3: Advanced Testing Techniques

    So far, you've learned the fundamentals of TDD and test doubles. Now you'll explore advanced testing techniques that you can use to write more maintainable, efficient, and comprehensive tests.

    Advanced Techniques Overview

    In this step, you'll learn about:

    • Test Fixtures and Factories: Creating reusable test data
    • Parameterized Tests: Testing multiple scenarios with a single test
    • Custom Matchers: Creating domain-specific assertions
    • Snapshot Testing: Capturing and comparing output snapshots

    These techniques help you:

    • Write tests faster with reusable components
    • Cover more scenarios with less code
    • Make tests more readable and maintainable
    • Handle complex testing scenarios efficiently

    Test Fixtures and Factories

    When writing tests, you often need to create test data. Instead of repeating the same data creation code, you can use test fixtures (predefined test data) and factories (functions that create test data).

    Test Fixtures are static data objects that represent common test scenarios:

    const testUsers = {
      regularUser: { id: "user1", name: "John Doe", email: "[email protected]" },
      adminUser: {
        id: "admin1",
        name: "Admin User",
        email: "[email protected]",
        role: "admin",
      },
      newUser: { id: "user2", name: "Jane Smith", email: "[email protected]" },
    };
    

    Factories are functions that create test data with default values that can be overridden:

    const createUser = (overrides = {}) => ({
      id: "user" + Math.random().toString(36).substr(2, 9),
      name: "Test User",
      email: "[email protected]",
      role: "user",
      ...overrides,
    });
    
    // Usage:
    const user = createUser({ name: "John", role: "admin" });
    ``` ## Parameterized Tests
    
    When you have multiple test scenarios that follow the same pattern but with different input data, **parameterized tests** allow you to write a single test that covers all scenarios. This reduces code duplication and makes it easier to add new test cases.
    
    Jest provides several ways to create parameterized tests:
    
    1. **Using `test.each()`**: The most common approach
    2. **Using `describe.each()`**: For grouping related parameterized tests
    3. **Using `it.each()`**: Alternative syntax for `test.each()`
    
    Here's an example of parameterized tests:
    
    ```javascript
    test.each([
      [2, 3, 5],
      [0, 0, 0],
      [-1, 1, 0],
      [10, 20, 30],
    ])("adds %i + %i to equal %i", (a, b, expected) => {
      expect(add(a, b)).toBe(expected);
    });
    

    As you can see, you pass an array of arrays to test.each. Each inner array contains the arguments for a single test case. These arguments are then passed to the test function, and you can use them to test the function with different inputs. You can also use printf-style format specifier (e.g. %i for integers, %s for strings, %f for floats, etc.) in template literals using % to make the test cases more readable.

    The advantage of using test.each is that you still see each case as a single separate test when you run the tests with npm run test, so you can see which cases fail and which pass in the output. ## Custom Matchers

    Jest's built-in matchers like toBe(), toEqual(), and toContain() are powerful, but sometimes you need domain-specific assertions that make your tests more readable and expressive. Custom matchers allow you to create your own assertion functions that encapsulate complex validation logic.

    Why Use Custom Matchers?

    Custom matchers can:

    • Make tests more readable: expect(user).toBeValidUser() is clearer than checking multiple properties
    • Encapsulate complex validation: Put complex business logic in one place
    • Provide better error messages: Give domain-specific feedback when tests fail
    • Reduce code duplication: Reuse validation logic across multiple tests

    How Custom Matchers Work

    Jest provides the expect.extend() function to add custom matchers. Each custom matcher:

    1. Receives the actual value being tested
    2. Returns an object with pass (boolean) and message (function). The message will be shown when the assertion fails. This can be a little bit confusing, but when pass is true, you should return the message to be shown when false was expected, and vice versa.
    3. Can accept additional parameters for comparison
    expect.extend({
      toBeValidUser(received) {
        const pass =
          received.name.length >
          0; /* a boolean indicating whether the user is valid*/
        return {
          pass,
          // If pass is true, the message should indicate you expected an invalid user, and vice versa
          message: () =>
            `expected ${received} ${pass ? "not" : ""} to be a valid user`,
        };
      },
    });
    

    Example: Built-in vs Custom Matchers

    Without custom matchers: Each time you want to make assertions on a user, you need to write multiple assertions, duplicating the same logic across all test cases.

    test("should validate user", () => {
      const user = { id: "123", name: "John", email: "[email protected]" };
    
      expect(user).toBeDefined();
      expect(typeof user.id).toBe("string");
      expect(user.id.length).toBeGreaterThan(0);
      expect(typeof user.name).toBe("string");
      expect(user.name.length).toBeGreaterThan(0);
      expect(typeof user.email).toBe("string");
      expect(user.email).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
    });
    

    With custom matchers: You can create a custom matcher that encapsulates the validation logic and use it in all test cases. This makes the code more readable and maintainable.

    test("should validate user", () => {
      const user = { id: "123", name: "John", email: "[email protected]" };
    
      expect(user).toBeValidUser();
    });
    
    expect.extend({
      toBeValidUser(received) {
        const pass =
          received &&
          typeof received.id === "string" &&
          received.id.length > 0 &&
          typeof received.name === "string" &&
          received.name.length > 0 &&
          typeof received.email === "string" &&
          received.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
        return {
          pass,
          message: () =>
            `expected ${received} ${pass ? "not" : ""} to be a valid user`,
        };
      },
    });
    

    The custom matcher encapsulates all the validation logic and provides a clear, domain-specific assertion. ## Snapshot Testing

    Snapshot testing is a powerful technique for testing the output of functions that return complex data structures. Instead of manually specifying expected values, you capture a "snapshot" of the current output and compare future runs against it.

    How Snapshot Testing Works

    1. First Run: When you run a snapshot test for the first time, Jest captures the output and saves it to a .snap file
    2. Subsequent Runs: Jest compares the current output with the saved snapshot
    3. Changes: If the output changes, Jest shows you exactly what changed and asks if you want to update the snapshot

    When to Use Snapshot Testing

    Snapshot testing is particularly useful for:

    • Testing complex object structures - API responses, configuration objects
    • Testing UI components - React components, HTML output
    • Testing data transformations - When the exact structure matters
    • Regression testing - Catching unexpected changes

    Example: Manual vs Snapshot Testing

    Manual testing (complex and error-prone):

    test("should generate user profile", () => {
      const profile = generateUserProfile(user);
    
      expect(profile.id).toBe("user123");
      expect(profile.name).toBe("John Doe");
      expect(profile.email).toBe("[email protected]");
      expect(profile.preferences.theme).toBe("dark");
      expect(profile.preferences.language).toBe("en");
      expect(profile.settings.notifications.email).toBe(true);
      expect(profile.settings.notifications.push).toBe(false);
      // ... many more assertions
    });
    

    Snapshot testing (simple and comprehensive):

    test("should generate user profile", () => {
      const profile = generateUserProfile(user);
      expect(profile).toMatchSnapshot();
    });
    

    The snapshot automatically captures all properties and their values, making it much easier to maintain.

    Note that the snapshot test will fail if the output changes, and you will need to update the snapshot, so you need to:

    1. Ensure the initial snapshot is correct
    2. Update the snapshot when the output changes
    3. Review the changes and ensure they are intentional

    This means that snapshot tests are not a good fit for every case, for example, if the output is changing frequently, or if the output is not deterministic.

    Handling Dynamic Values

    Since snapshots are exact matches, you need to handle dynamic values like timestamps, IDs, or random values:

    test("should generate order with dynamic values", () => {
      const order = generateOrder(user, items);
    
      // Remove or replace dynamic values
      const orderForSnapshot = {
        ...order,
        id: expect.any(String),
        timestamp: expect.any(String),
      };
    
      expect(orderForSnapshot).toMatchSnapshot();
    });
    ``` ## Step 3 Summary
    
    You've now learned several advanced testing techniques:
    
    1. **Test Fixtures and Factories** - Reusable test data for maintainable tests
    2. **Parameterized Tests** - Efficient testing of multiple scenarios
    3. **Custom Matchers** - Domain-specific assertions for better readability
    4. **Snapshot Testing** - Testing complex output structures
    
    These techniques help you write more maintainable, efficient, and comprehensive test suites. They're commonly used in professional development to handle real-world testing challenges.
  6. Challenge

    Conclusion

    Conclusion

    Congratulations! You've successfully completed this journey through JavaScript unit testing with Jest. Throughout this lab, you've learned essential testing concepts and practical techniques that will help you write more reliable, maintainable, and robust JavaScript applications.

    What You've Accomplished

    You've built a complete checkout system using Test-Driven Development (TDD), mastering both Chicago style (inside-out) and London style (outside-in) approaches. You've learned Jest's powerful features including assertions, matchers, test doubles (mocks, spies, stubs), parameterized tests, custom matchers, and snapshot testing. You've practiced writing tests first, following the Red-Green-Refactor cycle, and using test doubles to isolate units under test.

    Next Steps

    To continue your testing journey, explore integration testing, end-to-end testing with tools like Cypress, performance testing, and integrating tests into your development workflow. There are many other courses and labs in Plurlasight covering these topics.

    Remember to write tests first, keep them focused, and use appropriate test doubles to maintain fast, reliable test suites. Happy testing!

Julian is a Backend Engineer working with Java, Python, and distributed cloud systems. He has worked in Amazon, Google, and various startups, and focuses on first principles over tools.

What's a lab?

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Provided environment for hands-on practice

We will provide the credentials and environment necessary for you to practice right within your browser.

Guided walkthrough

Follow along with the author’s guided walkthrough and build something new in your provided environment!

Did you know?

On average, you retain 75% more of your learning if you get time for practice.