Testing JavaScript with Jest - Unit Testing
This article will cover the very basics of how testing works, what it's used for, and how to implement it in our Node.js apps using jest.
11 August 2021 ยท 10 minute read
Introduction
Over the last couple of weeks I've been immersing myself in the world of testing my JavaScript and Python projects, and OH BOY. It's so much fun that I can't believe I didn't start learning it sooner.
I've come to realize that testing our code is essential for writing maintainable, reusable, and modular code. And it also makes it easy for any contributors, colleagues, and in general any people we work with to be almost absolutely sure their new coolAndGreatFunction420()
doesn't break our entire project.
This article will cover the very basics of how testing works, what it's used for, and how to implement it in our Node.js apps using jest.
What is testing?
Testing code is the process of making sure our software behaves the way we intend it to. Testing our code can help us feel more comfortable with our final product.
For example, if we have a program which purpose is to add 2 + 2 and return 4, we'd like to make sure it does exactly that. We don't want it to return 5, or 1, or "cuatro", we want it to return 4. Tests enable us to make sure this program behaves as expected every time we run it.
Testing software comes in different shapes and sizes. For example, we could test the program mentioned above by simply using it the way a user would. We could launch a terminal, or a browser, or any kind of GUI, and run the program several times, making sure it always returns the expected value. The fun kind of testing, however, is automated testing.
Automated testing is code that tests code. Awesome, right? This can be achieved by using frameworks that enable us to write testing code.
Even though automated testing is the focus of this article, I think it's still important to manually test our programs. This way we make sure our end-users have the best experience possible with our products.
It's important to note that testing -no matter how in-depth or complex our tests are- can't ensure bug-free code. However, I do believe that testing improves code quality and makes better products in the end.
Types of tests
Before we get into practical examples, we should know the common types of testing. These are not the only types that exist, but the most popular ones in the world of JavaScript.
Unit tests
Unit testing covers blocks of code, making sure they work the way the are intended to work. A unit could be a function, a class, or an entire module. Personally, I recommend unit tests to be limited to functions, just because I try to test the smallest parts of my code first, but there is no real rule for this. We can have two types of units:
- Isolated or solitary units: units that have no other dependencies, and which behavior and/or output depend only of the block contained within it.
- Sociable units: these are units that have dependencies. Their execution and optional output depends on other units. When testing, this means that we gotta make sure their dependencies work as expected before testing them.
// This is an isolated unit
function myNameIs(nameString) {
return `Will the real ${nameString} please stand up`;
};
// This is a sociable unit, because it depends on other units
function pleaseStandUp() {
return myNameIs("Slim Shady") + "please stand up, please stand up";
};
Integration tests
Just because our unit tests pass doesn't mean we have a functioning and complete application. Once we have made sure that our units are properly tested and work by themselves, we test them together in the same way they are used in our software. This is integration testing. Putting these units and testing them together ensures that our functions, classes, and modules play well with each other.
End to end tests (E2E)
End to end testing (E2E) takes our application for a ride from beginning to end. By this, I mean that this type of testing focuses on the user's experience when using our software.
Remember how I said that manual testing is important, even when we have automated tests set up? Well, E2E testing is basically *automated manual testing* (try to explain that to a non-developer). These tests take place in the browser typically in a headless browser, although they can be run in browsers with a GUI. Through our test, we try to replicate as much as possible a user's interactions with our site, and make sure the output is what we expect.
In addition to replicating a user's navigational flow through the website, I actually also like to try to break things in these types of tests, as if I were a user typing and clicking madly through the site.
Unit testing with Jest
Jest is a Facebook Open Source product that enables us to write and run tests in pretty much any kind of JavaScript framework we prefer.
๐ NOTE
I know, I also don't love the fact that Jest -and React, for that matter- are Facebook products. However, I gotta admit that they are good products and Facebook is a topic for another post.
To install and use Jest in our project, we can run:
$ npm i -D jest
Then we can add a testing script to our package.json
:
"scripts": {
"test": "jest"
}
Whenever Jest is run, it'll automatically look for and run files that end in .test.js
, .spec.js
or any .js
files that are inside of the __tests__
directory.
Now, let's go ahead and write the unit that we want to test. And don't worry, these may look simple, but they are actual functions that I've had to use in real-life projects.
// helpers.js
function isNumber(possibleNumber) {
return typeof possibleNumber === "number";
};
module.exports = isNumber;
There we go, a very simple function that shouldn't be hard to test... right? Let's try writing our first test. For this example, let's assume the test file is in the same directory as the helpers.js
module.
// helpers.test.js
const isNumber = require("./helpers");
test("should return true if type of object is a number", () => {
expect(isNumber(5)).toBe(true);
});
That is what a basic jest file looks like. We import the module/class/function we want to test, we specify some description for what we expect the test result to be, and then we actually tell Jest what we think the function result will be. Let's break it down a bit.
test()
is a Jest function that defines a single test to be run. You can have as manytest
statements in a single file as you like. It takes two required arguments and an optional third one. The first argument is the test name. It's customary to use it as a clear description of what is being tested. The second argument is a function where the body of our test lives. This is where we tell Jest what our expectations from the test are. In this case, we expect the return value fromisNumber(5)
to betrue
. The third argument is an optionaltimeout
value in milliseconds. Since tests are usually really fast, we don't expect any singular test to take longer than 5 seconds, which is the defaulttimeout
value.expect()
is the function we use to actually test our expectations. We useexpect
along with "matcher" functions which asserts certain conditions about a value. In this test we're using thetoBe()
matcher, which compares actual values with our expectations. There's a lot of matchers, and I'll only cover a few in these articles, but you can read more about them in Jest's matchers documentation.
๐ NOTE
You can have as many `expect()` statements in a test as you like, there's no limit! It's recommended you use only as many as it makes sense, since you can also write as many `test()` statements as you like, and it becomes more readable to organize your `expect()` statements with their respective test cases.
Now that we have written our first test, we can run npm run test
and see the magic happen:
$ npm run test
> testing-javascript-with-jest@1.0.0 test
> jest
PASS ./helpers.test.js
โ should return true if type of object is a number (2 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.279 s, estimated 1 s
Ran all test suites.
๐ NOTE
"Test Suites" refers to the number of files jest ran, while "Tests" refers to all the `test()` statements found within those files.
Like I said before, Jest automatically looks for and runs all test files in our source code, and it does this really fast. Congratulations on writing your first test!
Let's write a couple more tests for this function, just so we make sure we cover as many use cases as we can.
// helpers.test.js
const isNumber = require("./helpers");
test("should return true if type of object is a number", () => {
expect(isNumber(0)).toBe(true);
expect(isNumber(5)).toBe(true);
expect(isNumber(+"5")).toBe(true);
});
test("should return false if type of object is not a number", () => {
expect(isNumber(null)).toBe(false);
expect(isNumber("number")).toBe(false);
expect(isNumber(undefined)).toBe(false);
});
We run npm run test
again and...
$ npm run test
...
PASS ./helpers.test.js
โ should return true if type of object is a number (2 ms)
โ should return false if type of object is not a number
...
Great! Our function seems to be working as intended.
Grouping tests under describe()
We could get away with just writing our tests at the top level like the one we just did. However, we can see that despite seeing our test descriptions and their results, we can't tell by the terminal output what unit we're testing. Let's illustrate this better by writing a second function in helpers.js
and adding its respective tests to helpers.test.js
.
// helpers.js
...
function isObject(possibleObject) {
return typeof possibleObject === "object";
};
module.exports = { isNumber, isObject };
// helpers.test.js
const { isNumber, isObject } = require("./helpers");
...
test('should return true if type of object is "object"', () => {
expect(isObject({})).toBe(true);
expect(isObject([])).toBe(true);
});
test('should return false if type of object is not "object"', () => {
expect(isObject(5)).toBe(false);
expect(isObject("object")).toBe(false);
});
We run npm run test
again and we get the expected (ha, get it?) result:
$ npm run test
> testing-javascript-with-jest@1.0.0 test
> jest
PASS ./helpers.test.js
โ should return true if type of object is a number (1 ms)
โ should return false if type of object is not a number (1 ms)
โ should return true if type of object is "object" (1 ms)
โ should return false if type of object is not "object" (1 ms)
Test Suites: 1 passed, 1 total
Tests: 4 passed, 4 total
Snapshots: 0 total
Time: 0.204 s, estimated 1 s
Ran all test suites.
Like I said before, while these results are great and we got all the green check-marks, they're not the most readable, and we don't know which test belongs to what unit. There's a better way to organize our tests so that the output to the terminal is cleaner and easier to read.
By using describe()
, we can group our tests together under a single block, and therefore, under the same scope -which will become useful later-. To implement the describe()
function on our existing tests, all we gotta do is wrap describe()
statements around a related group of test()
statements.
// helpers.test.js
...
describe("isNumber", () => {
test("should return true if type of object is a number", () => {
expect(isNumber(0)).toBe(true);
expect(isNumber(5)).toBe(true);
expect(isNumber(+"5")).toBe(true);
});
test("should return false if type of object is not a number", () => {
expect(isNumber(null)).toBe(false);
expect(isNumber("number")).toBe(false);
expect(isNumber(undefined)).toBe(false);
});
});
describe("isObject", () => {
test('should return true if type of object is "object"', () => {
expect(isObject({})).toBe(true);
expect(isObject([])).toBe(true);
});
test('should return false if type of object is not "object"', () => {
expect(isObject(5)).toBe(false);
expect(isObject("object")).toBe(false);
});
});
This time, when we run npm run test
we'll see groups of tests organized under the same name.
$ npm run test
...
PASS ./helpers.test.js
isNumber
โ should return true if type of object is a number (2 ms)
โ should return false if type of object is not a number (1 ms)
isObject
โ should return true if type of object is "object" (1 ms)
โ should return false if type of object is not "object" (1 ms)
Both the terminal output and the written code become much more readable when grouping tests together, and for reasons that will become important in future articles, it also groups related tests under the same scope.
Running multiple test cases using Jest Each
As of Jest version 23, we've been able to use the each
method on both the test
and describe
functions. each
allows us to run the same test multiple times using values defined in a "table column". The table can be both array types and template literals using Spock Data Tables.
We can simplify our tests with multiple expect
statements that contain different values like so:
//helpers.test.js
...
describe("isNumber", () => {
// Instead of this:
// test("should return true if type of object is a number", () => {
// expect(isNumber(0)).toBe(true);
// expect(isNumber(5)).toBe(true);
// expect(isNumber(+"5")).toBe(true);
// });
// We use this:
const numbers = [0, 5, +"5"];
test.each(numbers)("should return true since type of %j is a number",
numberToTest => {
expect(isNumber(numberToTest)).toBe(true);
});
It's a weird syntax, I know, but it makes it so much easier to test a large number of tests with fewer lines. In this case, we can just keep adding values to the numbers
array and keep checking to see if they all return true
without adding extra expect()
statements.
๐ NOTE
%j is a printf formatting specifier. When we run jest, each test will have a unique name with its specific value injected in the string by the parameters passed into it by thetable
argument.
Let's do this for all our tests:
// helpers.test.js
...
describe("isNumber", () => {
const numbers = [0, 5, +"5"];
const notNumbers = [null, "number", undefined];
test.each(numbers)('should return true since type of %j is "number"',
possibleNumber => {
expect(isNumber(possibleNumber)).toBe(true);
});
test.each(notNumbers)('should return false since type of %j is not "number"',
possibleNumber => {
expect(isNumber(possibleNumber)).toBe(false);
});
});
describe("isObject", () => {
const objects = [{}, []];
const notObjects = [5, "object"];
test.each(objects)('should return true since type of %j is "object"',
possibleObject => {
expect(isObject(possibleObject)).toBe(true);
expect(isObject(possibleObject)).toBe(true);
});
test.each(notObjects)('should return false since type of %j is not "object"',
possibleObject => {
expect(isObject(possibleObject)).toBe(false);
expect(isObject(possibleObject)).toBe(false);
});
});
Now not only do we save unnecessary lines of code, but our tests all have unique names when printed to the terminal:
$ npm run test
...
PASS ./helpers.test.js
isNumber
โ should return true since type of 0 is "number" (1 ms)
โ should return true since type of 5 is "number"
โ should return true since type of 5 is "number"
โ should return false since type of null is not "number" (1 ms)
โ should return false since type of "number" is not "number"
โ should return false since type of undefined is not "number"
isObject
โ should return true since type of {} is "object"
โ should return true since type of [] is "object"
โ should return false since type of 5 is not "object"
โ should return false since type of "object" is not "object"
...
Summary
This is an introductory article, and as such, we learnt the very basics of what testing is, the most common types of testing in JavaScript, and how to do test our units using the testing framework Jest. We know now that to test our code we use the test()
and expect()
functions together. We also know that we can group tests that share similar logic under the same scope by using the describe()
function, and we can reuse the same test under different test cases with the each
method.
Thank you for reading, and see you next time!