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.