📖 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.
- Create the Directory Structure
Create a root folder for the project, then add the
src
andtests
folders to the app root directory. - Create the project
Initialize the project to create a
package.json
file.npm init -y
- Install Jest
Install Jest as a development dependency. This is done with one command. This will create the
package-lock.json
file and install thenode_modules
.npm install --save-dev jest
- Add Jest Script
In
package.json
, add a test script to run Jest easily."scripts": { "test": "jest" }
- 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
, andgetItems
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 thegetItems
function returns a list that includes "Milk" after callingaddItem
.expect(getItems()).not.toContain('Bread')
verifies that the item was successfully removed byremoveItem
.
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
orexpect(...).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');
ifaddItem('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 calladdItem
with each input value. - Verify the Results
- For each value in
invalidInputs
, verify that it was not added to the list usingexpect(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.
- Create a New Function
Add a
clearList()
function inshoppingListManager.js
to empty the shopping list.function clearList() { shoppingList.length = 0; }
- 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([]); });
- 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 andexpect(...).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!