DEV Community

Sammy Abukmeil
Sammy Abukmeil

Posted on • Edited on

JavaScript Testing Basics

Table of Contents





What is testing

Testing is all about writing code ("tests") which automates the process of checking whether your application is working as intended.

You can run the tests:

  • After you make changes (to ensure your changes didn't break anything else)
  • During your CI/CD pipeline to ensure you're not pushing bugs to production

Benifits of testing

Testing helps with:

  • Confidence when refactoring (you'll be sure that your changes haven't broken anything)
  • Less bugs in production
  • Less manual testing
  • Forces you to write better code (code that's easy to test)

Types of tests

There are a few different categories of tests which test different things, and you should aim to add each of these to your app:

Unit tests

Test the smallest building blocks ("units") of your app (e.g. a function, a class, or a component in React).

Integration tests

Test multiple units to ensure they work well together.

End-to-End tests

Test an entire flow/feature of your app (e.g. a user uploading an image).

Generally, you want:

  • A lot of unit tests
  • Less integrations tests
  • A small amount of end-to-end tests

As shown in this testing pyramid
Testing Pyramid


Getting Started

Local development setup

We need a tool to achieve the following:

  • Test Runner - Runs the tests and shows the results
  • Assertion Library - The test themselves, what to check etc

A popular tool for both of these features is Jest, however, Jest can be slow when executing tests, and requires extra setup to use ECMAScript modules (i.e. import x from "y" syntax)

A modern alternative is Vitest (prounounced "vee-test") which works in the same way as Jest, is fast and supports ECMAScript modules out of the box.

Let's start by making a directory

mkdir testing
cd testing
Enter fullscreen mode Exit fullscreen mode

Create a package.json file

npm init -y
Enter fullscreen mode Exit fullscreen mode

Install vitest as a dev dependency

npm i vitest --save-dev
Enter fullscreen mode Exit fullscreen mode

Create a index.js file with the following contents

export function add(numbers) {
  let sum = 0;

  for (const number of numbers) {
    sum += number;
  }

  return sum;
}
Enter fullscreen mode Exit fullscreen mode

Note: The functions that we'll be testing in this article are intentionally simple, in order to keep the tests simple

This is the first function we'll test

Create a index.test.js file with the following code

import { expect, test } from "vitest";
import { add } from "./index";

test("should sum all numbers in an array", () => {
  const result = add([1, 2, 3]);

  expect(result).toBe(6);
});
Enter fullscreen mode Exit fullscreen mode

The test in the file name is important, Vitest looks for files with test in and executes the tests within them.

In your package.json file add a script to run vitest

{
  "scripts": {
    "test": "vitest"
  },
  "devDependencies": {
    "vitest": "^1.6.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now run the test script

npm test
Enter fullscreen mode Exit fullscreen mode

You should see the following output

Vitest output

Writing our first test

Now we have our test runner & assertion library setup, let's revisit our test

test("should sum all numbers in an array", () => {
  const result = add([1, 2, 3]);

  expect(result).toBe(6);
});
Enter fullscreen mode Exit fullscreen mode

The test() method sets up a new test and takes two arguments

  • A string describing what we're testing
  • A callback function to execute when the test executes

Inside the callback function, we simply execute the function we want to test and store the result in a variable: const result = add([1, 2, 3])

The expect() method takes one argument:

  • The value that the assertion library will check

The toBe() method takes one argument:

  • Checks if the value that was given to expect() is a particular value

In this case, the sum of [1, 2, 3] should result in 6, hence expect(result).toBe(6)

Using it() instead of test()

We can make the name of our test easier to read by using the it() method, which does the same thing as test()

import { expect, it } from "vitest";
import { add } from "./index";

it("should sum all numbers in an array", () => {
  const result = add([1, 2, 3]);

  expect(result).toBe(6);
});
Enter fullscreen mode Exit fullscreen mode

As you can see, our code reads better "It should..." rather than "Test should..."

Detecting bugs

If another developer on your team makes a change to the original function e.g. changing let sum = 0 to let sum

export function add(numbers) {
  let sum;

  for (const number of numbers) {
    sum += number;
  }

  return sum;
}
Enter fullscreen mode Exit fullscreen mode

When the tests run, an issue is detected and the test fails

Failed Test

The output tells you the following:

  • Which test(s) failed
  • What value the test was expecting
  • What value the test actaully recieved
index.test.js
   × should sum all numbers in an array

AssertionError: expected NaN to be 6

- Expected
+ Received

- 6
+ NaN
Enter fullscreen mode Exit fullscreen mode

This is happening since the variable sum is now undefined , and adding a number to undefined results in NaN

This will prompt the developer to review their change, and therefore the presence of this unit test has prevented a bug from being committed 🎉


Best Practices

How to structure tests

A common pattern for structuring a unit test is the Triple A Pattern

  • Arrange - Define the values that we're going to use
  • Act - Execute the function we want to test
  • Assert - Check if the expected outcome happened

Currently out test is only "Acting" and "Asserting"

it("should sum all numbers in an array", () => {
  // Act
  const result = add([1, 2, 3]);

  // Assert
  expect(result).toBe(6);
});
Enter fullscreen mode Exit fullscreen mode

Let's add the "Arrange" step to make our test clearer

it("should sum all numbers in an array", () => {
  // Arrange
  const numbers = [1, 2, 3];  

  // Act
  const result = add(numbers);

  // Assert
  expect(result).toBe(6);
});
Enter fullscreen mode Exit fullscreen mode

Avoid hardcoding values

Our test is currently hardcoding the expected value

expect(result).toBe(6);
Enter fullscreen mode Exit fullscreen mode

But what if a developer changes the numbers in the arrange step without updating the expected value

it("should sum all numbers in an array", () => {
  const numbers = [1, 2];  

  const result = add(numbers);

  expect(result).toBe(6);
});
Enter fullscreen mode Exit fullscreen mode

Now our test will fail, and we might start debugging the function rather than the test by mistake.

We can dynamically calulate the expected value to make our test more reliable:

it("should sum all numbers in an array", () => {
  const numbers = [1, 2];

  const result = add(numbers);

  const expectedResult = numbers.reduce((acc, curr) => acc + curr, 0);
  expect(result).toBe(expectedResult);
});
Enter fullscreen mode Exit fullscreen mode

Here we're using the reduce() array method to loop over the values in the numbers array and add them up.

This isn't necessary, however, it does arguably make your test clearer, in terms of how the final value is calculated.

Keep tests simple

When arranging your tests, keep the input values as simple as possible

it("should sum all numbers in an array", () => { 
  // Good
  const numbers = [1, 2];

  // Bad
  const numbers = [1, 2, 3, 4, 5, 6];

  // ...
});
Enter fullscreen mode Exit fullscreen mode

Regardless if we provide 2 or 6 input values, the test will establish whether "should sum all numbers in an array" is working or not.

To keep our test simple, we should go for the simplist set of inputs. This will help your colleagues understand the test quickly.

If, for example, you want to test if the functions works with a lot of input values (or negative numbers) you could write a sepatate test for that scenario.

Test multiple scenarios

Currently we're only testing one scenario, the "ideal scenario" so to speak (i.e. the function recieved an array of numbers).

What about the following scenarios:

  1. A value that can't be converted to a number (e.g. invalid) is passed to the function
  2. Strings which contain numbers (e.g. "1") are passed

We can write tests for these scenarios!

Test for failed conversions

Let's start off by writing a test for scenario 1 above:

it("should return NaN if a least one invalid number is passed", () => {
  const inputs = ["invalid", 1];

  const result = add(inputs);

  expect(result).toBeNaN();
});
Enter fullscreen mode Exit fullscreen mode

Here we're using the toBeNaN() provided by Vitest - there are lots of other methods available for other scenarios we might expect (see the right hand sidebar here)

If we run this test, it fails with the message

AssertionError: expected '0invalid1' to be NaN
Enter fullscreen mode Exit fullscreen mode

This is because JavaScript encounters a string in the numbers array and converts everything to a string and concatinates them.

We can update our function force each value in the array to be a number like so:

export function add(numbers) {
  let sum = 0;

  for (const number of numbers) {
    sum += Number(number);
  }

  return sum;
}
Enter fullscreen mode Exit fullscreen mode

Now our test passes since Number("invalid") results in NaN

In fact, both our tests are passing

Multiple Passing tests

Which confirms the last change didn't break the function.

Test for string to number conversions

Let's also add a test for the scenario 2 (when strings that can be converted to numbers are passed)

it("should return a correct sum even if an array of numeric string values is passed", () => {
  const numbers = ["1", "2"];

  const result = add(numbers);

  const expectedResult = numbers.reduce(
    (acc, curr) => Number(acc) + Number(curr),
    0
  );
  expect(result).toBe(expectedResult);
});
Enter fullscreen mode Exit fullscreen mode

Take note of a couple of statements here:

  • We're providing strings with numbers in ["1", "2"]
  • In the .reduce(), we're converting the acc variable (the accumulator in the reduce loop) and curr variable (the current value in the reduce loop) to numbers

Important: Make sure not to introduce bugs in your tests, you can console.log(expectedResult) and console.log(typeof expectedResult) in your test to ensure the dynamically calculated expected value is correct

Note: console logs will be shown by Vitest in the terminal output

When we run our tests, we see that the new test, as well as all previous tests, are passing!

Three tests

This is due to the refactor we made earlier in our add() function sum += Number(number).

Now, going forward, your and your colleagues can run these tests any time and be 100% certain that this function works in those scenarios, even after making changes to it.

Testing is an iterative process

At a certain point you'll ask yourself "Should I write any more tests for this unit?"

The answer is "it depends!"

  • Have you written tests for the main scenarios?
  • Do you tests cover good and bad scenarios?

As time goes on, you may continue working on the unit that you're testing, and therefore you should revisit the tests.


More Examples

You may not feel the need to add these tests, it's up to you, but these are some examples of scenarios you could test for it it's important to you and your team.

Test if an empty array is passed

Let's add a test for a scenario where a developer calls this function and passes an empty array, in which case, it shouldn't crash but instead simply return 0

it("should return 0 if an empty array is passed", () => {
  const numbers = [];

  const results = add(numbers);

  expect(results).toBe(0);
});
Enter fullscreen mode Exit fullscreen mode

When we run our tests, we find that our function already behaves that way - great!

At least now it's clear to everyone how the function behaves in this scenario.

You can consider your tests as a form of documentation for your code - a developer can look at the tests and see how it behaves without even trying it out.

Test if it throws an error when no argument is passed

There are a couple of approaches to check if an error is thrown in a test:

  1. Wrap it in a try...catch statement
  2. Wrap the function call in a function, and use .toThrow()
The try...catch approach
it("should throw an error if no value is passed", () => {
  try {
    add();
  } catch (error) {
    expect(error).toBeDefined();
  }
});
Enter fullscreen mode Exit fullscreen mode

Here we're using .toBeDefined() to check if the error object is defined

The .toThrow() approach

it("should throw an error if no value is passed", () => {
  const resultFn = () => {
    add();
  };

  expect(resultFn).toThrow();
});
Enter fullscreen mode Exit fullscreen mode

Here we're wrapping the function call add() in an anonymous function, then passing a reference to the function to expect() and chaining on .toThrow()

With this approach, the expect() method will execute the wrapping function resultFn() and check whether it throws

Test for falsy values with .not

For any of the example above, we can chain the .not property to check if a statement evaluates to a falsy value e.g.

expect(resultFn).not.toThrow();
Enter fullscreen mode Exit fullscreen mode
expect(results).not.toBe(0);
Enter fullscreen mode Exit fullscreen mode

Test if a specific error message is thrown

Let's write a test to see if, when passing individual numbers as arguments instead of an array, a specific error message is thrown

it("should throw an error if multiple arguments are passed instead of an array", () => {
  const num1 = 1;
  const num2 = 2;

  const resultFn = () => {
    add(num1, num2);
  };

  expect(resultFn).toThrow(/is not iterable/);
});
Enter fullscreen mode Exit fullscreen mode

Here we're passing a regular expression to .toThrow()

If you hover your mouse over the the method, you'll see what you can pass in

toThrow parameters

It could be

  • A string that we expect the error.message to be
  • A regular expression (i.e. a pattern to check for part of a message)
  • A class which the error might be an instance of

In our example above, we provided the following regular expression /is not iterable/ (i.e. the error.message must include those three words somewhere in the string)

Since we're using a for...of loop in our function which expects a value that can be looped over (i.e. an "iterable"), passing in a couple of numbers add(num1, num2) will throw that error message.

Test if the result is a specific type

Consider this function

export function convertToNumber(value) {
  return Number(value);
}
Enter fullscreen mode Exit fullscreen mode

Note: The functions that we'll be testing in this article are intentionally simple, in order to keep the tests simple

We can write a test which checks if the function returns a value of a specfic via .toBeTypeOf()

it("converts a string with a number inside to a number", () => {
  const input = "1";

  const result = convertToNumber(input);

  expect(result).toBeTypeOf("number");
});
Enter fullscreen mode Exit fullscreen mode

Top comments (0)