Back

Testing with Vitest

Published: 13/05/25

Back to top

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:

Benifits of testing

Testing helps with:

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:

As shown in this testing pyramid Testing Pyramid

Getting Started

Local development setup

We need a tool to achieve the following:

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

Create a package.json file

npm init -y

Install vitest as a dev dependency

npm i vitest --save-dev

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;
}

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);
});

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"
  }
}

Now run the test script

npm test

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);
});

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

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 toBe() method takes one argument:

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);
});

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;
}

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

Failed Test

The output tells you the following:

index.test.js
   × should sum all numbers in an array

AssertionError: expected NaN to be 6

- Expected
+ Received

- 6
+ NaN

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

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);
});

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);
});

Avoid hardcoding values

Our test is currently hardcoding the expected value

expect(result).toBe(6);

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);
});

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);
});

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];

  // ...
});

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();
});

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

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;
}

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);
});

Take note of a couple of statements here:

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!"

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);
});

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();
  }
});

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();
});

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();
expect(results).not.toBe(0);

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/);
});

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

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);
}

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");
});