Published: 13/05/25
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:
Testing helps with:
There are a few different categories of tests which test different things, and you should aim to add each of these to your app:
Test the smallest building blocks ("units") of your app (e.g. a function, a class, or a component in React).
Test multiple units to ensure they work well together.
Test an entire flow/feature of your app (e.g. a user uploading an image).
Generally, you want:
As shown in this testing pyramid
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 withtest
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
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:
expect()
is a particular valueIn this case, the sum of [1, 2, 3]
should result in 6
, hence expect(result).toBe(6)
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..."
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
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 🎉
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);
});
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.
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.
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:
invalid
) is passed to the function"1"
) are passedWe can write tests for these scenarios!
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
Which confirms the last change didn't break the function.
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:
["1", "2"]
.reduce()
, we're converting the acc
variable (the accumulator in the reduce loop) and curr
variable (the current value in the reduce loop) to numbersImportant: 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!
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.
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.
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.
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.
There are a couple of approaches to check if an error is thrown in a test:
try...catch
statement.toThrow()
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
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
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);
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
It could be
error.message
to beIn 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.
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");
});