12. Jest & Test Driven Development
Table of Contents
Learning Objectives
By the end of this lesson, you should be able to:
Explain what unit tests are and why they matter.
Write tests with Jest using
test()andexpect().Use unit tests to safely refactor code that uses loops into higher-order array methods.
Apply test-driven development (TDD) to add new functionality by writing tests before implementation.
Use Jest’s common matchers to check equality.
.toBe.toEqual.not
A Basic Example
Consider this pure function that takes in an array of numbers and returns a copy of that array where each value is doubled:
const doubleArrayPurely = (arr) => {
const doubled = [...arr]
for (let i = 0; i < doubled.length; i++) {
doubled[i] *= 2;
}
return doubled;
};Test Files
"Now that we've tested the application, what should we do with the tests? Do we delete them? Do we comment them out? If we keep them, where can they live?"
With manual testing, you're always left with this question. You've spent time and effort to create the tests so deleting them is wasteful, but we can't just leave them in our code because they add clutter.
Rather than testing functions directly in the files where they live, it is better to create separate test files that import functions and test them against sample inputs. Test files provide a number of benefits:
Separation of concerns: our source code can focus on functionality while test files focus on testing.
Automation: test files can be executed with a single command or can be configured to run automatically whenever a commit is made, ensuring all new code is functional.
Documentation: test files serve as living documentation, showing how functions are expected to behave.
Confidence: having a comprehensive test suite gives developers confidence when making changes.
Jest
Let's learn Jest, the most popular framework for creating test files in JavaScript / Node.
Installation & Setup
Install jest as a developer dependency (with the --save-dev flag)
npm install --save-dev jestModify the package.json file to include a "test" script.
"scripts": {
"test": "jest"
}First Test
We've created a simple example to demonstrate how to create a test file.
In the file called src/sum.js, we've exported a simple sum function.
// sum.js
const sum = (a, b) => a + b;
module.exports = sum;In the tests directory, create a file called sum.test.js (the test file name should always match the name of the file being tested).
Then, add the following code:
// sum.test.js
const sum = require('../src/sum');
test('adds two numbers', () => {
expect(sum(1, 2)).toBe(3);
});The test() function creates a new test to run with a description of the test and a callback function that should contain at least one expect().matcher() call.
expect(expression)takes in an expression to testtoBe(expected)is an example of a matcher method (there are many others). It takes in the value we expect theexpressionto be strictly equal to as if we had compared them using the===operator.
Run the test with:
npm test sumAnd you should see:

Each time we invoke test(message, fn), a new test is created and will appear with the provided message in the test output.
We see a passing test because in the test() callback, the expect(expression) value was equal to the toBe(expected) value.
Practice: Add A Test
In the tests/sum.test.js file, add a new test with the message "adds negatives" and confirms that the sum function will work properly for inputs like -3 and -2.
Run the npm test sum command again and you should now see both tests in the test output.
toEqual and .not
toEqual and .notTake a look back at the src/doubleArrayPurely.js file.
We've already created a test for you in the accompanying tests/doubleArrayPurely.test.js file.
Since we are now working with a reference type (an array), we need to test the output of our function using the .toEqual matcher method.
test('doubles each value in the array', () => {
// toEqual compares the contents of objects and arrays
expect(doubleArrayPurely([1, 2, 3, 4])).toEqual([2, 4, 6, 8]);
});
test('does not mutate the original array', () => {
const original = [1, 2, 3, 4];
const copy = doubleArrayPurely(original);
// A new array should be returned, not the original
expect(copy).not.toBe(original);
// The copy should be doubled and
expect(copy).toEqual([2, 4, 6, 8]);
// The original should not be mutated
expect(original).toEqual([1, 2, 3, 4]);
});This example demonstrates a few new details about expect() calls.
The
toEqual()matcher is used to compare equality of the contents within objects and arrays, rather than comparing their referencesThe
.toBe()matcher can be used to compare the references of objectsThe
.notproperty can be added in the middle of anyexpect().matcher()statement to check the opposite of an expectation.
Refactor with Confidence
Now that we have passing tests, we can change how the code works — as long as it keeps passing the same tests.
Run npm test doubleArrayPurely again: the tests should still pass.
Test-Driven Development
So far we've been looking at tests after we have already written the code. The tests just tell us whether the code we've already written works as expected.
Test-driven development is a workflow for creating software that starts with tests and then uses those tests as a guide for what code to write. Test driven development has a number of benefits:
Clear Requirements - Writing tests first forces you to clearly define what your code should do before writing it
Better Design - Starting with tests helps you design cleaner, more modular code that's easier to test
Fewer Bugs - Having comprehensive tests from the start helps catch bugs early in development
Faster Development - While it may seem slower at first, TDD often leads to faster development by catching issues early
Let's try it out. We'll follow 4 steps:
Define new requirements
Write tests before code (they will fail)
Implement just enough code to pass
Refactor if necessary while keeping the tests passing
Step 1: Define New Requirements
New Feature: The doubleArrayPurely function should be able to arrays containing numbers mixed with strings and other values.
Requirements:
If a given value in the array is a number, multiply the number by 2
If a given value in the array is a string, concatenate the value to itself: (
"abc">"abcabc")If a given value is neither, do nothing to it, just add it to the array.
Step 2: Write Tests Before Code (They Will Fail)
Step 3: Implement Just Enough Code to Pass
Reflection
How did writing tests before code clarify what the function should do?
Banking System Challenge
Consider an array of bank account objects like this:
const bankAccounts = [
{ owner: "Bob", balance: 100 },
{ owner: "Alice", balance: 300 },
{ owner: "Charlie", balance: 200 },
];And a transaction like this:
const depositTransaction = { owner: "Alice", amount: 50 };The deposit function below is designed to process one of these transactions and get an updated set of bank accounts. It is a pure function:
const deposit = (accounts, transaction) => {
let updatedAccounts = [];
for (let i = 0; i < accounts.length; i++) {
// Make a clone of the account object
const accountClone = { ...accounts[i] };
// If the account owner matches the transaction owner, update the balance.
if (accountClone.owner === transaction.owner) {
accountClone.balance += transaction.amount;
}
// Add the account clone to the updatedAccounts array
updatedAccounts.push(accountClone);
}
return updatedAccounts;
};It works by doing the following:
It iterates through each account and makes a copy of the account object, adding it to the
updatedAccountsarray.It then updates the balance of only the account whose owner matches the
transactionowner.Finally, it returns the updated accounts.
Take a look at the bank.test.js file which contains tests for this deposit function.
// bank.spec.js
const { deposit } = require('../src/bank');
describe('deposit', () => {
test('deposits money into the correct account', () => {
const bankAccounts = [
{ owner: "Bob", balance: 100 },
{ owner: "Alice", balance: 300 },
{ owner: "Charlie", balance: 200 },
];
const transaction = { owner: "Alice", amount: 50 };
const updatedAccounts = deposit(bankAccounts, transaction);
// We use `toEqual` to test equality of objects
expect(updatedAccounts).toEqual([
{ owner: "Bob", balance: 100 }, // Bob's account should not be affected
{ owner: "Alice", balance: 350 },
{ owner: "Charlie", balance: 200 }, // Charlie's account should not be affected
]);
});
test('does not mutate the original bank accounts array', () => {
const account = { owner: "Bob", balance: 100 };
const bankAccounts = [account];
const transaction = { owner: "Bob", amount: 1 };
const updatedAccounts = deposit(bankAccounts, transaction);
// the returned array should be a new array
expect(updatedAccounts).not.toBe(bankAccounts);
// the original account should not be mutated
expect(account).toEqual({ owner: "Bob", balance: 100 });
});
});Refactoring with Confidence
Now that we have passing tests, we can change how the code works — as long as it keeps passing the same tests.
Run npm test bank again. The tests should stil pass.
Adding a New Feature with TDD
In Test-Driven Development, we start by writing tests that fail and then write code to pass those tests. The tests serve as a guide for what our code should do.
Step 1: Define a New Requirement
“We want to add a new function
withdraw(owner, amount)that subtracts from the balance, but only if the account has enough money.”
Requirements:
If balance ≥ amount → subtract and return new balance.
If balance < amount → do nothing.
Step 2: Write Tests Before Code (Red)
Step 3: Implement Just Enough Code to Pass (Green)
Extension / Practice
Add a
getEmptyAccounts(bankAccounts)feature using TDD.Add a
add100ToAllAccounts(bankAccounts)feature using TDD.Add a
transfer(bankAccounts, { fromOwner, toOwner, amount })feature using TDD.Refactor repetitive lookup logic into a helper function
getAccountByOwner().Write new tests to confirm helper behavior.
Key Takeaways
Testing provides the following benefits:
Separation of concerns: our source code can focus on functionality while test files focus on testing.
Automation: test files can be executed with a single command or can be configured to run automatically whenever a commit is made, ensuring all new code is functional.
Documentation: test files serve as living documentation, showing how functions are expected to behave.
Confidence: having a comprehensive test suite gives developers confidence when making changes.
Test-driven development is a workflow for creating software that starts with tests and then uses those tests as a guide for what code to write. Test driven development has a number of benefits:
Clear Requirements - Writing tests first forces you to clearly define what your code should do before writing it
Better Design - Starting with tests helps you design cleaner, more modular code that's easier to test
Fewer Bugs - Having comprehensive tests from the start helps catch bugs early in development
Faster Development - While it may seem slower at first, TDD often leads to faster development by catching issues early
Last updated