How to test pure functions with Jest

How to test pure functions with Jest

A pure function is, quite simply, a function that, given the same input, will always return the same output without causing any side effects. This predictability isn’t just a nice-to-have; it’s the bedrock of reliable, maintainable code.

Think the following example:

function add(a, b) {
  return a + b;
}

No matter how many times you call add(2, 3), it will always return 5. It doesn’t alter any external state, nor does it depend on anything outside its parameters. This characteristic makes pure functions incredibly easy to test and reason about.

Contrast this with an impure function:

let counter = 0;

function increment() {
  counter += 1;
  return counter;
}

Calling increment() multiple times yields different results because it modifies the external variable counter. This side effect complicates testing and debugging, as the function’s behavior depends on the external state.

Why do pure functions matter beyond testing? Because they enable composability. When functions behave like predictable black boxes, you can combine them in complex ways without unexpected outcomes. They also dovetail nicely with functional programming paradigms and tools that rely on immutability and referential transparency.

Here’s a more nuanced example involving arrays:

function doubleValues(arr) {
  return arr.map(x => x * 2);
}

This function takes an array and returns a new array with each element doubled. It doesn’t mutate the original array, which means no side effects. If you called doubleValues([1, 2, 3]) twice, you’d get the exact same output each time.

Compare that with:

function doubleValuesImpure(arr) {
  for (let i = 0; i < arr.length; i++) {
    arr[i] *= 2;
  }
  return arr;
}

This version mutates the input array, making it impure. The first call changes the array, so subsequent calls will behave differently.

Ultimately, pure functions give you a solid foundation for writing predictable, testable, and maintainable code. They make the act of writing tests simpler, because you only need to consider inputs and outputs, not hidden side effects or external dependencies. That is why understanding and applying purity in functions is fundamental before moving to proper testing setups like Jest.

Setting up Jest for effective testing

To set up Jest for testing your JavaScript code, you first need to ensure that you have Node.js installed on your machine. Jest is a testing framework that runs on Node.js, and you can easily install it using npm. Begin by creating a new project directory if you haven’t done so already.

Once you’re in your project directory, run the following command to initialize a new npm project:

npm init -y

This command creates a package.json file with default settings. Next, install Jest as a development dependency:

npm install --save-dev jest

After installation, you’ll want to configure Jest in your package.json. Add a test script to the scripts section:

"scripts": {
  "test": "jest"
}

Now you can create a test file. Jest conventionally looks for files with a .test.js or .spec.js suffix. For example, if you have a function in a file named math.js, you could create a corresponding test file named math.test.js.

In your test file, you can start writing tests for your pure functions. Here’s how you might test the add function:

const { add } = require('./math');

test('adds 2 + 3 to equal 5', () => {
  expect(add(2, 3)).toBe(5);
});

This snippet uses Jest’s test function to define a test case. The expect function checks if the output of add(2, 3) equals 5. You can run your tests by executing:

npm test

Jest will automatically find your test files and run them, providing a summary of the results in the terminal. When you write tests for pure functions, you can focus solely on the inputs and outputs without worrying about other factors.

As you expand your testing suite, ponder writing tests for more complex functions. For example, you might want to test the doubleValues function:

const { doubleValues } = require('./math');

test('doubles values in an array', () => {
  expect(doubleValues([1, 2, 3])).toEqual([2, 4, 6]);
});

This test case checks that the output of doubleValues matches the expected array. The use of toEqual very important here, as it checks the contents of the arrays rather than their references.

As you continue to write tests, you might encounter scenarios where you need to test error handling or edge cases. Jest provides a variety of matchers to help with this, so that you can assert that functions throw errors under specific conditions:

test('throws error when input is not an array', () => {
  expect(() => doubleValues('not an array')).toThrow();
});

This test ensures that your function throws an error if it receives a non-array input, which is an essential aspect of robust software development. In this way, Jest allows you to cover a wide range of scenarios in your testing, ensuring that your pure functions behave as expected.

Writing test cases for pure functions

When writing test cases for pure functions, the emphasis is on verifying that for given inputs, the function returns the expected outputs consistently. Since pure functions have no side effects, you don’t need to mock dependencies or manage external state. This simplicity allows for concise and clear tests.

Start by importing the function you want to test. Suppose you have a file math.js with the following pure function:

function multiply(a, b) {
  return a * b;
}

module.exports = { multiply };

Your test file, math.test.js, would import and test it like this:

const { multiply } = require('./math');

test('multiplies 4 and 5 to get 20', () => {
  expect(multiply(4, 5)).toBe(20);
});

test('multiplies any number by zero to get zero', () => {
  expect(multiply(7, 0)).toBe(0);
  expect(multiply(0, 7)).toBe(0);
});

Notice how each test is focused on a specific behavior or case. This modular approach makes it easier to pinpoint failures and ensures comprehensive coverage.

For functions that operate on arrays or objects, use toEqual for deep equality checks rather than toBe, which tests reference equality. For example, testing a function that reverses an array:

function reverseArray(arr) {
  return arr.slice().reverse();
}

module.exports = { reverseArray };

And the corresponding test:

const { reverseArray } = require('./math');

test('reverses the array correctly', () => {
  const input = [1, 2, 3];
  const output = [3, 2, 1];
  expect(reverseArray(input)).toEqual(output);
});

Here, arr.slice() creates a copy to maintain purity by avoiding mutation of the original array.

Testing edge cases is equally important. Take a function that sums an array of numbers:

function sumArray(arr) {
  if (!Array.isArray(arr)) throw new TypeError('Input must be an array');
  return arr.reduce((acc, val) => acc + val, 0);
}

module.exports = { sumArray };

Tests might look like this:

const { sumArray } = require('./math');

test('sums an array of numbers', () => {
  expect(sumArray([1, 2, 3, 4])).toBe(10);
});

test('returns 0 for an empty array', () => {
  expect(sumArray([])).toBe(0);
});

test('throws error when input is not an array', () => {
  expect(() => sumArray(null)).toThrow(TypeError);
  expect(() => sumArray(123)).toThrow('Input must be an array');
});

These tests confirm the function’s behavior with normal inputs, boundary conditions, and invalid inputs. The use of specific error messages in toThrow assertions helps verify that the function fails in predictable and meaningful ways.

When testing pure functions that involve floating-point arithmetic, consider using toBeCloseTo to handle precision issues:

function divide(a, b) {
  if (b === 0) throw new Error('Division by zero');
  return a / b;
}

module.exports = { divide };

Test example:

const { divide } = require('./math');

test('divides numbers with floating point result', () => {
  expect(divide(1, 3)).toBeCloseTo(0.333, 3);
});

test('throws error when dividing by zero', () => {
  expect(() => divide(5, 0)).toThrow('Division by zero');
});

This approach ensures that minor floating-point inaccuracies don’t cause your tests to fail erroneously.

Finally, when testing pure functions, it’s often helpful to group related tests using describe blocks for better organization and readability:

const { multiply, sumArray } = require('./math');

describe('multiply function', () => {
  test('multiplies positive numbers', () => {
    expect(multiply(3, 7)).toBe(21);
  });

  test('multiplies by zero', () => {
    expect(multiply(0, 50)).toBe(0);
  });
});

describe('sumArray function', () => {
  test('sums array of numbers', () => {
    expect(sumArray([1, 2, 3])).toBe(6);
  });

  test('throws on invalid input', () => {
    expect(() => sumArray('not an array')).toThrow();
  });
});

This structure helps maintain clarity as your test suite grows, making it easier to navigate and understand the scope of each test group.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *