Featured resource
pluralsight tech forecast
2025 Tech Forecast

Which technologies will dominate in 2025? And what skills do you need to keep up?

Check it out
Hamburger Icon
  • Labs icon Lab
  • Core Tech
Labs

Guided: Unit Testing in JavaScript

This code lab will teach you how to implement unit tests for JavaScript/Node.js applications using Jest. You'll learn essential concepts and techniques, including test structure, assertions, mocking, asynchronous testing, and test coverage.

Labs

Path Info

Level
Clock icon Intermediate
Duration
Clock icon 1h 20m
Published
Clock icon Jul 03, 2024

Contact sales

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

Table of Contents

  1. Challenge

    Introduction

    Welcome to the lab Guided: Unit Testing in JavaScript.

    In this code lab, you will implement unit tests using Jest for a shopping cart application built with Node.js and Express.

    The application has the following functionality:

    • Display a catalog of products
    • Allow users to add products to the shopping cart
    • Display the contents of the shopping cart
    • Allow users to update the quantity of items in the cart
    • Allow users to remove items from the cart
    • Clear the entire cart
    • Simulate the checkout process with an asynchronous operation and clear the cart upon completion

    To run the application, either click the Run in the bottom-right corner of the screen or in a Terminal tab execute:

    npm start
    

    Then, click the following link to open the application in a new browser tab: {{localhost:3000}}. Alternatively, you can refresh the Web Browser tab next to the Terminal tabs to reload and view the application there.

    Note that an additional Terminal tab has been provided to you, so you can run the other commands you'll be executing throughout this lab. ---

    Familiarizing with the Program Structure

    The application includes the following files:

    1. app.js: The main entry point of the application, which sets up the Express server and defines routes.

    2. controllers/cartController.js: Contains the controller functions for handling cart-related operations.

    3. models/cart.js: Defines the Cart class that manages the shopping cart functionality.

    4. routes/appRoutes.js: Defines the routes for the shopping cart application.

    5. data/products.js: Contains the sample product data.

    6. views/index.ejs: The EJS template for rendering the shopping cart page.

    7. views/products.ejs: The EJS template for rendering the product catalog page.

    However, you'll focus on configuring Jest and writing test files for different parts of the application.

    Start by examining the provided code and understanding the program structure. When you're ready, dive into the coding process. If you encounter any problems, remember that a solution directory is available for reference or to verify your code.

  2. Challenge

    Step 1: Setting Up Jest

    Setup and Configuration

    To start using Jest, you first need to add it to your project's dependencies. You can do this by running the following command in your project's root directory:

    npm install --save-dev jest
    

    This command installs Jest as a development dependency (--save-dev) in your project. However, in this environment, Jest is already installed, so you don't have to execute the above command.

    Once Jest is installed, you can run your tests using the jest command in your terminal. By default, Jest looks for test files in a __tests__ directory, or in files with a .spec.js, .spec.jsx, .test.js, or .test.jsx extension.

    For example, if you have a test file named myFunction.test.js, you can run it with Jest by executing:

    npx jest myFunction.test.js
    

    npx is a tool that comes with npm (Node Package Manager). It is used to execute binaries from node_modules without having to install them globally. So, when you run the above command, npx looks for the jest binary in your project's node_modules/.bin directory and executes it, allowing you to run your tests without requiring a global installation of Jest.

    However, it's often more convenient to set up a test script in your project's package.json file. This allows you to run your tests with a simple command like npm test. This offers several benefits:

    1. Consistency: All developers working on the project can run tests the same way, regardless of their environment.
    2. Simplicity: Running npm test is easier and less error-prone than remembering and typing out the full Jest command.
    3. Customization: You can add additional options or flags to your test script to customize Jest's behavior for your project.

    The node_modules directory of this lab already contains the Jest dependency. In the next task, you'll set up the test script. After completing the task, you'll be able to run your Jest tests by executing the command npm test in the terminal. However, keep in mind that since we're just beginning, only a few tests will pass at this stage. ---

    The jest.config.js File

    By default, Jest works well out of the box, but sometimes you may need to tweak its settings to better suit your project's needs. That's where the jest.config.js file comes in handy.

    The jest.config.js file is a special file that allows you to customize Jest's configuration for your project. It is automatically detected by Jest when running your tests.

    Here are some of the most important options you can configure in the jest.config.js file:

    1. verbose: A boolean that indicates whether Jest should output verbose information about the test run. Setting it to true can be helpful for debugging.

    2. testMatch: An array of glob patterns that Jest uses to detect test files. Remember, by default, Jest looks for files with .test.js, .spec.js, or .js extensions inside __tests__ folders.

    3. setupFiles: An array of paths to modules that run some code to configure or set up the testing environment before each test file in the suite is executed.

    4. collectCoverage: A boolean that indicates whether Jest should collect code coverage information during the test run.

    5. moduleNameMapper: An object that maps module names to their corresponding paths, allowing you to stub out resources like images or styles with a single module.

    Here's an example of what a jest.config.js file might look like:

    module.exports = {
      verbose: true,
      testMatch: ['**/__tests__/**/*.js?(x)', '**/?(*.)+(spec|test).js?(x)'],
      setupFiles: ['./setupTests.js'],
      collectCoverage: true,
      moduleNameMapper: {
        '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
          '<rootDir>/__mocks__/fileMock.js',
        '\\.(css|less)$': '<rootDir>/__mocks__/styleMock.js',
      },
    };
    

    In the next task, you will configure Jest to output verbose information.

  3. Challenge

    Step 2: Writing Tests

    Test Structure: Given-When-Then

    A common pattern for structuring tests is the Given-When-Then approach:

    1. Given: Set up the necessary preconditions and inputs for the test.
    2. When: Perform the action or behavior that you want to test.
    3. Then: Assert the expected outcomes or results.

    This structure helps make your tests clear, readable, and focused on a single behavior or scenario.

    In Jest, you can use the test function to define individual test cases. The test function takes two arguments:

    1. A string description of the test case.
    2. A function that contains the test code.

    Here's an example:

    test('should add two numbers', () => {
      // Given
      const a = 2;
      const b = 3;
    
      // When
      const sum = a + b;
    
      // Then
      expect(sum).toBe(5);
    });
    

    In this example, the test verifies that adding two numbers produces the expected result. The expect function is used to make assertions about the test outcomes.

    Also, when labeling your test functions, it's a common convention to use the word should followed by a description of the expected behavior, as in the above example:

    test('should add two numbers', () => {
      // Test logic...
    });
    

    Using should in the test label helps to express the intended behavior or outcome of the test in a clear and readable way. It communicates the expectation of what the code being tested should do.

    While this convention is not strictly enforced by Jest, it is widely adopted and considered a good practice for writing clear and maintainable tests.

    In the following tasks, you'll write a test using Jest's test function. After completing this task, you will have added an empty test function to verify the functionality of adding an item to the cart. In the next task, you will implement the test logic inside this function. The test is now complete. It creates a new Cart instance, defines an item object, adds the item to the cart using the addItem method, and asserts that the item object is present in the cart.items array.

    You can run the test by executing the following command:

    npm test __tests__/addItemCart.test.js
    ``` ---
    ### Grouping Tests with `describe`
    
    When you have multiple related test cases, you can group them together using the `describe` function. The `describe` function takes two arguments:
    
    1. A string description of the group of tests.
    2. A function that contains the related test cases.
    
    Here's an example:
    
    ```javascript
    describe('Math operations', () => {
      test('should add two numbers', () => {
        // ...
      });
    
      test('should subtract two numbers', () => {
        // ...
      });
    
      // More related test cases...
    });
    

    Grouping tests with describe helps organize your test suite and makes it easier to understand the structure of your tests.

    Jest also provides helper functions (also called hooks) to handle setup and teardown logic for your tests:

    • beforeEach: Runs before each test case in the current test suite.
    • beforeAll: Runs once before all the test cases in the current test suite.
    • afterEach: Runs after each test case in the current test suite.
    • afterAll: Runs once after all the test cases in the current test suite.

    These functions allow you to set up necessary preconditions, clean up resources, or perform any other required actions before or after your tests run.

    In the upcoming tasks, you'll practice using Jest's describe function and explore how to use beforeEach to extract common setup logic. By wrapping the test functions inside a describe block, you group related test cases together, making the test suite more organized and readable. However, there's common code in the test methods that can be extracted into a beforeEach method. You will do this in the next task. By extracting the cart creation logic into a beforeEach hook, you ensure that a new Cart instance is created before each test case. This promotes test isolation and helps maintain a clean state for each test.

    However, you might be wondering about the creation of the item object within each test case. The recommendation is to extract only the Cart instance creation into the beforeEach hook and keeping the item objects within each test case. Here's the reasoning:

    1. The Cart instance is created in the same way across all the test cases, so extracting it into the beforeEach hook reduces duplication and ensures a fresh Cart instance for each test case.

    2. The item objects used in the test cases have different properties and values. For example, in the first test case, item has a quantity of 1, while in the second test case, item1 and item2 have different quantity values. Extracting the item objects into a shared variable may lead to confusion and make the test cases less readable.

    3. Keeping the item objects within each test case makes the tests more self-contained and independent. It allows for easier modification of the item objects specific to each test case without affecting others.

    You can run the tests by executing the following command:

    npm test __tests__/updateQuantityCart.test.js
    
  4. Challenge

    Step 3: Using Matchers

    What are Matchers?

    Matchers are methods provided by Jest that let you compare and verify values in your tests. They are used in conjunction with the expect function to create assertions.

    In the previous lesson, we already used a matcher in the addItemCart.test.js file:

    expect(cart.items).toContainEqual(item);
    

    Here, toContainEqual is a matcher that checks if the cart.items array contains an element that matches the item object.

    Jest provides a wide range of matchers to cover different assertion scenarios. Let's take a look at some commonly used matchers.

    Common Matchers

    1. toBe: Checks strict equality (===) between the actual and expected values.

      expect(result).toBe(42);
      
    2. toEqual: Checks deep equality between the actual and expected values, recursively comparing all properties of objects and arrays.

      expect(obj).toEqual({ name: 'John', age: 30 });
      
    3. toContain: Checks if an array or string contains a specific element or substring.

      expect(array).toContain('apple');
      expect(string).toContain('hello');
      
    4. toBeDefined: Checks if a value is not undefined.

      expect(result).toBeDefined();
      
    5. toBeTruthy: Checks if a value is truthy (evaluates to true in a boolean context).

      expect(result).toBeTruthy();
      
    6. toBeFalsy: Checks if a value is falsy (evaluates to false in a boolean context).

      expect(result).toBeFalsy();
      
    7. toBeGreaterThan, toBeGreaterThanOrEqual, toBeLessThan, toBeLessThanOrEqual: Checks if a value is greater than, greater than or equal to, less than, or less than or equal to another value.

      expect(result).toBeGreaterThan(10);
      expect(result).toBeLessThanOrEqual(5);
      
    8. toMatch: Checks if a string matches a regular expression.

      expect(string).toMatch(/hello/);
      
    9. toThrow: Checks if a function throws an error when called.

      expect(() => {
        throw new Error('An error occurred');
      }).toThrow();
      

    These are just a few examples of the matchers available in Jest. You can find the complete list of matchers in the Jest documentation.

    Negating Matchers

    You can invert the behavior of any matcher by chaining the .not modifier before the matcher:

    expect(value).not.toBe(42);
    expect(array).not.toContain('banana');
    

    This is useful when you want to assert that something should not be true or should not have a certain value.

    In the next tasks, you'll practice using matchers to add assertions to existing tests in the __tests__/otherFunctionalityCart.test.js file. The test file complete. You can run it by executing the following command:

    npm test __tests__/otherFunctionalityCart.test.js
    
  5. Challenge

    Step 4: Using Mocks

    What is Mocking?

    Mocking is a technique used in testing to replace real objects or functions with simulated versions that mimic their behavior. Mocks allow you to control the behavior of dependencies, isolate the code being tested, and verify interactions between different parts of your codebase.

    Jest provides built-in mocking capabilities through the jest.mock and jest.fn functions.

    Using jest.mock

    The jest.mock function is used to automatically mock a module or a function. When you use jest.mock, Jest replaces the actual implementation of the module or function with a mocked version.

    Here's an example of using jest.mock to mock a module:

    jest.mock('../src/models/product');
    

    In this case, Jest will replace the actual implementation of the product module with a mocked version. Any functions exported by the product module will be replaced with mock functions.

    Using jest.fn

    The jest.fn function is used to create a standalone mock function. It allows you to create a new function that can be tracked and configured with custom behavior.

    Here's an example of using jest.fn to create a mock function:

    const mockFunction = jest.fn();
    

    You can then use the mock function in your code and assert its behavior.

    Configuring Mock Behavior

    Once you have created a mock using jest.mock or jest.fn, you can configure its behavior using methods like mockReturnValue or mockResolvedValue.

    For example, to configure a mock function to return a specific value:

    mockFunction.mockReturnValue(42);
    

    Now, whenever the mockFunction is called, it will return the value 42.

    Asserting Mock Interactions

    Jest provides assertion functions to verify the interactions with mocks. One commonly used assertion is toHaveBeenCalledWith, which checks if a mock function was called with specific arguments.

    Here's an example of using toHaveBeenCalledWith:

    expect(mockFunction).toHaveBeenCalledWith('arg1', 'arg2');
    

    This assertion verifies that mockFunction was called with the arguments 'arg1' and 'arg2'.

    Other useful assertions for mocks include:

    • toHaveBeenCalled: Checks if a mock function was called at least once.
    • toHaveBeenCalledTimes: Verifies the number of times a mock function was called.
    • toHaveBeenLastCalledWith: Checks the arguments of the last call to a mock function.

    By using these assertions, you can ensure that your mocks are being invoked correctly and with the expected arguments.

    In the next tasks, you'll practice configuring mocks using jest.mock and jest.fn, and you'll use assertions like toHaveBeenCalledWith to verify mock interactions in the cartController.test.js file. Once you have added the necessary code to mock the Cart model and products data, the tests will use the mocked versions instead of the actual implementations.

    However, since the tests are testing the controller, you also need to mock the response objects. You'll do it in the next task. After replacing the null values with appropriate mock functions using jest.fn(), the res object will have mocked versions of the render, redirect, json, and status methods available for use in your tests. This setup ensures fresh request and response objects before each test.

    Additionally, the afterEach hook will clear all mocks after each test:

    afterEach(() => {
        jest.clearAllMocks();
    });
    

    The only remaining task is to practice using the toHaveBeenCalledWith matcher. To keep it simple, you'll complete the implementation of the first test case only. Once you have added the expect statements with the appropriate toHaveBeenCalledWith matchers, the test case will verify that the removeItem function is called with the correct item ID and that the redirect function is called with the correct URL.

    You can see other examples of how to use the toHaveBeenCalledWith matcher in the other test functions.

    Finally, you can run the test file by executing the following command:

    npm test __tests__/cartController.test.js
    
  6. Challenge

    Step 5: Asynchronous Testing

    Testing Promises

    When testing asynchronous code that uses promises, you can use the resolves and rejects matchers in combination with await to make assertions about the resolution or rejection of a promise.

    Here's an example of testing a promise that resolves successfully:

    test('should fetch user data successfully', async () => {
      const userData = { id: 1, name: 'John' };
      const fetchUser = () => Promise.resolve(userData);
      await expect(fetchUser()).resolves.toEqual(userData);
    });
    

    In this test, we have a function fetchUser that returns a promise resolving to userData. We use await to wait for the promise to resolve and the resolves matcher to assert that the promise resolves with the expected userData.

    Similarly, you can test a promise that rejects with an error:

    test('should throw an error when fetching user data fails', async () => {
      const errorMessage = 'Failed to fetch user data';
      const fetchUser = () => Promise.reject(new Error(errorMessage));
      await expect(fetchUser()).rejects.toThrow(errorMessage);
    });
    

    Here, we have a function fetchUser that returns a promise rejecting with an error. We use the rejects matcher along with toThrow to assert that the promise rejects with the expected error message.

    In the next tasks, you'll complete the implementation of the unit tests for the asynchronous checkout functionality, covering both resolved and rejected promise scenarios. Once you have added the expect statements with the appropriate matchers, the test cases will verify that the checkout method resolves or rejects with the expected success or error messages.

    You can run the tests by executing the following command:

    npm test __tests__/checkoutCart.test.js
    ``` ---
    ### Mocking Asynchronous Behavior
    
    When testing code that depends on asynchronous behavior, you may need to mock the asynchronous functions to control their behavior in tests. Jest provides methods like `mockResolvedValue` and `mockRejectedValue` to mock the resolution or rejection of a promise.
    
    Here's an example of mocking a promise to resolve with a specific value:
    
    ```javascript
    test('should display user data when API call succeeds', async () => {
      const userData = { id: 1, name: 'John' };
      api.getUserData.mockResolvedValue(userData);
      // Test code that calls getUserData and displays user data
      expect(screen.getByText('John')).toBeInTheDocument();
    });
    

    In this test, we mock the getUserData method of an api module using mockResolvedValue to resolve with userData. The test can then assert that the user data is displayed correctly.

    Similarly, you can mock a promise to reject with an error:

    test('should display error message when API call fails', async () => {
      const errorMessage = 'Failed to fetch user data';
      api.getUserData.mockRejectedValue(new Error(errorMessage));
      // Test code that calls getUserData and displays error message
      expect(screen.getByText(errorMessage)).toBeInTheDocument();
    });
    

    Here, we use mockRejectedValue to mock the getUserData method to reject with the specified error. The test can then assert that the error message is displayed appropriately.

    In the next tasks, you'll complete the implementation of the unit tests for the asynchronous checkout functionality, mocking both resolved and rejected values to test the checkout controller. Once you have added the lines to mock the resolved and rejected values of the checkout method, the test cases will use the mocked values instead of the actual implementation when calling cartController.checkout.

    You can run the tests by executing the following command:

    npm test __tests__/checkoutCartController.test.js
    
  7. Challenge

    Step 6: Test Coverage

    What is Test Coverage?

    Test coverage is a metric that measures the extent to which your code is covered by tests. It helps you identify parts of your codebase that lack sufficient testing and ensures that your tests are comprehensive.

    Jest provides built-in support for generating test coverage reports. These reports show you the percentage of code that is executed when running your tests, highlighting areas that are not covered.

    Configuring Test Coverage

    To enable test coverage in Jest, you need to configure it in the jest.config.js file. Here are the key options related to test coverage:

    1. collectCoverage: Set this option to true to enable test coverage collection.

      module.exports = {
        collectCoverage: true,
        // ...
      };
      
    2. collectCoverageFrom: Specify the files and patterns for which coverage information should be collected. You can use glob patterns to match specific files or directories.

      module.exports = {
        // ...
        collectCoverageFrom: ["src/dir1/*.{js,jsx}", "src/dir2/*.{js,jsx}", "src/dir3/*.{js,jsx}"],
        // ...
      };
      

      In this example, coverage will be collected for all .js and .jsx files in the src/dir1, src/dir2, and src/dir3 directories.

    3. coverageThreshold: Set minimum coverage thresholds for statements, branches, functions, and lines. If the coverage falls below the specified thresholds, Jest will fail the tests.

      module.exports = {
        // ...
        coverageThreshold: {
          global: {
            statements: 80,
            branches: 80,
            functions: 80,
            lines: 80,
          },
        },
        // ...
      };
      

      In this example, the global coverage thresholds are set to 80% for statements, branches, functions, and lines. Jest will fail the tests if the coverage falls below these thresholds.

    Analyzing Coverage Reports

    When you run your tests with coverage enabled, a report is generated automatically. This report provides detailed information about the coverage of your code. It includes:

    • Coverage percentages for statements, branches, functions, and lines.
    • A list of files and their individual coverage percentages.
    • Highlighted code showing covered and uncovered lines.

    You can open the coverage/lcov-report/index.html file in a web browser to view an interactive coverage report.

    Achieving 100% Test Coverage

    While striving for 100% test coverage is a good goal, it's important to note that it doesn't guarantee bug-free code. However, high test coverage increases confidence in your codebase and helps catch potential issues.

    To achieve 100% test coverage, you need to ensure that all statements, branches, functions, and lines of your code are executed by your tests. This may involve adding more test cases, covering edge cases, and testing error scenarios.

    In the next tasks, you'll configuring test coverage in the jest.config.js file and enable a previously disabled test to achieve 100% coverage. After configuring test coverage in the jest.config.js file, if you run the all the tests with:

    npm test
    

    You'll notice two things:

    1. The coverage threshold for branches (90%) is not met.
    2. The coverage directory with the report is generated.

    To achieve the desired coverage, you'll need to enable a currently disabled test in the updateQuantityCart.test.js file. By removing .skip, the test case will now be included in the test execution, contributing to the overall test coverage. If you run npm test, you'll see that the tests now achieve 100% coverage.

  8. Challenge

    Conclusion

    Congratulations on successfully completing this Code Lab!

    You've learned the fundamentals of writing unit tests, including:

    • Setting up Jest in your project
    • Writing test cases using Jest's test and describe functions
    • Using matchers to make assertions about the expected behavior
    • Mocking dependencies and asynchronous code
    • Configuring and achieving high test coverage

    Remember that to run the application, either click the Run in the bottom-right corner of the screen or in a Terminal tab execute:

    npm start
    

    Then, click the following link to open the application in a new browser tab: {{localhost:3000}}. Alternatively, you can refresh the Web Browser tab next to the Terminal tabs to reload and view the application there.

    To run all the tests, you can use the command npm test, which is equivalent to npx jest. However, you can also run individual test files by passing the name of the test file as an argument to the npm test command. For example, npm test __tests__/addItemCart.test.js will run only the tests in the addItemCart.test.js file.

    Writing unit tests is an essential practice in software development. It helps ensure the correctness of your application, prevents regression errors, and provides documentation for the expected behavior of your functions and classes. ---

    Extending the Program

    Consider exploring these ideas to further enhance your skills and expand the capabilities of the program:

    1. Implement a wishlist feature. Add functionality to allow users to create and manage a wishlist of products. Write unit tests to verify the behavior of adding items to the wishlist, removing items from the wishlist, and retrieving the wishlist.

    2. Implement a product review system. Allow users to leave reviews and ratings for products. Write unit tests to validate the submission of reviews, the calculation of average ratings, and the retrieval of product reviews.

    3. Add support for product variations. Extend the product model to include variations such as size, color, or other attributes. Write unit tests to ensure that the variations are properly handled during product selection, cart management, and order processing.

    You can follow the TDD (Test-Driven Development) approach when implementing these features. Write the unit tests first, describing the expected behavior, and then implement the functionality to make the tests pass. This will help you maintain high test coverage and ensure that the new features are thoroughly tested. ---

    Related Courses on Pluralsight's Library

    If you're interested in further honing your JavaScript skills or exploring more topics, Pluralsight offers several excellent courses in the following path:

    These courses cover many aspects of JavaScript programming. Check them out to continue your learning journey in JavaScript!

Esteban Herrera has more than twelve years of experience in the software development industry. Having worked in many roles and projects, he has found his passion in programming with Java and JavaScript. Nowadays, he spends all his time learning new things, writing articles, teaching programming, and enjoying his kids.

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.