12. Jest & Test Driven Development

Follow along with code examples here!

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() and expect().

  • 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;
};
Q: Imagine you were given this function and asked to verify that it works. How would you test it? What do you expect to happen when testing?

To test this manually, you could invoke the function with some sample data and see if the output matches what you expect.

const nums = [1, 2, 3, 4];

const copyOfNums = doubleArrayPurely(nums);

console.log(nums);
console.log(copyOfNums);

nums should not be mutated and copyOfNums should contain all of the values doubled. We would expect to see:

[1, 2, 3, 4]
[2, 4, 6, 8]

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 jest

Modify 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 test

  • toBe(expected) is an example of a matcher method (there are many others). It takes in the value we expect the expression to be strictly equal to as if we had compared them using the === operator.

Run the test with:

npm test sum

And you should see:

The sum.test.js test has a passing test.

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.

Solution
test('addsNegatives', () => {
  expect(sum(-3, -2)).toBe(-5);
});

Run the npm test sum command again and you should now see both tests in the test output.

toEqual and .not

Take 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 references

  • The .toBe() matcher can be used to compare the references of objects

  • The .not property can be added in the middle of any expect().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.

Challenge: Refactor doubleArrayPurely to use the higher-order method map
const doubleArrayPurely = (arr) => {
  return arr.map((num) => num * 2);
};

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:

  1. Define new requirements

  2. Write tests before code (they will fail)

  3. Implement just enough code to pass

  4. 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)

Solution
test('can double strings and numbers', () => {
  expect(doubleArrayPurely([1, 2, 'a', 'b'])).toEqual([2, 4, 'aa', 'bb']);
});

Step 3: Implement Just Enough Code to Pass

Solution
const doubleArrayPurely = (arr) => {
  return arr.map((value) => {
    if (typeof value === "number") {
      return value * 2 
    } else if (typeof value === "string") {
      return value + value;
    } else {
      return value;
    }
  });
};

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:

  1. It iterates through each account and makes a copy of the account object, adding it to the updatedAccounts array.

  2. It then updates the balance of only the account whose owner matches the transaction owner.

  3. 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.

Challenge: Refactor deposit to use the higher-order method map
const deposit = (accounts, transaction) => {
  return accounts.map((account) => {
    const accountCopy = { ...account };
    if (accountCopy.owner === transaction.owner) {
      accountCopy.balance += transaction.amount;
    }
    return accountCopy;
  });
};

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)

Solution
// bank.test.js
const { bankAccounts, deposit, withdraw } = require('./bank');

describe('withdraw', () => {
  test('subtracts from balance when sufficient funds', () => {
    const bankAccounts = [
      { owner: "Bob", balance: 100 },
      { owner: "Alice", balance: 300 },
      { owner: "Charlie", balance: 200 },
    ];
    const transaction = { owner: "Alice", amount: 50 };
    const updatedAccounts = withdraw(bankAccounts, transaction);

    expect(updatedAccounts).toEqual([
      { owner: "Bob", balance: 100 },
      { owner: "Alice", balance: 250 }, // only Alice's account should be withdrawn from
      { owner: "Charlie", balance: 200 },
    ]);
  });

  test('does not allow overdraft', () => {
    const bankAccounts = [
      { owner: "Bob", balance: 100 },
      { owner: "Alice", balance: 300 },
      { owner: "Charlie", balance: 200 },
    ];
    const transaction = { owner: "Alice", amount: 500 };
    const updatedAccounts = withdraw(bankAccounts, transaction);

    // No funds were withdrawn so the contents of the accounts should NOT change.
    expect(updatedAccounts).toEqual(bankAccounts);
  });
});

Step 3: Implement Just Enough Code to Pass (Green)

Solution
const withdraw = (owner, amount) => {
  return accounts.map((account) => {
    const accountCopy = { ...account };

    if (accountCopy.owner === transaction.owner) {
      if (account.balance >= amount) {
        account.balance -= amount;
      }
    }
    return accountCopy;
  });
};

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