|
From Testing JavaScript Applications by Lucas da Costa In this article, you’ll learn how to use Node and Jest to test code written to run in a browser. |
Take 40% off Testing JavaScript Applications by entering fccdacosta into the discount code box at checkout at manning.com.
Baking in a professional kitchen is quite different from baking at home. At home, you won’t always have all the unique ingredients you would find on a chef’s shelves. You probably won’t have the same fancy appliances, or the same impeccable kitchen. Nevertheless, that doesn’t mean you can’t bake excellent desserts. You’ve just got to adapt.
Similarly, running JavaScript in a browser is significantly different from running JavaScript in Node. Depending on the occasion, the JavaScript code running in a browser can’t run in Node at all, and vice-versa. Therefore, for you to test your front-end application, you’ll have to jump through a few extra hoops, but it doesn’t mean you can’t do it. With a few adaptations, you can use Node to run JavaScript that has been written for the browser in the same way that Louis can bake mouth-watering cheesecakes at home without the fancy French cookware he’s got at the bakery.
Within a browser, JavaScript has access to different APIs and thus has different capabilities.
In browsers, JavaScript has access to a global variable called window
. Through the window
object, you can change a page’s content, trigger actions in a user’s browser, and react to events like clicks and keypresses.
Through window
, you can, for example, attach a listener to a button so that each time a user clicks it, your application updates the quantity of an item in the bakery’s inventory.
Try creating an application which does exactly that. Write an HTML file that contains a button, a count, and which loads a script called main.js
.
Listing 1. index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Inventory Manager</title> </head> <body> <h1>Cheesecakes: <span id="count">0</span></h1> <button id="increment-button">Add cheesecake</button> #1 <script src="main.js"></script> </body> </html>
#1 The script with which we’ll make the page interactive.
In main.js
, find the button by its ID and attach a listener to it. Whenever users click this button, the listener will be triggered, and the application will increment the cheesecake count.
Listing 2. main.js
let data = { count: 0 }; const incrementCount = () => { data.cheesecakes++; window.document.getElementById("count") .innerHTML = data.cheesecakes; }; const incrementButton = window.document.getElementById("increment-button"); incrementButton.addEventListener("click", incrementCount);
#1 The function which updates the application’s state
#2 Attaching an event listener which will cause incrementCount
to be called whenever the button is clicked
To see that page in action, execute npx http-server ./
in the same folder as your index.html
, and then access localhost:8080
.
Because this script runs in a browser, it has access to window
, and thus it can manipulate the browser and the elements in the page.
Figure 1. The JavaScript environment within a browser.
Differently from the browser, Node can’t run that script. Try executing it with node main.js
and Node will immediately tell you that it has found a ReferenceError
because “window is not defined.”
That error happens because Node doesn’t have a window
. Instead, because it was designed to run different kinds of applications, it gives you access to APIs like process
, which contains information about the current Node.js process, and require
, which allows you to import different JavaScript files.
For now, if you were to write tests for the incrementCount
function, you’d have to run them in the browser. Because your script depends on DOM APIs, you wouldn’t be able to run these tests in Node. If you tried to do it, you’d run into the same ReferenceError
you’ve seen when you executed node main.js
. Given that Jest depends on Node-specific APIs and therefore only runs in Node, you can’t use Jest also.
To be able to run your tests in Jest, instead of running your tests within the browser, you can bring browser APIs to Node by using JSDOM. You can think of JSDOM as an implementation of the browser environment which can run within Node. It implements web standards using pure JavaScript. With JSDOM you can emulate, manipulating the DOM and attaching event listeners to elements, for example.
JSDOM JSDOM is an implementation of web standards written in purely in JavaScript which you can use in Node.
To understand how JSDOM works, let’s use it to create an object that represents index.html
and which we can use in Node.
First, create a package.json
file with npm init -y
and then install JSDOM with npm install jsdom
.
By using fs
you will read the index.html
file and pass its contents to JSDOM, so that it can create a representation of that page.
Listing 3. page.js
const fs = require("fs"); const { JSDOM } = require("jsdom"); const html = fs.readFileSync("./index.html"); const page = new JSDOM(html); module.exports = page;
The page
representation contains properties that you’d find in a browser, like, for example, window
. Because you’re now dealing with pure JavaScript, you can use page
in Node.
Try importing page
in a script and interacting with it as you’d do in a browser. You can try, for example, attaching a new paragraph to the page
.
Listing 4. example.js
const page = require("./page"); #1 console.log("Initial page body:"); console.log(page.window.document.body.innerHTML); const paragraph = page.window.document.createElement("p"); #2 paragraph.innerHTML = "Look, I'm a new paragraph"; #3 page.window.document.body.appendChild(paragraph); #4 console.log("Final page body:"); console.log(page.window.document.body.innerHTML);
#1 Importing the JSDOM representation of the page.
#2 Creating a paragraph element
#3 Updating the paragraph’s content
#4 Attaching the paragraph to the page
To execute the above script in Node, run node example.js
.
With JSDOM, you can do almost everything you can do in a browser, including updating DOM elements, like count
.
Listing 5. example.js
const page = require("./page"); // ... console.log("Initial contents of the count element:"); console.log(page.window.document.getElementById("count").innerHTML); page.window.document.getElementById("count").innerHTML = 1337; console.log("Updated contents of the count element:"); #1 console.log(page.window.document.getElementById("count").innerHTML); // ...
#1 Updating the contents of the count element
Thanks to JSDOM, you can run your tests in Jest, which, as I have mentioned, can only run in Node.
By using passing the value "jsdom"
to Jest’s testEnvironment
option, you can make it set up a global instance of JSDOM which you can use when running your tests.
Figure 2. The JavaScript environment within Node.
To set up a JSDOM environment within Jest, start by creating a new Jest configuration file called jest.config.js
. In this file, export an object whose testEnvironment
property’s value is "jsdom"
.
Listing 6. jest.config.js
module.exports = { testEnvironment: "jsdom", };
NOTE: At the time of writing, Jest’s current version is 24.9. In this version, jsdom
is the default value for Jest’s testEnvironment
, so you don’t necessarily need to specify it.
If you don’t want to create a jest.config.js
file manually, you can use ./node_modules/.bin/jest --init
to automate this process. Jest’s automatic initialization will then prompt you to choose a test environment and present you a jsdom
option.
Now try to create a main.test.js
file and import main.js
to see what happens.
Listing 7. main.test.js
require("./main");
If you try to run this test with Jest, you will still get an error.
FAIL ./main.test.js ● Test suite failed to run TypeError: Cannot read property 'addEventListener' of null 10 | 11 | const incrementButton = window.document.getElementById("increment-button"); > 12 | incrementButton.addEventListener("click", incrementCount);
Even though window
now exists thanks to Jest setting up JSDOM
, its DOM is not built from index.html
. Instead, it’s built from an empty HTML document, and thus there is no increment-button
. Because the button does not exist, you can’t call its addEventListener
method.
To use index.html
as the page that the JSDOM instance will use, you need to read index.html
and assign its content to window.document.body.innerHTML
before importing main.js
.
Listing 8. main.test.js
const fs = require("fs"); window.document.body.innerHTML = fs.readFileSync("./index.html"); require("./main");
#1 Assigning the contents of the index.html
file to the page’s body
Because you have now configured the global window
to use the contents of index.html
, Jest will be able to execute main.test.js
successfully.
The last step you need to take to be able to write a test for incrementCount
is to expose it. Because main.js
does not expose incrementCount
nor data
, you can’t exercise the function, nor check its result. Solve this problem by using module.exports
to export data
and the incrementCount
function.
Listing 9. main.js
// ... module.exports = { incrementCount, data };
Finally, you can go ahead and create a main.test.js
file which sets an initial count, exercises incrementCount
and checks the new count
within data
. We’re using the 3A pattern here — arrange, act, assert.
Listing 10. main.test.js
const fs = require("fs"); window.document.body.innerHTML = fs.readFileSync("./index.html"); const { incrementCount, data } = require("./main"); describe("incrementCount", () => { test("incrementing the count", () => { data.cheesecakes = 0; #1 incrementCount(); #2 expect(data.cheesecakes).toBe(1); #3 }); });
#1 Arrange: set the initial quantity of cheesecakes.
#2 Act: exercise the incrementCount
function, which is the unit under test.
#3 Assert: check whether data.cheesecakes
contains the correct amount of cheesecakes.
Once you’ve celebrated seeing this test pass, it’s time to solve one last problem.
Because you’ve used module.exports to expose incrementCount
and data, main.js
will now throw an error when running in the browser. To see the error, try serving your application again with npx http-server ./
, and accessing localhost:8080
with your browser’s devtools open.
Uncaught ReferenceError: module is not defined at main.js:14
Your browser throws this error because it doesn’t have module
globally available. Again, you have run into a problem related to the differences between browsers and Node.
A common strategy to run in browsers files which use Node’s module system is to use a tool that bundles dependencies into a single file that the browser can execute. One of the main goals of tools like webpack
and browserify
is to do this kind of bundling.
Install browserify
as a dev-dependency
and run ./node_modules/.bin/browserify main.js -o bundle.js
to transform your main.js
file into a browser-friendly bundle.js
.
NOTE: You can find Browserify’s complete documentation at browserify.org.
Once you have run browserify
, update index.html
to use bundle.js
instead of main.js
.
Listing 11. index.html
<!DOCTYPE html> <html lang="en"> <!-- ... --> <body> <!-- ... --> <script src="bundle.js"></script> </body> </html>
#1 The bundle.js will be generated from main.js. It’s a single file which contains all of main.js direct and indirect dependencies.
TIP: You will need to rebuild bundle.js
whenever there’s a change to main.js
.
Because you have to run it frequently, it would be wise to create an NPM script which runs browserify
with the correct arguments.
To create an NPM script which runs browserify
, update your package.json
so that it includes the lines below.
Listing 12. package.json
{ // ... "scripts": { // ... "build": "browserify main.js -o bundle.js" }, // ... }
#1 Goes through the main.js file’s dependency tree and bundles all of the dependencies into a single bundle.js file.
By using tools like browserify
or webpack
, you can transform the testable code you’ve written to run in Node so that it can run in a browser.
Using bundlers enables you to test your modules separately, and makes it easier to manage them within browsers. When you bundle your application into a single file, you don’t need to manage multiple script
tags in your HTML page.
In this article, you’ve learned how to use Node and Jest to test JavaScript designed to run in a browser. You’ve seen the differences between these two platforms and learned how to bring browser APIs to Node with JSDOM.
You’ve also seen how browserify
can help you test your application by enabling you to divide it into separate modules, which you can test in Node and then bundle to run in a browser.
By using these tools, you are able to test your browser application in Node, using Jest.
That’s all for now.
If you want to learn more about the book, you can check it out on our browser-based liveBook platform here.