📖 Introduction to Unit Testing

As projects grow, ensuring everything works as expected becomes challenging. Unit testing allows us to check individual pieces of code to confirm they work as expected. In this article, we'll explore how to write and run tests with Jest, a popular testing framework. By the end, you'll understand the basics of testing and how it can help you catch bugs early.

Core Concepts

Unit Testing
Testing individual units (usually functions) in isolation to ensure they work as expected.
Jest
A JavaScript testing framework that makes writing and running tests straightforward, especially in JavaScript and Node.js projects.
Test-Driven Development (TDD)
A process where you write tests before writing the code itself. While we won't go deep into TDD here, it's a powerful approach to consider.

Getting Started

Let's set up for a simple JavaScript project. Before you begin, make sure Node.js and Node Package Manager (npm) are installed on your machine. You can verify your installation by running the following commands in your terminal.

Node.js

Node.js is a runtime environment that allows you to run JavaScript code outside of a web browser, typically on a server. Node.js comes with npm (Node Package Manager), which simplifies package and dependency management, allowing developers to install and share libraries and tools easily.

# Check Node.js version
node -v

Node Package Manager (npm)

Node Package Manager (npm) allows developers to install, manage, and share reusable packages (libraries, tools, frameworks) that can be easily integrated into their projects.

# Check npm version
npm -v
        

If these commands return version numbers, you're ready to go. If not, you'll need to download and install Node.js first.

Setting Up the Project

Follow these steps to set up the project for testing.

  1. Create the Directory Structure

    Create a root folder for the project, then add the src and tests folders to the app root directory.

  2. Create the project

    Initialize the project to create a package.json file.

    npm init -y
  3. Install Jest

    Install Jest as a development dependency. This is done with one command. This will create the package-lock.json file and install the node_modules.

    npm install --save-dev jest
  4. Add Jest Script

    In package.json, add a test script to run Jest easily.

    "scripts": {
        "test": "jest"
    }
  5. Create .gitignore file. Add /node_modules to the file. Initialize a new Git repo.

Project Structure

After completing the setup, your project directory should look like this:

/shopping-list-manager
    /node_modules                  # Dependencies folder created by npm
    /src
        shoppingListManager.js     # Main file with app functions (code shown below)
    /tests
        shoppingListManager.test.js # Jest test file (code shown below)
    .gitignore                     # Specifies files to ignore in version control, IE: /node_modules
    package.json                   # Project dependencies and scripts
    package-lock.json              # Locks dependency versions for consistency
        

This structure ensures you can keep your functions and tests organized, making it easier to build and test new features as your app grows.

Explanation of Each File

To set up an app for testing, we will create the app with the final structure as we perform the project setup.

src/shoppingListManager.js
The main file containing functions like addItem, removeItem, and getItems that manage the shopping list.
tests/shoppingListManager.test.js
The Jest test file for testing each function in shoppingListManager.js.
package.json
Manages project dependencies and includes a script to run Jest.
package-lock.json
Locks installed dependency versions to ensure consistency across different environments.
node_modules
Contains all project dependencies installed by npm, such as Jest.
.gitignore
Specifies files or folders, like node_modules, to exclude from version control.

Writing Simple Tests

Let's start with a basic example for a Shopping List app. We need functions to add items, remove items, and retrieve the list of items. You will need to create the referenced src/shoppingListManager.js and tests/shoppingListManager.test.js files, if you have not already done so, then add the scripts shown below to each file.

Step 1: Define Functions in src/shoppingListManager.js
// shoppingListManager.js
const shoppingList = [];

function addItem(item) {
    if (typeof item === 'string' && item.trim()) {
        shoppingList.push(item.trim());
    }
}

function removeItem(index) {
    if (index >= 0 && index < shoppingList.length) {
        shoppingList.splice(index, 1);
    }
}

function getItems() {
    return shoppingList;
}

module.exports = { addItem, removeItem, getItems };
Step 2: Write Tests in tests/shoppingListManager.test.js

Jest tests use the test function, where you pass in a description of the test and a function that contains the expectations for what should happen. Here's the general syntax:

test('description of the test', () => {
    // Test code goes here
});

Inside each test, we use Jest's expect function to check if the outcome matches our expectations. Here's how to apply this in our Shopping List app.

// tests/shoppingListManager.test.js
const { addItem, removeItem, getItems } = require('../src/shoppingListManager');

test('addItem should add an item to the list', () => {
    addItem('Milk');
    expect(getItems()).toContain('Milk'); // Expect the list to contain 'Milk'
});

test('removeItem should remove an item from the list', () => {
    addItem('Bread');
    removeItem(0);
    expect(getItems()).not.toContain('Bread'); // Expect the list not to contain 'Bread' after removal
});

In these examples:

  • test('addItem should add an item to the list', ...) describes the action we're testing.
  • expect(getItems()).toContain('Milk') checks if the getItems function returns a list that includes "Milk" after calling addItem.
  • expect(getItems()).not.toContain('Bread') verifies that the item was successfully removed by removeItem.

Running Tests

Run your tests by using the following command:

npm test
Example 1: All Tests Pass
 PASS  tests/shoppingListManager.test.js
  ✓ addItem should add an item to the list (3 ms)
  ✓ removeItem should remove an item from the list (2 ms)
  ✓ clearList should remove all items from the list (2 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        1.567 s
Explanation

All tests passed. Each test shows a checkmark, indicating success. No changes needed here!

Example 2: Error - TypeError: item.trim is not a function
 FAIL  tests/shoppingListManager.test.js
  ✕ addItem should add an item to the list (5 ms)
  ✓ removeItem should remove an item from the list (2 ms)

  ● addItem should add an item to the list

    TypeError: item.trim is not a function

      4 |
      5 | function addItem(item) {
    > 6 |     if (item && item.trim()) {
        |                      ^
      7 |         shoppingList.push(item.trim());
      8 |     }
      9 | }

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.894 s
Explanation

The error TypeError: item.trim is not a function indicates that item.trim() was called on a value that isn't a string. Jest highlights that the problem is in the addItem function on line 6. This may be because a test is passing an incorrect data type to addItem.

Solution

To fix this, update addItem to check that item is a string:

function addItem(item) {
    if (typeof item === 'string' && item.trim()) {
        shoppingList.push(item.trim());
    }
}

This type check ensures that item.trim() only runs if item is a string, preventing the error. Rerun your tests to confirm they pass.

Example 3: Expectation Failure - expect(list).not.toContain("Milk")
 FAIL  tests/shoppingListManager.test.js
  ✕ removeItem should remove an item from the list (3 ms)

  ● removeItem should remove an item from the list

    expect(received).not.toContain(expected) // indexOf

    Expected value: not "Milk"
    Received array: ["Milk"]

      13 |         const list = ["Milk"];
      14 |         removeItem(list, 0);
    > 15 |         expect(list).not.toContain("Milk");
         |                          ^
      16 |     });
      17 | });

Test Suites: 1 failed, 1 total
Tests:       1 failed, 2 total
Snapshots:   0 total
Time:        1.123 s
Explanation

The error indicates that removeItem did not remove "Milk" from the list as expected. This suggests removeItem may not be correctly removing items at the specified index.

Solution

Check the removeItem function to ensure it removes the item based on the index:

function removeItem(index) {
    if (index >= 0 && index < shoppingList.length) {
        shoppingList.splice(index, 1);
    }
}

The splice method removes the item at the given index, so the test should now pass when rerun. Ensure that removeItem receives a valid index, and verify by rerunning your tests.

Interpreting Test Results

Each failure provides helpful clues.

  • Look for the error type, such as TypeError or expect(...).toContain, to understand the root issue.
  • Check the line numbers and specific function calls Jest highlights to locate the problematic code.
  • Use small adjustments in code or tests to isolate and resolve each error systematically.

These fixes should help you understand how to interpret and resolve test failures effectively.

Argument Options and Expected Results

When writing unit tests, it's crucial to consider the possible arguments a function might receive and clearly define the expected results. This approach helps ensure that each test is precise and effectively catches potential issues.

Step 1: Define Argument Options

Start by identifying the range of values that the function could receive. These options typically include:

Valid Inputs
Values that are expected and should yield a successful result.
Invalid Inputs
Values that the function should reject or handle gracefully (e.g., null, undefined, empty strings).
Edge Cases
Extreme or unusual values that test the boundaries of the function's capabilities (e.g., very large numbers, negative values).

Once you've defined these options, you can create tests to handle each scenario.

Step 2: Establish Clear Expected Results

For each test, determine the precise result that should occur if the function is working correctly. Expected results provide a clear metric to verify if the function meets its intended behavior. Consider:

Expected Return Value
For example, expect(getItems()).toContain('Apples'); if addItem('Apples') should add 'Apples' to the list.
Expected Behavior
If a function should reject invalid values, verify that they are not added or processed. For instance, expect(getItems()).not.toContain('') to confirm that empty strings are ignored.
Side Effects
If the function modifies another variable or state, ensure that the side effect occurs as expected (e.g., shoppingList length changes appropriately after an item is added).
Example Test: Combining Argument Options with Expected Results

Below is an example that tests a variety of arguments for the addItem function and verifies that each behaves as expected.

Step 3: Interpreting Results

After running the test, check each result against the expected outcomes:

If a valid item is missing
Check the function logic for handling typical input values.
If an invalid item is added
Ensure the function has checks in place to filter or reject unwanted values.
If the test passes
Confirm that it accurately covers all defined argument options and expected results.

By focusing on the argument options and expected results, your tests become a powerful tool for validating function behavior under various conditions, making your code more robust and reliable.

Writing Effective Tests with Multiple Inputs

When writing tests, it's essential to consider all potential inputs, including both valid and invalid values. This ensures that your functions handle a wide range of cases and improves code reliability. In this example, we will use an array of values to test a function with multiple possible inputs within a single test case.

Example: Testing Multiple Invalid Inputs

Understanding how to pass arguments and what results to expect is essential for effective testing. Each test we write should focus on both the inputs it provides to a function and the criteria used to validate the function's output.

Step 1: Define Argument Options for Each Test Case

Identify Possible Inputs
Consider all possible types and values that could be passed to the function.
Create Variations of Inputs
Develop a range of inputs to thoroughly test the function's response.
Valid Arguments
Test with typical, expected values.
Invalid or Edge-Case Arguments
Include empty strings, non-string values, numbers, and strings with only whitespace.

Step 2: Define Expected Results and Verification

Identify Expected Outcomes
For each argument option, decide what the function should ideally return or change.
Write Verification Statements
Use Jest's expect(...).toContain(...) or other matchers (like .not.toContain(...) for unexpected items) to confirm that the function's behavior matches expectations.
// src/shoppingListManager.test.js
const { addItem, getItems } = require('../src/shoppingListManager');

test('addItem should add a valid item to the list', () => {
    // Pass a regular string to addItem
    addItem('Apples');

    // Verify: Expect 'Apples' to be in the list
    expect(getItems()).toContain('Apples');
});

test('addItem should ignore empty strings', () => {
    // Pass an empty string to addItem
    addItem('');

    // Verify: Expect the list not to contain an empty string
    expect(getItems()).not.toContain('');
});

test('addItem should ignore non-string inputs', () => {
    // Pass a non-string (number) to addItem
    addItem(123);

    // Verify: Expect the list not to contain the number 123
    expect(getItems()).not.toContain(123);
});

test('addItem should trim whitespace from valid strings', () => {
    // Pass a string with whitespace to addItem
    addItem('   Bananas   ');

    // Verify: Expect 'Bananas' to be in the list without extra whitespace
    expect(getItems()).toContain('Bananas');
});
Example: Testing Multiple Invalid Inputs in One Test

Let's start by testing the addItem function to ensure it ignores invalid inputs. Here, we'll use an array to group various invalid values and test each one in a single Jest test. This makes the code efficient and helps avoid repetitive tests.

// shoppingListManager.test.js
const { addItem, getItems } = require('../src/shoppingListManager');

test('addItem should ignore invalid inputs', () => {
    // Array of invalid inputs
    const invalidInputs = ['', '   ', null, undefined, 123, {}, []];

    // Pass each invalid input to addItem
    invalidInputs.forEach(input => {
        addItem(input);
    });

    // Verify: Expect none of the invalid inputs to be added to the list
    invalidInputs.forEach(input => {
        expect(getItems()).not.toContain(input);
    });
});
Explanation

This test case demonstrates how to handle multiple values in a single test case:

Define the Inputs
Create an invalidInputs array containing different invalid values, such as empty strings, numbers, and objects, which should not be added to the list.
Run the Function
Use forEach to call addItem with each input value.
Verify the Results
For each value in invalidInputs, verify that it was not added to the list using expect(getItems()).not.toContain(input);
Example: Testing Multiple Valid Inputs in One Test

Similarly, we can use an array to test valid inputs for addItem. This test checks that each valid item is correctly added to the shopping list.

test('addItem should add valid items', () => {
    // Array of valid inputs
    const validInputs = ['Apples', 'Oranges', 'Bananas'];

    // Add each valid input to the shopping list
    validInputs.forEach(input => {
        addItem(input);
    });

    // Verify: Each valid input should now be in the list
    validInputs.forEach(input => {
        expect(getItems()).toContain(input);
    });
});
Best Practices for Writing Tests

When writing tests with multiple inputs, keep these tips in mind:

Organize Similar Inputs Together
Use arrays to group values with similar expectations. This keeps tests concise and easy to read.
Verify Expected Results Clearly
Ensure each test has a clear, expected result by specifying conditions such as .toContain or .not.toContain with relevant values.
Handle Edge Cases
Think about edge cases, like empty strings or unexpected data types, and include them in your tests to make the code more robust.

This strategy gives you a practical way to test a variety of cases without writing repetitive code. With a few adjustments, you can apply this technique to other functions and testing scenarios, ensuring that your code is well-tested for a wide range of input values.

Putting It Into Action

To practice writing and running tests, follow these steps. Note: This section builds on the earlier example setup. Ensure you have the basic functions (addItem, removeItem, getItems) already implemented and tested before continuing.

  1. Create a New Function

    Add a clearList() function in shoppingListManager.js to empty the shopping list.

    function clearList() {
        shoppingList.length = 0;
    }
  2. Write Tests for clearList

    Write a test case to verify that clearList removes all items from the list.

    test('clearList should remove all items from the list', () => {
        addItem('Milk');
        addItem('Eggs');
        clearList();
        expect(getItems()).toEqual([]);
    });
  3. Run the Tests: Check that all tests pass.

Challenge

Apply your knowledge of writing effective tests by extending the current functionality.

Instructions
Define multiple possible inputs and expected outcomes, as discussed in the article. Here's a structure you can follow:
Identify Possible Inputs
  • Valid inputs (e.g., typical expected values)
  • Invalid inputs (e.g., empty strings, numbers, objects)
Define Expected Results
For each input type, specify what the function should return or change.
Verification Statements
Use expect(...).toContain(...) for valid inputs and expect(...).not.toContain(...) for invalid inputs.

In order to check your learning, you should attempt to create a solution before revealing the provided solution below.

Example of Testing Multiple Values
test('addItem handles multiple valid and invalid inputs', () => {
    const validInputs = ['Milk', 'Eggs', 'Bread'];
    const invalidInputs = ['', 123, {}, null, undefined];
    
    validInputs.forEach(input => {
        addItem(input);
        expect(getItems()).toContain(input);
    });
    
    invalidInputs.forEach(input => {
        addItem(input);
        expect(getItems()).not.toContain(input);
    });
});

Remember, successful tests will confirm that valid inputs are added to the list, while invalid inputs are ignored. Good luck!

Summary

Unit testing helps ensure that each function in your code works as expected. In this article, we introduced Jest, wrote a few basic tests, and explored how to expand and refactor our tests as the app grows. With these tools, you can catch bugs early and maintain more reliable code!

References