6 Testing frontend applications

This chapter covers

  • Replicating a browser’s JavaScript environment in your tests
  • Asserting on DOM elements
  • Handling and testing events
  • Writing tests involving browser APIs
  • Handling HTTP requests and WebSocket connections

Trying to write a client-side application without using JavaScript is as difficult as baking a dessert without carbohydrates. JavaScript was born to conquer the web, and in browsers, it has shone.

In this chapter, we’ll cover the fundamentals of testing frontend applications. Together, we’ll build a small web client for the backend we wrote in chapter 4 and learn how to test it.

During the process of building and testing this application, I’ll explain the peculiarities of running JavaScript within a browser, and, because Jest can’t run in a browser, I’ll teach you how to simulate that environment within Node.js. Without being able to emulate a browser’s environment in Jest, you wouldn’t be able to use it to test your frontend applications.

When testing frontend applications, assertions can become more challenging, because now you’re dealing not only with a function’s return value but also with its interaction with the DOM. Therefore, you will learn how to find elements within your tests and perform assertions on their contents.

The way users interact with frontend applications is also significantly different from how they interact with backend applications. Whereas servers receive input through, for example, HTTP, CoAP, or MQTT, web clients have to deal with users scrolling, clicking, typing, and dragging, which are harder to simulate accurately.

To learn how you can handle those elaborate interactions, I’ll explain how events work and how you can trigger them as a browser would. Learning how to simulate user behavior appropriately is critical to making your tests closely resemble what happens at run time. This resemblance will enable you to extract from your tests as much value as you can, increasing the number of bugs you can catch before reaching production.

Let’s say, for example, that you have an input field whose content is validated every time a user enters a character. If in your tests you change the input’s content all at once, you will not trigger the multiple validations that would have happened at run time. Because your tests would simulate a situation different from what happens in production, your tests would be unreliable. For example, you wouldn’t catch bugs that happen only as users type.

Besides being able to handle complex interactions, browsers also provide many exciting APIs that you can use to store data or manipulate the navigation history, for example. Even though you don’t need to test these APIs themselves, it’s crucial to validate whether your code interfaces adequately with them. Otherwise, your application may not work as it should.

By using the History and the Web Storage API, you’ll understand how to approach testing features that involve browser APIs. You’ll learn what you should test, what you should not test, and, most importantly, how to test it.

Finally, at the end of this chapter, you’ll see how to handle interactions with third parties through HTTP requests or WebSocket connections, two of the most common ways of gathering data on the web. Just like you’ve done when testing backend applications, you’ll learn how to handle these interactions reliably and without creating maintenance overhead.

The main goal of this chapter is to teach you the fundamentals required to test any frontend applications. Because you will learn the role of each tool, and how they work behind the scenes, these concepts will be useful no matter whether you’re testing an application written in Vue.js or React.

A solid understanding of how to test “raw” frontend applications makes it easier to test any other libraries or frameworks you may use in the future.

You’ll learn these concepts by building a frontend application for Louis’s staff to manage their stock. At first, you’ll only allow users to add cheesecakes to the inventory and learn how to run your application’s code within Node.js so that you can test it using Jest.

As you progress through these sections, you’ll add new functionality and learn how to test it and which tools to use. You will, for example, allow Louis’s staff to add any quantity of any desserts they want and validate their inputs to prevent them from making mistakes. Then, if they do make a mistake, you’ll enable them to revert it with an undo button, which interacts with the browser’s History API.

Finally, in this chapter’s final section, you’ll make the application update itself without requiring users to refresh the page. As operators add items, any members of the staff will be able to see, in real time, which ingredients the chefs are consuming and which desserts customers are buying.

By testing these features, which cover different aspects involved in writing a frontend application, you’ll be able to test any functionality that Louis might ask you to implement in the future.

Hopefully, the staff will be as delighted by how well your software works as customers are delighted by how good the bakery’s cheesecakes taste.

6.1 Introducing JSDOM

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 in 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 just have to adapt.

Similarly, running JavaScript in a browser is significantly different from running JavaScript in Node.js. Depending on the occasion, the JavaScript code running in a browser can’t run in Node.js at all and vice versa. Therefore, for you to test your frontend application, you’ll have to go through a few extra hoops, but it doesn’t mean you can’t do it. With a few adaptations, you can use Node.js to run JavaScript that’s been written for the browser in the same way that Louis can bake mouth-watering cheesecakes at home without the fancy French cookware he has at the bakery.

In this section, you’ll learn how to use Node.js and Jest to test code written to run in a browser.

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 that does exactly that. Write an HTML file that contains a button and a count and that loads a script called main.js, as shown next.

Listing 6.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>
    <script src="main.js"></script>                         
</body>
</html>

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 6.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);                 

The function that updates the application’s state

Attaching an event listener that will cause incrementCount to be called whenever the button is clicked

To see this 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, as shown in figure 6.1.

Unlike the browser, Node.js can’t run that script. If you try executing it with node main.js, Node.js will immediately tell you that it has found a ReferenceError because "window is not defined".

Figure 6.1 The JavaScript environment within a browser

That error happens because Node.js doesn’t have a window. Instead, because it was designed to run different kinds of applications, it gives you access to APIs such as 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.js. If you tried to do it, you’d run into the same ReferenceError you saw when you executed node main.js. Given that Jest depends on Node.js-specific APIs and therefore run only in Node.js, you also can’t use Jest.

To be able to run your tests in Jest, instead of running your tests within the browser, you can bring browser APIs to Node.js by using JSDOM. You can think of JSDOM as an implementation of the browser environment that can run within Node.js. It implements web standards using pure JavaScript. For example, with JSDOM, you can emulate manipulating the DOM and attaching event listeners to elements.

JSDOM JSDOM is an implementation of web standards written purely in JavaScript that you can use in Node.js.

To understand how JSDOM works, let’s use it to create an object that represents index.html and that we can use in Node.js.

First, create a package.json file with npm init -y, and then install JSDOM with npm install --save 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 6.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, such as window. Because you’re now dealing with pure JavaScript, you can use page in Node.js.

Try importing page in a script and interacting with it as you’d do in a browser. For example, you can try attaching a new paragraph to the page, as shown here.

Listing 6.4 example.js

const page = require("./page");                                 
 
console.log("Initial page body:");
console.log(page.window.document.body.innerHTML);
 
const paragraph = page.window.document.createElement("p");      
paragraph.innerHTML = "Look, I'm a new paragraph";              
page.window.document.body.appendChild(paragraph);               
 
console.log("Final page body:");
console.log(page.window.document.body.innerHTML);

Imports the JSDOM representation of the page

Creates a paragraph element

Updates the paragraph’s content

Attaches the paragraph to the page

To execute the previous script in Node.js, run node example.js.

With JSDOM, you can do almost everything you can do in a browser, including updating DOM elements, like count.

Listing 6.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:");
console.log(page.window.document.getElementById("count").innerHTML);
 
// ...

Updates the contents of the count element

Thanks to JSDOM, you can run your tests in Jest, which, as I have mentioned, can run only in Node.js.

By 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.

To set up a JSDOM environment within Jest, as shown in figure 6.2, 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".

Figure 6.2 The JavaScript environment within Node.js

Listing 6.6 jest.config.js

module.exports = {
  testEnvironment: "jsdom",
};

NOTE At the time of this writing, Jest’s current version is 26.6. In this version, jsdom is the default value for the 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 with a jsdom option.

Now try to create a main.test.js file and import main.js to see what happens.

Listing 6.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, no increment-button exists. 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, as shown next.

Listing 6.8 main.test.js

const fs = require("fs");
window.document.body.innerHTML = fs.readFileSync("./index.html");       
 
require("./main");

Assigns 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 or data, you can’t exercise the function or check its result. Solve this problem by using module.exports to export data and the incrementCount function as follows.

Listing 6.9 main.js

// ...
 
module.exports = { incrementCount, data };

Finally, you can go ahead and create a main.test.js file that sets an initial count, exercises incrementCount, and checks the new count within data. Again, it’s the three As pattern—arrange, act, assert—just like we’ve done before.

Listing 6.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;                          
    incrementCount();                              
    expect(data.cheesecakes).toBe(1);              
  });
});

Arrange: sets the initial quantity of cheesecakes

Act: exercises the incrementCount function, which is the unit under test

Assert: checks whether data.cheesecakes contains the correct amount of cheesecakes

NOTE For now, we won’t worry about checking the page’s contents. In the next sections, you’ll learn how to assert on the DOM and deal with events triggered by user interactions.

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 dev tools 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.js.

A common strategy to run in browsers’ files that use Node.js’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 6.11 index.html

<!DOCTYPE html>
<html lang="en">
  <!-- ... -->
  <body>
    <!-- ... -->
    <script src="bundle.js"></script>         
  </body>
</html>

The bundle.js will be generated from main.js. It’s a single file that contains all of main.js’s 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 that runs Browserify with the correct arguments.

To create an NPM script that runs Browserify, update your package.json so that it includes the next lines.

Listing 6.12 package.json

{
  // ...
  "scripts": {
    // ...
    "build": "browserify main.js -o bundle.js"         
  },
  // ...
}

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.js 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 section, you’ve learned how to use Node.js 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.js 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.js and then bundle to run in a browser.

By using these tools, you were able to test your browser application in Node.js, using Jest.

6.2 Asserting on the DOM

The best chefs know that a dessert should not only taste right; it must also look good.

In the previous section, you learned how to set up Jest so that you can test your scripts, but you haven’t yet checked whether the page displays the correct output to your users. In this section, you will understand how your scripts interact with a page’s markup and learn how to assert on the DOM.

Before we get to writing tests, refactor the previous section’s application so that it can manage multiple inventory items, not just cheesecakes.

Because you’re using Browserify to bundle your application, you can create a separate inventoryController.js file that will manage the items in the inventory, which you’ll store in memory.

NOTE For now, we’ll store all the data in memory and focus on testing our web client. In this chapter’s final section, you will learn how to connect your frontend application to the server from chapter 4 and test its backend integration.

Listing 6.13 inventoryController.js

const data = { inventory: {} };
 
const addItem = (itemName, quantity) => {
  const currentQuantity = data.inventory[itemName] || 0;
  data.inventory[itemName] = currentQuantity + quantity;
};
 
module.exports = { data, addItem };

As we’ve done in the previous section, you can add a test for this function by importing inventoryController.js, assigning an empty object to the inventory property, exercising the addItem function, and checking the inventory’s contents—the usual three As pattern.

Listing 6.14 inventoryController.test.js

const { addItem, data } = require("./inventoryController");
 
describe("addItem", () => {
  test("adding new items to the inventory", () => {
    data.inventory = {};                                    
    addItem("cheesecake", 5);                               
    expect(data.inventory.cheesecake).toBe(5);              
  });
});

Arrange: assigns an empty object to the inventory, representing its initial state

Act: exercises the addItem function, adding five cheesecakes to the inventory

Assert: checks whether the inventory contains the correct amount of cheesecakes

Running Jest should indicate that your test is passing, but even though addItem works, it doesn’t update the page with the inventory’s contents. To update the page with a list of items in stock, update your index.html file so that it includes an unordered list to which we’ll append items, as shown next.

Listing 6.15 index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Inventory Manager</title>
  </head>
  <body>
    <h1>Inventory Contents</h1>
    <ul id="item-list"></ul>
    <script src="bundle.js"></script>
  </body>
</html>

After creating this unordered list, create a file called domController.js, and write an updateItemList function. This function should receive the inventory and update item-list accordingly.

Listing 6.16 domController.js

const updateItemList = inventory => {
  const inventoryList = window.document.getElementById("item-list");
 
  inventoryList.innerHTML = "";                                         
 
  Object.entries(inventory).forEach(([itemName, quantity]) => {         
    const listItem = window.document.createElement("li");
    listItem.innerHTML = `${itemName} - Quantity: ${quantity}`;
    inventoryList.appendChild(listItem);
  });
};
 
module.exports = { updateItemList };

Clears the list

For each item in the inventory, creates a li element, sets its contents to include the item’s name and quantity, and appends it to the list of items

Finally, you can put all of this together into your main.js file. Go ahead and try adding a few items to the inventory by using addItem and calling updateItemList, passing it the new inventory.

Listing 6.17 main.js

const { addItem, data } = require("./inventoryController");
const { updateItemList } = require("./domController");
 
addItem("cheesecake", 3);
addItem("apple pie", 8);
addItem("carrot cake", 7);
 
updateItemList(data.inventory);

NOTE Because you should’ve completely rewritten main.js, its tests at main.test.js do not apply anymore and, therefore, can be deleted.

Don’t forget that, because we’re using Node.js’s module system to enable testing, we must run main.js through Browserify so that it can generate a bundle.js file capable of running in the browser. Instead of relying on APIs like require and module, bundle .js includes the code for both inventoryController.js and domController.js.

Once you have built bundle.js with ./node_modules/.bin/browserify main.js -o bundle.js, you can serve your application with npx http-server ./ and access localhost:8080 to see a list of inventory items.

So far, you have tested only whether addItem adequately updates the application’s state, but you haven’t checked updateItemList at all. Even though the unit test for addItem passes, there’s no guarantee that updateItemList can update the page when you give it the current inventory.

Because updateItemList depends on the page’s markup, you must set the innerHTML of the document used by Jest’s JSDOM, just like we did in the previous section.

Listing 6.18 domController.test.js

const fs = require("fs");
document.body.innerHTML = fs.readFileSync("./index.html");

TIP Besides window, document is also global within your tests. You can save yourself a few keystrokes by accessing document instead of window.document.

After setting up the JSDOM instance with the contents of your index.html page, test updateItemList using the three As pattern again: set up a scenario by creating an inventory with a few items, pass it to updateItemList, and check whether it updates the DOM appropriately.

Given that, thanks to Jest and JSDOM, the global document works just like it would in a browser, and you can use browser APIs to find DOM nodes and assert on them.

Try, for example, using querySelector to find an unordered list that is an immediate child of body and assert on the number of childNodes it contains.

Listing 6.19 domController.test.js

// ...
 
document.body.innerHTML = fs.readFileSync("./index.html");         
 
const { updateItemList } = require("./domController");
 
describe("updateItemList", () => {
  test("updates the DOM with the inventory items", () => {
    const inventory = {                                            
      cheesecake: 5,
      "apple pie": 2,
      "carrot cake": 6
    };
    updateItemList(inventory);                                     
 
    const itemList = document.querySelector("body > ul");          
    expect(itemList.childNodes).toHaveLength(3);                   
  });
});

Because you’re assigning the index.html file’s content to the body’s innerHTML, the page will be in its initial state when the test runs.

Creates an inventory representation containing a few different items

Act: exercises the updateItemList function

Finds the list by its placement in the DOM

Assert: checks whether the list contains the correct quantity of child nodes

DOM elements in JSDOM contain the same properties as in a browser, so you can go ahead and make your test more rigorous by asserting on the innerHTML of each item in the list.

Listing 6.20 domController.test.js

// ...
 
test("updates the DOM with the inventory items", () => {
  const inventory = { /* ... */ };
 
  updateItemList(inventory);
 
  const itemList = document.getElementById("item-list");
  expect(itemList.childNodes).toHaveLength(3);
 
  // The `childNodes` property has a `length`, but it's _not_ an Array
  const nodesText = Array.from(itemList.childNodes).map(                  
    node => node.innerHTML
  );
  expect(nodesText).toContain("cheesecake - Quantity: 5");
  expect(nodesText).toContain("apple pie - Quantity: 2");
  expect(nodesText).toContain("carrot cake - Quantity: 6");
});
 
// ...

Extract the innerHTML from each node in the itemList, creating an array of strings.

Because you will be directly invoking updateItemList but checking the DOM to assert on whether the function produced the correct output, I’d classify this test for updateItemList as an integration test. It specifically tests whether updateItemList updates the page’s markup correctly.

You can see how this test interfaces with other modules in figure 6.3.

Figure 6.3 How the tests and the unit under test interact with the document

Notice how the testing pyramid permeates all of your tests. The same principles you’ve used to test backend applications apply to frontend applications.

The problem with the previous test is that it’s tightly coupled to the page’s markup. It relies on the DOM’s structure to find nodes. If your page’s markup changes in such a way that the nodes are not in the exact same place, the tests will fail, even if the application, from a user’s point of view, is still flawless.

Let’s say, for example, that you wanted to wrap your unordered list in a div for stylistic purposes, as shown next.

Listing 6.21 index.html

< !-- ... -->
 
<body>
  <h1>Inventory Contents</h1>
  <div class="beautiful-styles">
    <ul id="item-list"></ul>
  </div>
  <script src="bundle.js"></script>
</body>
 
< !-- ... -->

This change will make your test in domController fail because it won’t find the unordered list anymore. Because the test relies on the list being a direct descendant of body, it will fail as soon as you wrap item-list in any other elements.

In this case, you’re not worried about whether the list is a direct descendant of body. Instead, what you need to guarantee is that it exists and that it contains the correct items. This query would be adequate only if your goal were to ensure that the ul directly descended from body.

You should think of the queries in your tests as built-in assertions. If, for example, you want to assert that an element is a direct descendant of another, you should write a query that relies on its DOM position.

NOTE We have previously discussed how to turn assertions into preconditions in the final section of chapter 5. Queries that depend on specific characteristics of an element operate on the same principles.

As you write frontends, you will soon notice that the DOM structure will frequently change without affecting the overall functioning of the application. Therefore, in the vast majority of situations, you should avoid coupling your tests to the DOM structure. Otherwise, you will generate extra costs by having to update tests too frequently, even if the application still works.

To avoid depending on the DOM’s structure, update your test in domController so that it finds the list by its id, as shown in the next code.

Listing 6.22 domController.test.js

// ...
 
test("updates the DOM with the inventory items", () => {
  const inventory = { /* ... */ };
  updateItemList(inventory);
 
  const itemList = document.getElementById("item-list");       
  expect(itemList.childNodes).toHaveLength(3);
 
  // ...
});
 
// ...

Finds the list by its id

By finding the list by its id, you are free to move it around in the DOM and to wrap it in as many elements you want. As long as it has the same id, your tests will pass.

TIP The elements on which you want to assert will not always already have an id attribute. It could be the case that your application doesn’t use id attributes to find elements, for example.

In that case, attaching to your elements an attribute with such strong semantics as id is not the best option. Instead, you can add a unique data-testid attribute and use it to find your element with document.querySelector ('[data-testid="your-element-testid"]').

Now, to indicate which actions happened since the page was first loaded, update your updateItemList function so that it attaches a new paragraph to the document’s body whenever it runs.

Listing 6.23 domController.js

// ...
 
const updateItemList = inventory => {
  // ...
 
  const inventoryContents = JSON.stringify(inventory);
  const p = window.document.createElement("p");                            
  p.innerHTML = `The inventory has been updated - ${inventoryContents}`;   
  window.document.body.appendChild(p);                                     
};
 
module.exports = { updateItemList };

Creates a paragraph element

Sets the paragraph’s content

Appends the paragraph to the document’s body

Once you’ve updated updateItemList, use Browserify to rebuild bundle.js by running browserify main.js -o bundle.js, and serve the application with npx http-server ./. When accessing localhost:8080, you should see a paragraph at the bottom of the page indicating what the last update was.

Now it’s time to add a test covering this functionality. Because the paragraph appended to the body doesn’t have an id or a data-testid, you must either add one of these attributes or discover another way of finding this element.

In this case, adding an identifier attribute to the paragraph seems like a bad idea. To make sure that these identifiers are unique, you’d have to make domController stateful so that it can generate a new ID every time. By doing this, you’d be adding a significant amount of code just to make this functionality testable. Besides adding more code, which would require more maintenance, you’d also be tightly coupling your implementation to your tests.

To avoid this overhead, instead of finding paragraphs by unique identifiers, find paragraphs by the characteristic on which you want to assert: their content.

Add to domController.test.js a new test that finds all paragraphs in a page and filters them by their contents.

Warning You now have multiple tests running on the same document, so you must reset its contents between each test. Don’t forget to encapsulate the assignment to document.body.innerHTML in a beforeEach hook.

Listing 6.24 domController.test.js

const fs = require("fs");
const initialHtml = fs.readFileSync("./index.html");
 
// ...
 
beforeEach(() => {
  document.body.innerHTML = initialHtml;                                   
});
 
describe("updateItemList", () => {
  // ...
 
  test("adding a paragraph indicating what was the update", () => {
    const inventory = { cheesecake: 5, "apple pie": 2 };
    updateItemList(inventory);                                             
    const paragraphs = Array.from(document.querySelector("p"));            
    const updateParagraphs = paragraphs.filter(p => {                      
      return p.includes("The inventory has been updated");
    });
 
    expect(updateParagraphs).toHaveLength(1);                              
    expect(updateParagraphs[0].innerHTML).toBe(                            
      `The inventory has been updated - ${JSON.stringify(inventory)}`
    );
  });
});

Before each test, you’ll reset the document’s body to its initial state by reassigning to it the contents of index.html.

Exercises the updateItemList function

Finds all the paragraphs in the page

Filters all of the page’s paragraphs by their text to find the one containing the desired text

Checks that there’s only one paragraph with the expected text

Checks the paragraph’s entire content

Finding an element by its content is better than relying on the DOM’s structure or unique ids. Even though all of these techniques are valid and apply to different scenarios, finding an element through its content is the best way to avoid coupling between your application and your tests. Alternatively, you can find an element through other attributes that not only uniquely identify it but also constitute an integral part of what the element should be. You can, for example, find an element by its role attribute and, therefore, build accessibility checks into your selectors.

When testing your frontend applications, remember to assert not only whether your functions work but also whether your pages display the right elements, with the correct content. To do that, find elements in the DOM, and make sure to write assertions to validate them. When writing these assertions, be careful with how you find those elements. Try to always assert on the characteristics that are an integral part of what the element should be. By asserting on these characteristics, you’ll make your tests robust and won’t create extra maintenance overhead when you refactor your application but everything is still working.

6.2.1 Making it easier to find elements

Louis would have given up baking a long time ago if it took him an hour to find the right pan every time he wanted to bake a cake. To prevent you from giving up writing valuable tests every time you add a new feature, it’s a good idea to make finding elements as effortless as it is for Louis to find his pans.

So far, we’ve been using native APIs to find elements. Sometimes, this can get quite cumbersome.

If you’re finding elements by their test-id, for example, you have to rewrite many similar selectors. In the previous test, to find a paragraph by its text, we not only had to use a selector but also had to write a significant amount of code to filter the page’s p elements. Similar tricky situations could happen if you were trying to find, for example, an input by its value or label.

To make finding elements more straightforward, you can use a library like dom-testing-library, which ships with functions that make it easy for you to find DOM nodes.

Now that you understand how to assert on the DOM, you’ll install dom-testing-library as a dev-dependency by running npm install --save-dev @testing-library/dom and refactor your tests so that they use this library’s queries.

Start with the test that checks the page’s list of items. In that test, you’ll use the getByText function exported by dom-testing-library. With getByText, you won’t need to create an array with each item’s innerHTML and check whether the array includes the text you want. Instead, you’ll tell getByText to find the desired piece of text within the list. The getByText function takes as arguments the HTMLElement within which you want to search and the text to find.

Because getByText will return a falsy result if it doesn’t find an element, you can use toBeTruthy to assert that it did find a matching node. For now, toBeTruthy will be enough, but in the next subsection, you will learn how to write more precise assertions.

Listing 6.25 domController.test.js

const { getByText } = require("@testing-library/dom");
 
// ...
 
describe("updateItemList", () => {
  // ...
 
  test("updates the DOM with the inventory items", () => {
    const inventory = {
      cheesecake: 5,
      "apple pie": 2,
      "carrot cake": 6
    };
    updateItemList(inventory);
 
    const itemList = document.getElementById("item-list");
    expect(itemList.childNodes).toHaveLength(3);
 
    expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeTruthy();  
    expect(getByText(itemList, "apple pie - Quantity: 2")).toBeTruthy();   
    expect(getByText(itemList, "carrot cake - Quantity: 6")).toBeTruthy(); 
  });
 
  // ...
});

In these assertions, you’re using getByText to find desired elements more easily.

Now, instead of having to write the logic for finding elements by their text, you delegate that task to dom-testing-library.

To make your selection even more thorough, you could also pass a third argument to getByText telling it to consider only nodes that are li elements. Try passing { selector: "li" } as the third argument for getByText, and you’ll see that the test still passes.

Go ahead and do the same for the other test in domController.test.js. This time, instead of having to pass an element within which getByText should search, you can use the getByText method from the screen namespace that dom-testing-library exports. Unlike the directly exported getByText, screen.getByText finds items within the global document by default.

Listing 6.26 domController.test.js

const { screen, getByText } = require("@testing-library/dom");
 
// ...
 
describe("updateItemList", () => {
  // ...
 
  test("adding a paragraph indicating what was the update", () => {
    const inventory = { cheesecake: 5, "apple pie": 2 };
    updateItemList(inventory);
 
    expect(
      screen.getByText(                                                   
        `The inventory has been updated - ${JSON.stringify(inventory)}`
      )
    ).toBeTruthy();
  });
});

Instead of using getByText, use screen.getByText to search for elements within the global document and thus avoid having to find the itemList beforehand.

The dom-testing-library package also includes many other useful queries, such as getByAltText, getByRole, and getByLabelText. As an exercise, try adding new elements to the page, such as an image in your input field, and use these queries to find them in the tests you will write.

NOTE You can find the complete documentation for dom-testing-library queries at https://testing-library.com/docs/dom-testing-library/api-queries.

Your selectors, just like your assertions, should be based on what constitutes an integral part of what an element should be. An id, for example, is arbitrary, and, therefore, finding elements by their ids will tightly couple your tests to your markup. Instead of finding elements by arbitrary properties, you should find elements by what matters to your users, like their text or their role. By using robust and easy-to-write selectors, your tests will be much quicker to write and much more resilient to changes that do not affect whether your application works as it should.

6.2.2 Writing better assertions

In the previous section, you used toBeTruthy to assert that dom-testing-library was able to find the elements you wanted. Even though it worked well enough for those examples, assertions like toBeTruthy are too loose and can make tests more difficult to understand.

Just like we used the jest-extended library to extend Jest with new matchers in chapter 3, we can use jest-dom to extend it with new matchers specifically for testing the DOM. These matchers can help you reduce the amount of code you need to write in your tests and make them more readable.

To use jest-dom, first, install it as a dev dependency by running npm install --save-dev @testing-library/jest-dom. Once you’ve installed it, add a jest. config.js file to your application’s directory, and configure Jest to run a setup file called setupJestDom.js.

Listing 6.27 jest.config.js

module.exports = {
  setupFilesAfterEnv: ['<rootDir>/setupJestDom.js'],
};

Within setupJestDom.js, call expect.extend and pass it jest-dom’s main export.

Listing 6.28 setupJestDom.js

const jestDom = require("@testing-library/jest-dom");
 
expect.extend(jestDom);

Adding setupJestDom.js to your setupFilesAfterEnv config will cause it to run after Jest has been initialized and add the matchers from jest-dom to expect.

After updating your Jest configuration, you can replace toBeTruthy with the toBeInTheDocument assertion from jest-dom. This change will make your tests more readable and precise. In case the element found by dom-testing-library is not attached to the document anymore, for example, toBeInTheDocument will fail, whereas toBeTruthy would pass.

Listing 6.29 domController.test.js

// ...
 
describe("updateItemList", () => {
  // ...
 
  test("updates the DOM with the inventory items", () => {
    // ...
 
    expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeInTheDocument();
    expect(getByText(itemList, "apple pie - Quantity: 2")).toBeInTheDocument();
    expect(getByText(itemList, "carrot cake - Quantity: 6")).toBeInTheDocument();
  });
 
  // ...
});

To try a different assertion, update your application so that it highlights in red the name of items whose quantity is less than five.

Listing 6.30 domController.js

const updateItemList = inventory => {
  // ...
 
  Object.entries(inventory).forEach(([itemName, quantity]) => {       
    const listItem = window.document.createElement("li");
    listItem.innerHTML = `${itemName} - Quantity: ${quantity}`;
 
    if (quantity < 5) {                                               
      listItem.style.color = "red";
    }
 
    inventoryList.appendChild(listItem);
  });
  // ...
};
 
// ...

Iterates through each entry in the inventory

If an item’s quantity is less than five, sets its color to red

To assert on an element’s style, instead of manually accessing its style property and checking the value of color, you can use toHaveStyle.

Go ahead and add a new test to check if your application highlights in red the elements whose quantity is less than five, as shown next.

Listing 6.31 domController.test.js

describe("updateItemList", () => {
  // ...
 
  test("highlighting in red elements whose quantity is below five", () => {
    const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 };
    updateItemList(inventory);
 
    expect(screen.getByText("apple pie - Quantity: 2")).toHaveStyle({
      color: "red"
    });
  });
});

With toHaveStyle, you can also assert on styles that are applied through a stylesheet. For example, try adding to your index.html a style tag that includes an almost-soldout class that sets an element’s color to red.

Listing 6.32 index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    < !-- ... -->
    <style>
      .almost-soldout {
        color: red;
      }
    </style>
  </head>
  < !-- ... -->
</html>

Then, instead of manually setting the item’s style.color property when its quantity is less than five, set its className property to almost-soldout.

Listing 6.33 domController.js

const updateItemList = inventory => {
  // ...
  Object.entries(inventory).forEach(([itemName, quantity]) => {
    const listItem = window.document.createElement("li");
    listItem.innerHTML = `${itemName} - Quantity: ${quantity}`;
 
    if (quantity < 5) {
      listItem.className = "almost-soldout";        
    }
 
    inventoryList.appendChild(listItem);
  });
 
  // ...
};
 
// ...

Instead of setting an element’s color directly, sets its class to almost-soldout, which causes an element’s color to become red

Even though the styles are not applied by your scripts, your tests should still pass. For you to achieve the same goal without jest-dom, you’d need to write way more code in your tests.

As an exercise, try adding new features to the application, such as setting the visibility of soldout items to hidden or adding a button that empties the inventory and remains disabled if the inventory is already empty. Then, use assertions like toBeVisible, toBeEnabled, and toBeDisabled to test these new features.

NOTE You can find the entire documentation for jest-dom, including a complete list of available matchers, at https://github.com/testing-library/jest-dom.

In this section, you should’ve learned how to find DOM elements within your tests, be it with native browser APIs or with utilities from dom-testing-library, which make your tests more readable. By now, you should also understand which techniques you should use to avoid maintenance overhead. You should know, for example, that finding an element based on its hierarchical chain is not a good idea, and that it’s better to find elements by their labels so that you can build verifications into your selectors. Additionally, you should be able to write precise and readable assertions for your tests with the assistance of jest-dom.

6.3 Handling events

To make something people want, you must listen to what your customers have to say. The customer may not always be right, but, in Louis’s bakery, every employee knows that they must always listen to their customers—or, at least, make the customers feel listened to.

From a business perspective, a customer’s input drives product decisions. For example, it helps the bakery to produce more of what their customers want and less of what they don’t. From a software perspective, user inputs cause the application to react, changing its state and displaying new results.

Applications that run in the browser don’t directly receive input like numbers or strings. Instead, they deal with events. As users click, type, and scroll, they trigger events. These events include details about users’ interactions, like what was the content of the form they submitted or which button they clicked.

In this section, you will learn how to handle events within your tests and accurately simulate the way users interact with your application. By precisely representing user’s inputs, you will have more reliable tests, because they will more closely resemble what happens in run time.

To see how events work and learn how to test them, you’ll add to your application a new form that allows users to add items to the inventory. Then you will make your application validate the form as users interact with it and write a few more tests for these interactions.

First, add to index.html a form that contains two fields: one for an item’s name and another for its quantity.

Listing 6.34 index.html

<!DOCTYPE html>
<html lang="en">
  < !-- ... -->
 
  <body>
    < !-- ... -->
 
    <form id="add-item-form">
      <input
        type="text"
        name="name"
        placeholder="Item name"
      >
      <input
        type="number"
        name="quantity"
        placeholder="Quantity"
      >
      <button type="submit">Add to inventory</button>         
    </form>
    <script src="bundle.js"></script>
  </body>
</html>

Causes the form to be submitted, triggering a submit event

In your domController.js file, create a function named handleAddItem. This function will receive an event as its first argument, retrieve the submitted values, call addItem to update the inventory, and then updateItemList to update the DOM.

Listing 6.35 domController.js

// ...
 
const handleAddItem = event => {
  event.preventDefault();                                   
 
  const { name, quantity } = event.target.elements;
 
  addItem(name.value, parseInt(quantity.value, 10));        
 
  updateItemList(data.inventory);
};

Prevents the page from reloading as it would by default

Because the quantity field value is a string, we need to use parseInt to convert it to a number.

NOTE By default, browsers will reload the page when users submit a form. Calling the event’s preventDefault method will cancel the default behavior, causing the browser to not reload the page.

Finally, for handleAddItem to be called whenever users submit new items, you need to attach to the form an event listener for submit events.

Now that you have a form to submit items, you don’t need to manually call addItem and updateItemList in your main.js file anymore. Instead, you can replace the entire content of this file, and make it attach only an event listener to the form.

Listing 6.36 main.js

const { handleAddItem } = require("./domController");
 
const form = document.getElementById("add-item-form");
form.addEventListener("submit", handleAddItem);           

Invokes handleAddItem whenever users submit the form

After these changes, you should have an application that is capable of dynamically adding items to the inventory. To see it running, execute npm run build to regenerate bundle.js, npx http-server ./ to serve index.html, and access localhost:8080, as you’ve done before.

Now, think about what you’d do to test the code you’ve just added.

One possibility would be to add a test for the handleAddItem function itself. This test would create an eventlike object and pass it as an argument to handleAddItem, as shown next.

Listing 6.37 domController.test.js

const { updateItemList, handleAddItem } = require("./domController");
 
// ...

describe("handleAddItem", () => {
  test("adding items to the page", () => {
    const event = {                                                
      preventDefault: jest.fn(),
      target: {
        elements: {
          name: { value: "cheesecake" },
          quantity: { value: "6" }
        }
      }
    };
 
    handleAddItem(event);                                          
 
    expect(event.preventDefault.mock.calls).toHaveLength(1);       
 
    const itemList = document.getElementById("item-list");
    expect(getByText(itemList, "cheesecake - Quantity: 6"))        
      .toBeInTheDocument();
  });
});

Creates an object that replicates an event’s interface

Exercises the handleAddItem function

Checks if the form’s default reload has been prevented

Checks whether the itemList contains a node with the expected text

For the previous test to pass, you’ve had to reverse-engineer the properties of event, building it from scratch.

One of the problems with this technique is that it doesn’t take into account any of the actual input elements in the page. Because you’ve built event yourself, you were able to include arbitrary values for name and quantity. If you try, for example, removing the input elements from your index.html, this test will still pass, even though your application can’t possibly work.

Because this test directly invokes handleAddItem, as shown in figure 6.4, it doesn’t care about whether it’s attached to the form as a listener for the submit event. For example, if you try removing from main.js the call to addEventListener, this test will continue to pass. Again, you’ve found another case in which your application won’t work but in which your tests will pass.

Figure 6.4 The test for handleAddItem invokes it directly, causing it to update the document.

Constructing events manually, as you’ve just done, can be useful to iterate quickly and test your listeners in isolation as you build them. But, when it comes to creating reliable guarantees, this technique is inadequate. This unit test covers only the handleAddItem function itself and, therefore, can’t guarantee that the application will work when users trigger real events.

To create more reliable guarantees, it’s better to create a real event instance and dispatch it through a DOM node by using the node’s dispatchEvent method.

The first step to accurately reproduce what happens in run time is to update the document’s body so that it contains the markup from index.html, as we’ve done previously. Then, it would be best if you executed main.js by using require("./main") so that it can attach the eventListener to the form. If you don’t run main.js after updating the document’s body with initialHTML again, its form will not have an event listener attached to it.

Additionally, you must call jest.resetModules before requiring main.js. Otherwise, Jest will get ./main.js from its cache, preventing it from being executed again.

Listing 6.38 main.test.js

const fs = require("fs");
const initialHtml = fs.readFileSync("./index.html");
 
beforeEach(() => {
  document.body.innerHTML = initialHtml;
 
  jest.resetModules();                    
  require("./main");                      
});

Here you must use jest.resetModules because, otherwise, Jest will have cached main.js and it will not run again.

You must execute main.js again so that it can attach the event listener to the form every time the body changes.

Now that your document has the contents from index.html and main.js has attached a listener to the form, you can write the test itself. This test will fill the page’s inputs, create an Event with type submit, find the form, and call its dispatchEvent method. After dispatching the event, it will check whether the list contains an entry for the item it just added.

Listing 6.39 main.test.js

const { screen, getByText } = require("@testing-library/dom");
 
// ...
 
test("adding items through the form", () => {
  screen.getByPlaceholderText("Item name").value = "cheesecake";
  screen.getByPlaceholderText("Quantity").value = "6";
 
  const event = new Event("submit");                               
  const form = document.getElementById("add-item-form");           
  form.dispatchEvent(event);
 
  const itemList = document.getElementById("item-list");
  expect(getByText(itemList, "cheesecake - Quantity: 6"))          
    .toBeInTheDocument();
});

Creates a “native” instance of Event with type submit

Dispatches the event through the page’s form

Checks whether the dispatched event caused the page to include an element with the expected text

This test (also shown in figure 6.5) represents what happens at run time much more accurately. Because its scope is broader than the previous test’s, this test goes higher in the testing pyramid, and, therefore, its guarantees are more reliable. For example, if you try removing the input elements from index.html or the call to addEventListener from main.js, this test will fail, unlike the previous one.

Figure 6.5 By dispatching events through the form, the application triggers the event listener attached by main.js.

Next, you’ll make your application validate the item name field as users type. Every time a user types into the item name input, you’ll confirm that its value is valid by checking if it exists in a predefined list of ingredients.

Start implementing this feature by adding a new function to domController. This function will take an event and check whether the event’s target is in the ingredient list. If the item exists, it will display a success message. Otherwise, it will show an error.

Listing 6.40 domController.js

// ...
 
const validItems = ["cheesecake", "apple pie", "carrot cake"];
const handleItemName = event => {
  const itemName = event.target.value;
 
  const errorMsg = window.document.getElementById("error-msg");
 
  if (itemName === "") {
    errorMsg.innerHTML = "";
  } else if (!validItems.includes(itemName)) {
    errorMsg.innerHTML = `${itemName} is not a valid item.`;
  } else {
    errorMsg.innerHTML = `${itemName} is valid!`;
  }
};
 
// Don't forget to export `handleItemName`
module.exports = { updateItemList, handleAddItem, handleItemName };

Now, for handleItemName to be able to display its messages, add to index.html a new p tag whose id is error-msg.

Listing 6.41 index.html

<!DOCTYPE html>
<html lang="en">
  < !-- ... -->
  <body>
    < !-- ... -->
    <p id="error-msg"></p>               
 
    <form id="add-item-form">
      < !-- ... -->
    </form>
    <script src="bundle.js"></script>
  </body>
</html>

The element that will display feedback to the users depending on whether an item’s name is valid

If you want to test the handleItemName function in isolation, you can, as an exercise, try to write a unit test for it, just like we have previously done for the handleAddItem function. You can find a complete example of how to write this test in the chapter6/3_handling_events/1_handling_raw_events folder in this book’s GitHub repository, at https://github.com/lucasfcosta/testing-javascript-applications.

NOTE As previously mentioned, unit testing these functions can be useful as you iterate, but tests that dispatch actual events are much more reliable. Given that both kinds of tests have a high degree of overlap and require similar amounts of code, if you have to choose one, I’d recommend you to stick with tests that use an element’s dispatchEvent.

If you are comfortable writing your handler functions without testing them in isolation throughout the process, it’s probably better to write tests that use only dispatchEvent.

The final step for the validation to work is to attach an event listener that handles input events that happen in the input for the item name. Update your main.js, and add the following code.

Listing 6.42 main.js

const { handleAddItem, handleItemName } = require("./domController");
 
// ...
 
const itemInput = document.querySelector(`input[name="name"]`);
itemInput.addEventListener("input", handleItemName);               

Uses handleItemName to handle input events from itemInput

TIP To see this new feature, don’t forget to rebuild bundle.js by running npm run build before serving it with npx http-server ./.

Now that you have the validation feature working, write a test for it. This test must set the input’s value and dispatch an input event through the input node. After dispatching the event, it should check whether the document contains a success message.

Listing 6.43 main.test.js

// ...
 
describe("item name validation", () => {
  test("entering valid item names ", () => {
    const itemField = screen.getByPlaceholderText("Item name");
    itemField.value = "cheesecake";
    const inputEvent = new Event("input");                
 
    itemField.dispatchEvent(inputEvent);                  
 
    expect(screen.getByText("cheesecake is valid!"))      
      .toBeInTheDocument();
  });
});

Creates a “native” instance of Event with type input

Dispatches the event through the field for an item’s name

Checks whether the page contains the expected feedback message

As an exercise, try writing a test for the unhappy path. This test should enter an invalid item name, dispatch an event through the item name field, and check if the document contains an error message.

Back to our application requirements—showing an error message when an item’s name is invalid is excellent, but, if we don’t disable the users from submitting the form, they’ll still be able to add invalid items to the inventory. We also don’t have any validation to prevent users from submitting the form without specifying a quantity, causing NaN to be displayed.

To prevent these invalid actions from happening, you’ll need to refactor your handlers. Instead of listening only to input events that happen on the item name field, you’ll listen to all input events that happen on the form’s children. Then, the form will check its children’s values and decide whether it should disable the submit button.

Start by renaming handleItemName to checkFormValues and making it validate the values in both of the form’s fields.

Listing 6.44 domController.js

// ...
 
const validItems = ["cheesecake", "apple pie", "carrot cake"];
const checkFormValues = () => {
  const itemName = document.querySelector(`input[name="name"]`).value;
  const quantity = document.querySelector(`input[name="quantity"]`).value;
 
  const itemNameIsEmpty = itemName === "";
  const itemNameIsInvalid = !validItems.includes(itemName);
  const quantityIsEmpty = quantity === "";
 
  const errorMsg = window.document.getElementById("error-msg");
  if (itemNameIsEmpty) {
    errorMsg.innerHTML = "";
  } else if (itemNameIsInvalid) {
    errorMsg.innerHTML = `${itemName} is not a valid item.`;
  } else {
    errorMsg.innerHTML = `${itemName} is valid!`;
  }
 
  const submitButton = document.querySelector(`button[type="submit"]`);
  if (itemNameIsEmpty || itemNameIsInvalid || quantityIsEmpty) {           
    submitButton.disabled = true;
  } else {
    submitButton.disabled = false;
  }
};
 
// Don't forget to update your exports!
module.exports = { updateItemList, handleAddItem, checkFormValues };

Disables or enables the form’s submit input, depending on whether the values in the form’s fields are valid

Now update main.js so that instead of attaching handleItemName to the name input, it attaches the new checkFormValues to your form. This new listener will respond to any input events that bubble up from the form’s children.

Listing 6.45 main.js

const { handleAddItem, checkFormValues } = require("./domController");
 
const form = document.getElementById("add-item-form");
form.addEventListener("submit", handleAddItem);
form.addEventListener("input", checkFormValues);                 
 
// Run `checkFormValues` once to see if the initial state is valid
checkFormValues();

The checkFormValues function will now handle any input events triggered in the form, including input events that will bubble up from the form’s children.

NOTE To see the application working, rebuild it with npm run build before serving it, as we have done multiple times throughout this chapter.

Given that you have preserved the error message that appears when users enter invalid item names, the previous tests for the item name validation should continue to pass. But, if you try rerunning them, you will see that they fail.

TIP To run only the tests in main.test.js, you can pass main.test.js as the first argument to the jest command.

If you are running jest from your node_modules folder, your command should look like ./node_modules/.bin/jest main.test.js.

If you have added an NPM script to run Jest called, for example, test, you should run npm run test -- main.test.js.

These tests fail because the events you have dispatched will not bubble up. When dispatching an input event through the item name field, for example, it will not trigger any of the listeners attached to its parents, including the one attached to the form. Because the form listener is not executed, it won’t add any error messages to the page, causing your tests to fail.

To fix your tests by making events bubble up, you must pass an extra argument when instantiating events. This additional argument should contain a property named bubbles, whose value is true. Events created with this option will bubble up and trigger listeners attached to an element’s parents.

Listing 6.46 main.test.js

// ...
 
describe("item name validation", () => {
  test("entering valid item names ", () => {
    const itemField = screen.getByPlaceholderText("Item name");
    itemField.value = "cheesecake";
    const inputEvent = new Event("input", { bubbles: true });    
 
    itemField.dispatchEvent(inputEvent);                         
 
    expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument();
  });
});
 
// ...

Creates a “native” instance of Event with type input, which can bubble up to the parents of the element through which it’s dispatched

Dispatches the event through the field for an item’s name. Because the event’s bubble property is set to true, it will bubble up to the form, triggering its listeners.

To avoid having to instantiate and dispatch events manually, dom-testing-library includes a utility called fireEvent.

With fireEvent, you can accurately simulate many different kinds of events, including submitting forms, pressing keys, and updating fields. Because fireEvent handles all that you need to do when firing an event on a particular component, it helps you write less code and not have to worry about everything that happens when an event is triggered.

By using fireEvent instead of manually creating an input event, you can, for example, avoid having to set the value property of the field for an item’s name. The fireEvent function knows that an input event changes the value of the component through which it’s dispatched. Therefore it will handle changing the value for you.

Update your tests for the form validation so that they use the fireEvent utility from dom-testing-library.

Listing 6.47 main.test.js

// ...
 
const { screen, getByText, fireEvent } = require("@testing-library/dom");
 
// ...
 
describe("item name validation", () => {
  test("entering valid item names ", () => {
    const itemField = screen.getByPlaceholderText("Item name");
 
    fireEvent.input(itemField, {                  
      target: { value: "cheesecake" },
      bubbles: true
    });
 
    expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument();
  });
});

Instead of creating an event and then dispatching it, use fireEvent.input to trigger an input event on the field for an item’s name.

TIP In case you need to simulate user events more accurately, such as users typing at a certain speed, you can use the user-event library, which is also made by the testing-library organization.

This library can be especially useful when, for example, you have fields that use debounced validations: validations that are triggered only at a certain time after users stop typing.

You can see the complete documentation for @testing-library/user-event at https://github.com/testing-library/user-event.

As an exercise, try updating all the other tests so that they use fireEvent. I’d also recommend handling different kinds of interactions with the inventory manager and testing them. You can try, for example, removing items when users double-click their names on the item list.

After this section, you should be able to write tests that validate interactions that users will have with your page. Even though it’s okay to manually construct events so that you can get quick feedback while you iterate, that’s not the kind of test that creates the most reliable quality guarantees. Instead, to simulate your user’s behavior much more accurately—and, therefore, create more reliable guarantees—you can either dispatch native events using dispatchEvent or use third-party libraries to make this process more convenient. When it comes to catching errors, this resemblance will make your tests much more valuable, and because you’re not trying to manually reproduce an event’s interface, they’ll cause much less maintenance overhead.

6.4 Testing and browser APIs

A well-equipped kitchen doesn’t necessarily imply well-baked desserts. When it comes to its role in baking amazing cakes, a kitchen is only as good as the pastry chef in it. Similarly, in the less tasty but equally fun world of web development, the fantastic APIs that browsers provide you are helpful only if your application interfaces with them correctly.

As I’ve previously mentioned in this chapter, thanks to the methods that browsers make available to your code, you can build feature-rich applications. You can, for example, obtain a user’s location, send notifications, navigate through the application’s history, or store data in the browser that will persist between sections. Modern browsers even allow you to interact with Bluetooth devices and do speech recognition.

In this chapter, you will learn how to test features that involve these APIs. You’ll understand from where they come, how to check them, and how to write adequate test doubles to help you deal with event handlers without interfering with your application code.

You’ll learn how to test these DOM APIs by integrating two of them with your frontend application: localStorage and history. By using localStorage, you’ll make your application persist its data within the browser and restore it when the page loads. Then, with the History API, you’ll allow users to undo adding items to the inventory.

6.4.1 Testing a localStorage integration

The localStorage is a mechanism that is part of the Web Storage API. It enables applications to store key-value pairs in the browser and retrieve them at a later date. You can find documentation for localStorage at https://developer.mozilla.org/ en-US/docs/Web/API/Web_Storage_API/Local_storage.

By learning how to test APIs like localStorage, you will understand how they work within a test environment and how to validate your application’s integration with them.

In these examples, you’ll persist to localStorage the inventory used to update the page. Then, when the page loads, you’ll retrieve the inventory from localStorage and use it to populate the list again. This feature will cause your application to not lose data between sessions.

Start by updating updateItemList so that it stores the object passed to it under the inventory key in localStorage. Because localStorage can’t store objects, you’ll need to serialize inventory with JSON.stringify before persisting the data.

Listing 6.48 domController.js

// ...
 
const updateItemList = inventory => {
  if (!inventory === null) return;
 
  localStorage.setItem("inventory", JSON.stringify(inventory));      
 
  // ...
}

Stores the serialized inventory in the browser’s localStorage

Now that you’re saving to localStorage the list of items used to populate the page, update main.js, and make it retrieve data under the inventory key when the page loads. Then, call updateItemList with it.

Listing 6.49 main.js

// ...
 
const storedInventory = JSON.parse(localStorage.getItem("inventory"));     
 
if (storedInventory) {
  data.inventory = storedInventory;                                        
  updateItemList(data.inventory);                                          
}

Retrieves and deserializes the inventory from localstorage when the page loads

Updates the application’s state with the previously stored data

Updates the item list using the restored inventory

After this change, when you rebuild your application and refresh the page you’re serving, you’ll see that the data persists between sessions. If you add a few items to the inventory and refresh the page again, you’ll see that the items from the previous session will remain in the list.

To test these features, we’ll rely on JSDOM once again. In the same way that, in the browser, localStorage is a global available under window, in JSDOM, it is also available under the window property in your JSDOM instance. Thanks to Jest’s environment setup, this instance is available in the global namespace of each of your test files.

Because of this infrastructure, you can test your application’s integration with localStorage using the same lines of code as you would in a browser’s console. By using JSDOM’s implementation instead of stubs, your tests will resemble a browser’s run time more closely and, therefore, will be way more valuable.

TIP As a rule of thumb, whenever JSDOM implements the browser API with which you integrate, use it. By avoiding test doubles, your tests will resemble what happens in run time more closely and, therefore, will become more reliable.

Go ahead and add a test that validates updateItemList and its integration with localStorage. This test will follow the three As pattern. It will create an inventory, exercise the updateItemList function, and check whether localStorage’s inventory key contains the expected value.

Additionally, you should add a beforeEach hook that clears the localStorage before each test runs. This hook will ensure that any other tests that use localStorage will not interfere in this test’s execution.

Listing 6.50 domController.test.js

// ...
 
describe("updateItemList", () => {
  beforeEach(() => localStorage.clear());
 
  // ...
 
  test("updates the localStorage with the inventory", () => {
    const inventory = { cheesecake: 5, "apple pie": 2 };
    updateItemList(inventory);
 
    expect(localStorage.getItem("inventory")).toEqual(
      JSON.stringify(inventory)
    );
  });
});
 
// ...

As I’ve previously mentioned, thanks to JSDOM and Jest’s environment setup, you can use the localStorage available in the global namespace both in your test and in the unit under test, as shown in figure 6.6.

Figure 6.6 Both your test and the unit under test will have access to the same global localStorage provided by JSDOM.

Notice that this test doesn’t create a very reliable quality guarantee. It doesn’t check whether the application uses updateItemList as a handler for any events or that it restores the inventory when the page reloads. Even though it doesn’t tell you much about the overall functioning of the application, it is a good test for iterating quickly, or obtaining granular feedback, especially given how easy it is to write.

From here onward, you could write many different kinds of tests in various levels of isolation. For example, you could write a test that fills the form, clicks the submit button, and checks the localStorage to see if it’s been updated. This test’s scope is broader than the previous one, and, therefore, it goes higher in the testing pyramid, but it still wouldn’t tell you whether the application reloads the data after the user refreshes the page.

Alternatively, you could go straight to a more complex end-to-end test, which would fill the form, click the submit button, check the content in localStorage, and refresh the page to see if the item list remains populated between sessions. Because this end-to-end test closely resembles what happens at run time, it creates more reliable guarantees. This test completely overlaps with the one I previously mentioned, so it saves you the effort of duplicating testing code. Essentially, it just packs more actions into a single test and helps you keep your testing codebase small and easier to maintain.

Because you won’t reload the page’s scripts, you can, instead, reassign your HTML’s content to document.body.innerHTML and execute main.js again, just like you did in the beforeEach hook in main.test.js.

Even though, for now, this test will be the only one using localStorage in this file, it’s good to add a beforeEach hook to clear localStorage before each test. By adding this hook now, you won’t waste time in the future wondering why any other tests involving this API are failing.

Here’s what that test should look like.

Listing 6.51 main.test.js

// ...
 
beforeEach(() => localStorage.clear());
 
test("persists items between sessions", () => {
  const itemField = screen.getByPlaceholderText("Item name");
  fireEvent.input(itemField, {
    target: { value: "cheesecake" },
    bubbles: true
  });
 
  const quantityField = screen.getByPlaceholderText("Quantity");
  fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true });
 
  const submitBtn = screen.getByText("Add to inventory");
  fireEvent.click(submitBtn);                                       
 
  const itemListBefore = document.getElementById("item-list");
  expect(itemListBefore.childNodes).toHaveLength(1);
  expect(
    getByText(itemListBefore, "cheesecake - Quantity: 6")
  ).toBeInTheDocument();
 
  document.body.innerHTML = initialHtml;                            
  jest.resetModules();                                              
  require("./main");                                                
 
  const itemListAfter = document.getElementById("item-list");       
  expect(itemListAfter.childNodes).toHaveLength(1);                 
  expect(                                                           
    getByText(itemListAfter, "cheesecake - Quantity: 6")
  ).toBeInTheDocument();
});
 
// ...

After having filled the form, submits it so that the application can store the inventory’s state

In this case, this reassignment is equivalent to reloading the page.

For main.js to run when importing it again, don’t forget that you must clear Jest’s cache.

Executes main.js again for the application to restore the stored state

Checks whether page’s state corresponds to the state stored before reloading it

Now that you’ve learned where browser APIs come from, how they’re made available to your tests, and how you can use them to simulate a browser’s behavior, try adding a similar feature and test it yourself. As an exercise, you can try persisting the log of actions, too, so that it’s kept intact between sessions.

6.4.2 Testing a History API integration

The History API enables developers to interface with the user’s navigation history within a specific tab or frame. Applications can push new states into history and unwind or rewind it. You can find documentation for the History API at https://developer.mozilla.org/en-US/docs/Web/API/History.

By learning how to test the History API, you’ll learn how to manipulate event listeners with test doubles and how to execute assertions that depend on events triggered asynchronously. This knowledge is useful not only for testing features that involve the History API but also for whenever you need to interact with listeners to which you don’t necessarily have access by default.

Before getting to tests, you’ll implement the “undo” feature.

To allow users to undo an item to the inventory, update handleAddItem so that it pushes a new state to the inventory whenever users add items.

Listing 6.52 domController.js

// ...
 
const handleAddItem = event => {
  event.preventDefault();
 
  const { name, quantity } = event.target.elements;
  addItem(name.value, parseInt(quantity.value, 10));
 
  history.pushState(                                   
    { inventory: { ...data.inventory } },
    document.title
  );
 
  updateItemList(data.inventory);
};
 
// ...

Pushes into history a new frame containing the inventory’s content

NOTE JSDOM’s history implementation has a bug in which the pushed state will not be cloned before being assigned to the state. Instead, JSDOM’s history will hold a reference to the object passed.

Because you mutate inventory as users add items, the previous frame in JSDOM’s history will contain the latest version of the inventory, not the previous one. Therefore, reverting to the former state won’t work as it should.

To avoid this problem, you can create a new data.inventory yourself by using { ... data.inventory }.

JSDOM’s implementation of DOM APIs should never differ from the ones in browsers, but, because it’s an entirely different piece of software, that can happen.

This issue is already being investigated at https://github.com/jsdom/jsdom/issues/2970, but if you happen to find a JSDOM bug like this one, the quickest solution is to fix it yourself by updating your code to behave in JSDOM as it would in a browser. If you have the time, I’d highly recommend that you also file an issue against the upstream jsdom repository, and, if possible, create a pull request to fix it so that others won’t face the same problems in the future.

Now, create a function that will be triggered when users click an undo button. In case the user isn’t already in the very first item in history, this function should go back by calling history.back.

Listing 6.53 domController.js

// ...
 
const handleUndo = () => {
  if (history.state === null) return;        
  history.back();                            
};
 
module.exports = {
  updateItemList,
  handleAddItem,
  checkFormValues,
  handleUndo                                 
};

If history.state is null, it means we’re already in the very beginning of history.

If history.state is not null, uses history.back to pop the history’s last frame

You’ll have to use handleUndo to handle events. Don’t forget to export it.

Because history.back happens asynchronously, you must also create a handler you’ll use for the window’s popstate event, which is dispatched when history.back finishes.

Listing 6.54 domController.js

const handlePopstate = () => {
  data.inventory = history.state ? history.state.inventory : {};
  updateItemList(data.inventory);
};
 
// Don't forget to update your exports.
module.exports = {
  updateItemList,
  handleAddItem,
  checkFormValues,
  handleUndo,
  handlePopstate           
};

Exports handlePopstate, too, so that you can attach it to the window’s popstate event in main.js later.

To index.html you add an Undo button, which we’ll use to trigger handleUndo later.

Listing 6.55 index.html

<!DOCTYPE html>
<html lang="en">
  <!-- ... -->
  <body>
    <!-- ... -->
 
    <button id="undo-button">Undo</button>        
 
    <script src="bundle.js"></script>
  </body>
</html>

The button that will trigger “undo” actions

Finally, let’s put everything together and update main.js for handleUndo to be called when users click the Undo button and so that the list gets updated when popstate events are triggered.

NOTE The interesting thing about popstate events is that they’re also triggered when users press the browser’s back button. Because your handler for popstate is separate from handleUndo, the undo functionality is also going to work when users press the browser’s back button.

Listing 6.56 main.js

const {
  handleAddItem,
  checkFormValues,
  handleUndo,
  handlePopstate
} = require("./domController");
 
// ...
 
const undoButton = document.getElementById("undo-button");
undoButton.addEventListener("click", handleUndo);               
 
window.addEventListener("popstate", handlePopstate);
 
// ...

Calls handleUndo whenever a user clicks the Undo button

Just like you’ve done before, rebuild bundle.js by running Browserify, and serve it with http-server so that you can see it working at localhost:8080.

With this feature implemented, it’s time to test it. Because this feature involves multiple functions, we’ll break its tests into a few different parts. First, you’ll learn how to test the handleUndo function, checking whether it goes back in history when it’s called. Then you’ll write a test to check whether handlePopstate integrates adequately with updateItemList. And, at last, you will write an end-to-end test that fills the form, submits an item, clicks the Undo button, and checks if the list updates as it should.

Start with a unit test for handleUndo. It should follow the three As pattern: arrange, act, assert. It will push a state into the global history—which is available thanks to JSDOM—call handleUndo, and check if the history is back to its initial state.

NOTE Because history.back is asynchronous, as I have already mentioned, you must perform your assertions only after the popstate event is triggered.

In this case, it might be simpler and clearer to use a done callback to indicate when your test should finish, instead of using asynchronous testing callbacks like we’ve done most of the time until now.

If you don’t remember how done works and how it compares to using promises, take another look at the examples in the “Integration tests” section of chapter 2.

Listing 6.57 domController.test.js

const {
  updateItemList,
  handleAddItem,
  checkFormValues,
  handleUndo
} = require("./domController");
 
// ...
 
describe("tests with history", () => {
  describe("handleUndo", () => {
    test("going back from a non-initial state", done => {
      window.addEventListener("popstate", () => {               
        expect(history.state).toEqual(null);
        done();
      });
 
      history.pushState(                                        
        { inventory: { cheesecake: 5 } },
        "title"
      );
      handleUndo();                                             
    });
  });
});
 
// ...

Checks whether the history is back to its initial state, and finishes the tests when a popstate event is triggered

Pushes a new frame into the history

Exercises the handleUndo function for it to trigger a popstate event

When running this test in isolation, it will pass, but, if it runs after the other tests in the same file, it will fail. Because the other tests have previously used handleAddItem, they’ve interfered with the initial state from which the test for handleUndo starts. To solve this, you must reset the history before each test.

Go ahead and create a beforeEach hook that keeps calling history.back until it gets back to the initial state. Once it reaches the initial state, it should detach its own listener so that it doesn’t interfere in the test.

Listing 6.58 domController.test.js

// ...
 
describe("tests with history", () => {
  beforeEach(done => {
    const clearHistory = () => {
      if (history.state === null) {                                   
        window.removeEventListener("popstate", clearHistory);
        return done();
      }
 
      history.back();                                                 
    };
 
    window.addEventListener("popstate", clearHistory);                
 
    clearHistory();                                                   
  });
 
  describe("handleUndo", () => { /* ... */ });
});

If you’re already at history’s initial, detaches itself from listening to popstate events and finishes the hook

If the history is not at its initial state yet, triggers another popstate event by calling the history.back function

Uses the clearHistory function to handle popstate events

Calls clearHistory for the first time, causing the history to rewind

Another problem with the test you’ve just written is that it attaches a listener to the global window and doesn’t remove it after the test finishes. Because the listener has not been removed, it will still be triggered every time a popstate event happens, even after that test has finished. These activations could cause other tests to fail because the assertions for the completed test would run again.

To detach all the listeners for the popstate event after each test, we must spy on the window’s addEventListener method, so that we can retrieve the listeners added during tests and remove them, as illustrated in figure 6.7.

Figure 6.7 To find event handlers, you can spy on the window’s addEventListener function and filter its calls by the event name passed as the first argument.

To find and detach event listeners, add the following code to your tests.

Listing 6.59 domController.test.js

// ...
 
describe("tests with history", () => {
  beforeEach(() => jest.spyOn(window, "addEventListener"));      
 
  afterEach(() => {
    const popstateListeners = window                             
      .addEventListener
      .mock
      .calls
      .filter(([ eventName ]) => {
        return eventName === "popstate"
      });
 
    popstateListeners.forEach(([eventName, handlerFn]) => {      
      window.removeEventListener(eventName, handlerFn);
    });
 
    jest.restoreAllMocks();
  });
 
  describe("handleUndo", () => { /* ... */ });
});

Uses a spy to track every listener added to the window

Finds all the listeners for the popstate event

Removes from window all the listeners for the popstate event

Next, we need to ensure that handleUndo will not call history.back if the user is already in the initial state. In this test, you can’t wait for a popstate event before performing your assertion because, if handleUndo does not call history.back—as expected—it will never happen. You also can’t write an assertion immediately after invoking handleUndo because by the time your assertion runs, history.back might have been called but may not have finished yet. To perform this assertion adequately, we’ll spy on history.back and assert that it’s not been called—one of the few situations in which a negated assertion is adequate, as we discussed in chapter 3.

Listing 6.60 domController.test.js

// ...
 
describe("tests with history", () => {
  // ...
 
  describe("handleUndo", () => {
    // ...
 
    test("going back from an initial state", () => {
      jest.spyOn(history, "back");
      handleUndo();

      expect(history.back.mock.calls).toHaveLength(0);       
    });
  });
});

This assertion doesn’t care about whether history.back has finished unwinding the history stack. It checks only whether history.back has been called.

The tests you’ve just written cover only handleUndo and its interaction with history.back. In the testing pyramid, they’d be somewhere between a unit test and an integration test.

Now, write tests covering handlePopstate, which also uses handleAddItem. This test’s scope is broader, and, therefore, it’s placed higher in the testing pyramid than the previous one.

These tests should push states into the history, call handlePopstate, and check whether the application updates the item list adequately. In this case, you’ll need to write DOM assertions, as we did in the previous section.

Listing 6.61 domController.test.js

const {
  updateItemList,
  handleAddItem,
  checkFormValues,
  handleUndo,
  handlePopstate
} = require("./domController");
 
// ...
 
describe("tests with history", () => {
  // ...
 
  describe("handlePopstate", () => {
    test("updating the item list with the current state", () => {
      history.pushState(                                                 
        { inventory: { cheesecake: 5, "carrot cake": 2 } },
        "title"
      );
 
      handlePopstate();                                                  
 
      const itemList = document.getElementById("item-list");
      expect(itemList.childNodes).toHaveLength(2);                       
      expect(getByText(itemList, "cheesecake - Quantity: 5"))            
        .toBeInTheDocument();
      expect(
        getByText(itemList, "carrot cake - Quantity: 2")                 
      ).toBeInTheDocument();
    });
  });
});

Pushes into history a new frame containing the inventory’s content

Invokes handlePopstate so that the application updates itself using the state in the current history frame

Asserts that the item list has exactly two items

Finds an element indicating that there are 5 cheesecakes in the inventory and then asserting that it’s in the document

Finds an element indicating that there are 2 carrot cakes in the inventory and then asserting that it’s in the document

NOTE If you wanted to test handlePopstate in complete isolation, you could find a way to create a stub for updateItemList, but, as we’ve previously discussed, the more test doubles you use, the less your tests resemble a run-time situation, and, therefore, the less reliable they become.

Here is what happens when running the test you’ve just written, including its hooks:

  1. The topmost beforeEach hook assigns the initialHtml to the document’s body innerHTML.

  2. The first beforeEach hook within this test’s describe block spies on the window’s addEventListener method so that it can track all the listeners that will be attached to it.

  3. The second beforeEach hook within this test’s describe block resets the browser’s history back to its initial state. It does so by attaching to window an event listener that calls history.back for every popstate event until the state is null. Once the history is clear, it detaches the listener, which clears the history.

  4. The test itself runs. It pushes a state to the history, exercises handlePopstate, and checks whether the page contains the expected elements.

  5. The test’s afterEach hook runs. It uses the records in window.addEventListener.mock.calls to discover the listeners that respond to the window’s popstate event and detaches them.

As an exercise, try writing a test that covers the integration between handleAddItem and the History API. Create a test that invokes handleAddItem and checks whether the state has been updated with the items added to the inventory.

Now that you’ve learned how to test handleUndo isolation and handlePopstate and its integration with updateItemList, you’ll write an end-to-end test that puts everything together. This end-to-end test is the most reliable guarantee you can create. It will interact with the applications as a user would, firing events through the page’s elements and checking the final state of the DOM.

To run this end-to-end test, you’ll also need to clear the global history stack. Otherwise, other tests that might have caused the history to change can cause it to fail. To avoid copying and pasting the same code among multiple tests, create a separate file with a function that clears the history, as shown next.

Listing 6.62 testUtils.js

const clearHistoryHook = done => {
  const clearHistory = () => {
    if (history.state === null) {
      window.removeEventListener("popstate", clearHistory);
      return done();
    }
 
    history.back();
  };
 
  window.addEventListener("popstate", clearHistory);
 
  clearHistory();
};
 
module.exports = { clearHistoryHook };

Now that you have moved the function that clears the history stack to a separate file, you can import and use it in your hooks instead of rewriting the same inline function each time. You can, for example, go back to domController.test.js and use clearHistoryHook to replace the lengthy inline hook you’ve written there.

Listing 6.63 domController.test.js

// ...
 
const { clearHistoryHook } = require("./testUtils");
 
// ...
 
describe("tests with history", () => {
   // ...
 
  beforeEach(clearHistoryHook);             
 
  // ...
});

Instead of an inline function, uses the separate clearHistoryHook to reset the history to its initial state

Finally, add the same hook to main.test.js, and write a test that adds items through the form, clicks the Undo button, and checks the list’s contents, just like a user would.

Listing 6.64 main.test.js

const { clearHistoryHook } = require("./testUtils.js");
 
describe("adding items", () => {
  beforeEach(clearHistoryHook);
 
  // ...

  test("undo to empty list", done => {
    const itemField = screen.getByPlaceholderText("Item name");
    const submitBtn = screen.getByText("Add to inventory");
    fireEvent.input(itemField, {                                           
      target: { value: "cheesecake" },
      bubbles: true
    });
 
    const quantityField = screen.getByPlaceholderText("Quantity");
    fireEvent.input(quantityField, {                                       
      target: { value: "6" },
      bubbles: true
    });
 
    fireEvent.click(submitBtn);                                            
 
    expect(history.state).toEqual({ inventory: { cheesecake: 6 } });       
 
    window.addEventListener("popstate", () => {                            
      const itemList = document.getElementById("item-list");
      expect(itemList).toBeEmpty();
      done();
    });
 
    fireEvent.click(screen.getByText("Undo"));                             
  });
});

Fills the field for an item’s name

Fills the field for an item’s quantity

Submits the form

Checks whether the history is in the expected state

When a popstate event happens, checks whether the item list is empty, and finishes the test

Triggers a popstate event by clicking the Undo button

As happened previously, this test will always pass when executed in isolation, but if it runs alongside other tests in the same file that trigger a popstate event, it may cause them to fail. This failure occurs because it attaches to window a listener with assertions, which will continue to run even after the test has finished, just like before.

If you want to see it failing, try adding a test that also triggers a popstate event right before this one. For example, you can write a new test that adds multiple items to the inventory and clicks the Undo button only once, as follows.

Listing 6.65 main.test.js

// ...
describe("adding items", () => {
  // ...
 
  test("undo to one item", done => {
    const itemField = screen.getByPlaceholderText("Item name");
    const quantityField = screen.getByPlaceholderText("Quantity");
    const submitBtn = screen.getByText("Add to inventory");

    // Adding a cheesecake
    fireEvent.input(itemField, {
      target: { value: "cheesecake" },
      bubbles: true
    });
    fireEvent.input(quantityField, {
      target: { value: "6" },
      bubbles: true
    });
    fireEvent.click(submitBtn);                                     
 
    // Adding a carrot cake
    fireEvent.input(itemField, {
      target: { value: "carrot cake" },
      bubbles: true
    });
    fireEvent.input(quantityField, {
      target: { value: "5" },
      bubbles: true
    });
    fireEvent.click(submitBtn);                                     
 
    window.addEventListener("popstate", () => {                     
      const itemList = document.getElementById("item-list");
      expect(itemList.children).toHaveLength(1);
      expect(
        getByText(itemList, "cheesecake - Quantity: 6")
      ).toBeInTheDocument();
      done();
    });
 
    fireEvent.click(screen.getByText("Undo"));                      
  });
 
  test("undo to empty list", done => { /* ... */ });
});
 
// ...

Submits the form, adding 6 cheesecakes to the inventory

Submits the form again, adding 5 carrot cakes to the inventory

When a popstate event happens, checks whether the item list contains the elements you expect and finishes the test

Triggers a popstate event by clicking the Undo button

When running your tests, you will see that they fail because all the previously attached handlers for the window’s popstate events are executed, no matter whether the previous test finished.

You can solve this problem in the same way you’ve done for the tests in domController.test.js: by tracking the calls to window.addEventListener and detaching handlers after each test.

Because you’ll reuse the hook you wrote at domController.test.js, move it to testUtils.js, too, as shown next.

Listing 6.66 testUtils.js

// ...
 
const detachPopstateHandlers = () => {
  const popstateListeners = window.addEventListener.mock.calls      
    .filter(([eventName]) => {
      return eventName === "popstate";
    });
 
  popstateListeners.forEach(([eventName, handlerFn]) => {           
    window.removeEventListener(eventName, handlerFn);
  });
 
  jest.restoreAllMocks();
}
 
module.exports = { clearHistoryHook, detachPopstateHandlers };

Finds all the listeners for the popstate event

Detaches all popstate listeners

Now, you can use detachPopstateHandlers in domController.test.js instead of writing an inline function.

Listing 6.67 domController.test.js

const {
  clearHistoryHook,
  detachPopstateHandlers
} = require("./testUtils");
 
// ...
 
describe("tests with history", () => {
  beforeEach(() => jest.spyOn(window, "addEventListener"));      
  afterEach(detachPopstateHandlers);                             
 
  // ...
});

Uses a spy to track every event listener added to the window

Instead of using an inline function to detach the listeners for the popstate event, uses detachPopstateHandlers

When using detachPopstateHandlers in main.test.js, you must be careful when detaching all of the window’s listeners after each test, because, otherwise, the listener attached by main.js can accidentally be detached, too. To avoid removing the listeners attached by main.js, make sure that you spy on window.addEventListener only after executing main.js, as shown in figure 6.8.

Figure 6.8 Your spy should capture the calls that happen only after executing main.js.

Then, add the afterEach hook with detachPopstateHandlers.

Listing 6.68 main.test.js

// ...
 
const {
  clearHistoryHook,
  detachPopstateHandlers
} = require("./testUtils");
 
beforeEach(clearHistoryHook);
 
beforeEach(() => {
  document.body.innerHTML = initialHtml;
  jest.resetModules();
  require("./main");
 
  jest.spyOn(window, "addEventListener");          
});
 
afterEach(detachPopstateHandlers);
 
describe("adding items", () => { /* ... */ });

You can spy on window.add-EventListener only after main.js has been executed. Otherwise, detachPopstateHandlers will also detach the handlers that main.js attached to the page.

NOTE It’s important to notice that these tests have a high degree of overlap.

Because you’ve written tests both for the individual functions that are part of this feature and for the whole feature, including interactions with the DOM, you’ll have somewhat redundant checks.

Depending on how granular you want your feedback to be, and the time you have available, you should consider writing only the end-to-end test, which provides the most prominent coverage of them all. If, on the other hand, you’ve got the time, and you want to have a quicker feedback loop as you write code, it will be useful to write the granular tests, too.

As an exercise, try adding a “redo” functionality and writing tests for it.

Now that you’ve tested integrations both with localStorage and the History API, you should know that JSDOM is responsible for simulating them within your test environment. Thanks to Jest, these values that JSDOM stores within its instance’s window property will be available to your tests through the global namespace. You can use them exactly as you would in a browser, without the necessity for stubs. Avoiding these stubs increases the reliability guarantees that your tests create because their implementation should mirror what happens in a browser’s run time.

As we’ve done throughout this chapter, when testing your frontend applications, pay attention to how much your tests overlap and the granularity of feedback you want to achieve. Take those factors into account to decide which tests you should write, and which you shouldn’t, just like we’ve discussed in the previous chapter.

6.5 Dealing with WebSockets and HTTP requests

In this chapter’s previous sections, you’ve built a frontend application that stores data locally. Because your clients don’t share a backend, as multiple users update the inventory, each one will see a different item list.

In this section, to sync items among clients, you will integrate your frontend application with the backend from chapter 4 and learn how to test that integration. By the end of this section, you’ll have an application that can read, insert, and update database items. To avoid users having to refresh the page to see changes made by others, you’ll also implement live updates, which will happen through WebSockets.

NOTE You can find the complete code for the previous chapter’s backend at https://github.com/lucasfcosta/testing-javascript-applications.

This backend will handle requests from the web client, providing it with data and updating database entries.

To keep this chapter focused on tests and make sure that the server will support the client we’re building, I highly recommend you to use the backend application I’ve pushed to GitHub. It already contains a few updates to support the following examples better, so that you don’t have to change the backend yourself.

To run it, navigate to the folder called server within chapter6/5_web_ sockets_and_http_requests, install its dependencies with npm install, run npm run migrate:dev to ensure that your database has an up-to-date schema, and start it with npm start.

In case you want to update the backend yourself, within the server folder there’s a README.md file that details all the changes I’ve had to make to the application we built in chapter 4.

6.5.1 Tests involving HTTP requests

Start your backend integration by saving to the database the items that users add to the inventory. To implement this feature, whenever users add an item, send a request to the new POST /inventory/:itemName route I’ve added to the server from chapter 4. This request’s body should contain the quantity added.

Update the addItem function so that it will send a request to the backend whenever users add items, as shown next.

Listing 6.69 inventoryController.js

const data = { inventory: {} };
 
const API_ADDR = "http://localhost:3000";
 
const addItem = (itemName, quantity) => {
  const currentQuantity = data.inventory[itemName] || 0;
  data.inventory[itemName] = currentQuantity + quantity;
 
  fetch(`${API_ADDR}/inventory/${itemName}`, {               
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ quantity })
  });
 
  return data.inventory;
};
 
module.exports = { API_ADDR, data, addItem };

Sends a POST request to the inventory when adding an item

Before you get to write the request that retrieves items from the inventory, let’s discuss what the optimal way would be to test the functionality you’ve just implemented. How would you test whether the addItem function correctly interfaces with your backend?

A suboptimal way to test this integration would be to spin up your server and allow requests to reach it. At first, it may seem like the most straightforward option, but, in fact, it’s the one that requires more work and yields fewer benefits.

Having to run your backend for your client’s tests to pass adds complications to the test process because it involves too many steps and creates too much room for human error. It’s easy for developers to forget that they must have the server running, and it’s even easier for them to forget to which port the server should listen or in which state the database should be.

Even though you could automate these steps, it would be better to avoid them. It’s better to leave this kind of integration for end-to-end UI tests, which you’ll learn about in chapter 10. By avoiding having to use a backend to run your client’s tests, you’ll also make it easier to set up continuous integration services that will execute your tests in a remote environment, which I’ll cover in chapter 12.

Considering you don’t want to involve your backend in these tests, you have only one option: use test doubles to control the responses to fetch. You could do that in two ways: you could stub fetch itself, write assertions to check whether it’s adequately used, and specify a hardcoded response. Or you could use nock to replace the necessity for a server. With nock, you’d determine which routes to match and which responses to give, making your tests even more decoupled from implementation details, such as which arguments you pass to fetch or even which libraries you’re using to perform requests. Because of these advantages, which I previously mentioned in chapter 4, I recommend you to go with the second option.

Because nock depends on requests reaching your interceptors, first, make sure that your tests can run within node and that they can dispatch requests. To do that, run your tests, and see what happens. When running them, you will notice that all the tests that call handleAddItem will fail because "fetch is not defined".

Even though fetch is globally available on browsers, it’s not yet available through JSDOM, and, therefore, you need to find a way to replace it with an equivalent implementation. To override it, you can use a setup file, which will attach isomorphic-fetch—a fetch implementation that can run in Node.js—to the global namespace.

Install isomorphic-fetch as a dev dependency with npm install --save-dev isomorphic-fetch, and create a setupGlobalFetch.js file, which will attach it to the global namespace.

Listing 6.70 setupGlobalFetch.js

const fetch = require("isomorphic-fetch");
 
global.window.fetch = fetch;           

Replaces the window’s original fetch with the fetch function from isomorphic-fetch

Once you have created this file, add it to the list of scripts in the setupFilesAfterEnv property of your jest.config.js, as shown in the next code, so that Jest can run it before your tests, making fetch available to them.

Listing 6.71 jest.config.js

module.exports = {
  setupFilesAfterEnv: [
    "<rootDir>/setupGlobalFetch.js",
    "<rootDir>/setupJestDom.js"
  ]
};

After these changes, if you don’t have a server available, your tests should fail because the requests made by fetch couldn’t get a response.

Finally, it’s time to use nock to intercept responses to these requests.

Install nock as a dev dependency (npm install --save-dev nock), and update your tests so that they have interceptor for the /inventory route.

Listing 6.72 inventoryController.test.js

const nock = require("nock");
const { API_ADDR, addItem, data } = require("./inventoryController");
 
describe("addItem", () => {
  test("adding new items to the inventory", () => {
    nock(API_ADDR)                                      
      .post(/inventory/.*$/)
      .reply(200);
 
    addItem("cheesecake", 5);
    expect(data.inventory.cheesecake).toBe(5);
  });
});

Responds to all post requests to POST /inventory/:itemName

Try running the tests only for this file. To do that, pass its name as the first argument to Jest. You’ll see that the test passes.

Now, add a test that ensures that the interceptor for POST /inventory/:itemName has been reached.

Listing 6.73 inventoryController.test.js

// ...
 
afterEach(() => {
  if (!nock.isDone()) {                     
    nock.cleanAll();
    throw new Error("Not all mocked endpoints received requests.");
  }
});
 
describe("addItem", () => {
  // ...
 
  test("sending requests when adding new items", () => {
    nock(API_ADDR)
      .post("/inventory/cheesecake", JSON.stringify({ quantity: 5 }))
      .reply(200);
 
    addItem("cheesecake", 5);
  });
});

If, after a test, not all interceptors have been reached, clears them and throws an error

As an exercise, go ahead and use nock to intercept requests to POST /inventory/:itemName in all other tests that reach this route. If you need help, check this book’s GitHub repository, at https://github.com/lucasfcosta/testing-javascript-applications.

As you update your other tests, don’t forget to check, at multiple levels of integration, whether specific actions call this route. I’d recommend, for example, adding a test to main.test.js to ensure that the correct route is reached when adding items through the UI.

TIP Interceptors are removed once they’re reached. To avoid tests failing because fetch can’t get a response, you must either create a new interceptor before each test or use nock’s persist method, as we saw in chapter 4.

For this feature to be complete, your frontend must ask the server for the inventory items when it loads. After this change, it should load the data in localStorage only if it can’t reach the server.

Listing 6.74 main.js

// ...
const { API_ADDR, data } = require("./inventoryController");
 
// ...
 
const loadInitialData = async () => {
  try {
    const inventoryResponse = await fetch(`${API_ADDR}/inventory`);
    if (inventoryResponse.status === 500) throw new Error();
 
    data.inventory = await inventoryResponse.json();
    return updateItemList(data.inventory);                        
  } catch (e) {
    const storedInventory = JSON.parse(                           
      localStorage.getItem("inventory")
    );
 
    if (storedInventory) {
      data.inventory = storedInventory;
      updateItemList(data.inventory);
    }
  }
};
 
module.exports = loadInitialData();

If the request succeeds, updates the item list using the server’s response

Restores the inventory from localStorage if the request fails

Even though your application is working, the test in main.test.js that checks whether items persist between sessions should be failing. It fails because it needs the GET request to /inventory to fail before trying to load data from localStorage.

To make that test pass, you make do two changes: you must use nock to make GET /inventory to respond with an error, and you must wait until the initial data has loaded.

Listing 6.75 main.test.js

// ...
 
afterEach(nock.cleanAll);
 
test("persists items between sessions", async () => {
  nock(API_ADDR)                                             
    .post(/inventory/.*$/)
    .reply(200);
 
  nock(API_ADDR)                                             
    .get("/inventory")
    .twice()
    .replyWithError({ code: 500 });
 
  // ...
 
  document.body.innerHTML = initialHtml;                     
  jest.resetModules();
 
  await require("./main");                                   
 
  // Assertions...
});
 
// ...

Succesfully responds to requests to POST /inventory/:itemName

Replies twice with an error requests to GET /inventory

This is equivalent to reloading the page.

Waits for the initial data to load

Don’t forget that those tests include a beforeEach hook, so, in it, you must also wait for loadInitialData to complete.

Listing 6.76 main.test.js

// ...
 
beforeEach(async () => {
  document.body.innerHTML = initialHtml;
 
  jest.resetModules();
 
  nock(API_ADDR)                               
    .get("/inventory")
    .replyWithError({ code: 500 });
  await require("./main");
 
  jest.spyOn(window, "addEventListener");
});
 
 
// ...

Replies with an error requests to GET /inventory

NOTE Here you are exposing the promise that will resolve once the application loads the initial data because you need to know what to wait for.

Alternatively, you can wait for a fixed timeout in your test, or keep retrying until it either succeeds or times out. These alternatives won’t require you to export the promise that loadInitialData returns, but they can make your test flaky or slower than it should be.

You don’t have to worry about the assignment to module.exports in main.js because when running that file in a browser after building it with Browserify, it will not have any effect. Browserify will take care of all the module.exports assignments for you, packing all the dependencies into a single bundle.js.

Now that you’ve learned how to test features that involve HTTP requests by using nock interceptors, and, if necessary, overriding fetch, I’ll end the section with a challenge.

Currently, when undoing actions, your application will not send a request to the server to update the inventory contents. As an exercise, try making the undo functionality sync with the server, and test this integration. For you to be able to implement this feature, I have added a new DELETE /inventory/:itemName route to the server in this chapter’s server folder on GitHub, which takes a body containing the quantity the user wants to delete.

By the end of this section, you should be capable of isolating your client’s tests from your backend by accurately simulating its behavior with nock. Thanks to nock, you can focus on specifying the responses your server would yield in which situation without having to spin up an entire backend. Creating isolated tests like this makes it much quicker and easier for everyone in your team to run tests. This improvement accelerates the feedback loop developers receive, and, therefore, incentivizes them to write better tests and to do it more often, which, in turn, tends to lead to more reliable software.

6.5.2 Tests involving WebSockets

Up to now, if your application has a single user at a time, it works seamlessly. But what if multiple operators need to manage the stock simultaneously? If that’s the case, the inventory will easily get out of sync, causing each operator to see different items and quantities.

To solve that problem, you will implement support for live updates through WebSockets. These WebSockets will be responsible for updating each client as the inventory data changes so that it’s always in sync between the clients.

Because this book is about tests, I’ve already implemented this functionality in the backend. If you don’t want to implement it yourself, you can use the server that you can find within the chapter6 folder in this book’s GitHub repository at https://github.com/lucasfcosta/testing-javascript-applications.

When clients add items, the changes I’ve made to the server will cause it to emit an add_item event to all the connected clients except the one who sent the request.

To connect to the server, you will use the socket.io-client module, so you must install it as a dependency by using npm install socket.io-client.

Start implementing the live updates functionality by creating a module that will connect to the server and save the client’s ID once it’s connected.

Listing 6.77 socket.js

const { API_ADDR } = require("./inventoryController");
 
const client = { id: null };
 
const io = require("socket.io-client");
 
const connect = () => {
  return new Promise(resolve => {
    const socket = io(API_ADDR);         
 
    socket.on("connect", () => {         
      client.id = socket.id;
      resolve(socket);
    });
  });
}
 
module.exports = { client, connect };

Creates a client instance that connects to API_ADDR

Once the client is connected, stores its id and resolves the promise

For each client to connect to the server, you must call in main.js the connect function exported by socket.js.

Listing 6.78 main.js

const { connect } = require("./socket");
 
// ...
 
connect();                             
 
module.exports = loadInitialData();

Connects to the Socket.io server when the application loads

After the client connects to the server, whenever users add a new item, the client must send its Socket.io client ID to the server through the x-socket-client-id header. The server will use this header to identify which client added the item so that it can skip it, given that this client will have already updated itself.

NOTE The route that enables clients to add items to the inventory will extract the value in the x-socket-client-id header to determine which client sent the request. Then, once it has added an item to the inventory, it will iterate through all the connected sockets and emit an add_item event to the clients whose id does not match the one in x-socket-client-id.

Listing 6.79 server.js

router.post("/inventory/:itemName", async ctx => {
  const clientId = ctx.request.headers["x-socket-client-id"];
 
  // ...
 
  Object.entries(io.socket.sockets.connected)
    .forEach(([id, socket]) => {
      if (id === clientId) return;
      socket.emit("add_item", { itemName, quantity });
    });
 
  // ...
});

Update inventoryController.js, so that it sends the client’s ID to the server, as shown next.

Listing 6.80 inventoryController.js

// ...
 
const addItem = (itemName, quantity) => {
  const { client } = require("./socket");
 
  // ...
 
  fetch(`${API_ADDR}/inventory/${itemName}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-socket-client-id": client.id             
    },
    body: JSON.stringify({ quantity })
  });
 
  return data.inventory;
};

Includes an x-socket-client-id containing the Socket.io client’s ID when sending requests that add items

Now that the server can identify the sender, the last step is to update the socket.js file so that the client can update itself when it receives the add_item messages the server sends when others add items. These messages contain an itemName and a quantity properties, which you will use to update the inventory data. Once the local state is up-to-date, you will use it to update the DOM.

Listing 6.81 socket.js

const { API_ADDR, data } = require("./inventoryController");
const { updateItemList } = require("./domController");
 
// ...

const handleAddItemMsg = ({ itemName, quantity }) => {             
  const currentQuantity = data.inventory[itemName] || 0;
  data.inventory[itemName] = currentQuantity + quantity;
  return updateItemList(data.inventory);
};
 
const connect = () => {
  return new Promise(resolve => {
    // ...
 
    socket.on("add_item", handleAddItemMsg);                       
  });
};
 
module.exports = { client, connect };

A function that updates the application’s state and the item list given an object containing an item’s name and quantity

Invokes the handleAddItemMsg when the server emits an add_item event

Give these changes a try by rebuilding your bundle.js with Browserify through npm run build and serving it with npx http-server ./. Don’t forget that your server must be running on the address specified in API_ADDR.

Testing this functionality can be done at multiple levels of integration. You could, for example, check your handleAddItemMsg function individually, without touching WebSockets at all.

To test handleAddItemMsg in isolation, first export it in socket.js.

Listing 6.82 socket.js

// ...
 
module.exports = { client, connect, handleAddItemMsg };

Then, import it in a new socket.test.js, and invoke it directly, passing an object containing an itemName and quantity. Don’t forget that you’ll need hooks to make sure that both the document and the inventory states are reset before each test.

Listing 6.83 socket.test.js

const fs = require("fs");
const initialHtml = fs.readFileSync("./index.html");
const { getByText } = require("@testing-library/dom");
const { data } = require("./inventoryController");
 
const { handleAddItemMsg } = require("./socket");
 
beforeEach(() => {
  document.body.innerHTML = initialHtml;
});
 
beforeEach(() => {
  data.inventory = {};
});

describe("handleAddItemMsg", () => {
  test("updating the inventory and the item list", () => {
    handleAddItemMsg({ itemName: "cheesecake", quantity: 6 });       
 
    expect(data.inventory).toEqual({ cheesecake: 6 });
    const itemList = document.getElementById("item-list");
    expect(itemList.childNodes).toHaveLength(1);
    expect(getByText(itemList, "cheesecake - Quantity: 6"))
      .toBeInTheDocument();
  });
});

Directly tests the handleAddItemMsg function by invoking it

TIP Even though this test can be useful for you to get feedback as you iterate, it has a high degree of overlap with a test that sends add_item messages through WebSockets instead of invoking the handleAddItemMsg directly. Therefore, in a real-world scenario, consider your time and cost constraints before choosing whether you will keep it.

As I’ve previously mentioned, accurately replicating run-time scenarios will cause your tests to generate more reliable guarantees. In this case, the closest you could get to simulating the updates your backend sends is to create a Socket.io server and dispatch updates yourself. You can then check whether those updates triggered the desired effects in your client.

Because you will need a Socket.io server when running tests, install it as a dev dependency with npm install --save-dev socket.io.

After installing Socket.io, create a file called testSocketServer.js, in which you will create your own Socket.io server. This file should export functions to start and stop the server and a function that sends messages to clients.

Listing 6.84 testSocketServer.js

const server = require("http").createServer();
const io = require("socket.io")(server);             
 
const sendMsg = (msgType, content) => {              
  io.sockets.emit(msgType, content);
};
 
const start = () =>                                  
  new Promise(resolve => {
    server.listen(3000, resolve);
  });
 
const stop = () =>                                   
  new Promise(resolve => {
    server.close(resolve);
  });
 
module.exports = { start, stop, sendMsg };

Creates a socket.io server

A function that sends a message to the clients connected to the socket.io server

Starts the socket.io server on port 3000, and resolves a promise once it’s up

Closes the socket.io server, and resolves a promise once it’s stopped

NOTE Ideally, you’d have a separate constant that determines the port to which your server should listen. If you want, you can separate API_ADDR into API_HOST and API_PORT. Because this book focuses on testing, here I’m hard-coding 3000.

Furthermore, to avoid not being able to run tests because a server is already bound to port 3000, it could be useful to allow users to configure this port through an environment variable.

It’s crucial to return promises that resolve when start and stop finish so that you can wait for them to complete when using them in your hooks. Otherwise, your tests can hang due to resources hanging.

Finally, it’s time to write a test that sends messages through the Socket.io server and checks whether your application handles them appropriately.

Start with the hooks that will start the server, connect your client to it, and then shut down the server after the tests finish.

Listing 6.85 testSocketServer.js

const nock = require("nock");
 
// ...
 
const { start, stop } = require("./testSocketServer");
 
// ...
 
describe("handling real messages", () => {
  beforeAll(start);                             
 
  beforeAll(async () => {
    nock.cleanAll();                            
    await connect();                            
  });
 
  afterAll(stop);                               
});

Before the tests run, starts your Socket.io testing server

To avoid nock interfering with your connection to the Socket.io server, cleans all mocks before trying to connect

Before all tests, connects to the Socket.io testing server

After tests finish, stops the Socket.io testing server

Finally, write a test that sends an add_item message, waits for a second so that the client can receive and process it, and checks whether the new application state matches what you expect it to be.

Listing 6.86 testSocketServer.js

const { start, stop, sendMsg } = require("./testSocketServer");
 
// ...

describe("handling real messages", () => {

  // ...
 
  test("handling add_item messages", async () => {
    sendMsg("add_item", { itemName: "cheesecake", quantity: 6 });          
 
    await new Promise(resolve => setTimeout(resolve, 1000));               
 
    expect(data.inventory).toEqual({ cheesecake: 6 });                     
    const itemList = document.getElementById("item-list");                 
    expect(itemList.childNodes).toHaveLength(1);                           
    expect(getByText(itemList, "cheesecake - Quantity: 6"))                
      .toBeInTheDocument();                                                
  });                     
});

Sends a message through the Socket.io testing server

Waits for the message to be processed

Checks whether the page’s state corresponds to the expected state

Notice how much this test overlaps with the unit test for handleAddItemMsg. The advantage of having both is that, if there’s a problem with the connection setup, the test that uses real sockets will fail, but the unit test won’t. Therefore, you can quickly detect whether the problem is with your logic or with your server connection. The problem with having both is that they add extra costs to maintaining your test suite, especially given that you perform the same assertions in both tests.

Now that you’ve checked whether your application can update when it receives messages, write a test to check whether the handleAddItem function in inventoryController.js includes the socket client’s ID into the POST requests it sends to the server. The communication between the different parts of this test are illustrated in figure 6.9.

Figure 6.9 How your tests communicate with your Socket.io server, causing it to update the document so that they can perform assertions

For that, you must start your test server, connect to it, and exercise the handleAddItem function against a nock interceptor, which will match only requests containing the adequate x-socket-client-id header.

Listing 6.87 inventoryController.test.js

// ...
 
const { start, stop } = require("./testSocketServer");
const { client, connect } = require("./socket");
 
// ...
 
describe("live-updates", () => {
  beforeAll(start);
 
  beforeAll(async () => {
    nock.cleanAll();
    await connect();
  });
 
  afterAll(stop);
 
  test("sending a x-socket-client-id header", () => {
    const clientId = client.id;
 
    nock(API_ADDR, {                                              
        reqheaders: { "x-socket-client-id": clientId }
    })
      .post(/inventory/.*$/)
      .reply(200);
 
    addItem("cheesecake", 5);
  });
});

Only responds succesfully to requests to POST /inventory/:itemName that include the x-socket-client-id header

It’s important to see that, in these examples, we’re not trying to replicate our backend’s behavior in our tests. We’re separately checking both the request we send and whether we can handle the messages we receive. Checking whether the backend sends the right messages to the right clients is a validation that should happen within the server’s tests, not the client’s.

Now that you have learned how to set up a Socket.io server that you can use within your tests and how to validate your WebSockets integrations, try extending this application with new functionality and testing it. Remember that you write these tests at multiple different levels of integration, either by checking your handler functions individually or by pushing real messages through a test server. Try, for example, pushing live updates when clients click the undo button, or maybe try adding a test that checks whether main.js connects to the server when the page loads.

By using WebSockets as an example, you must have learned how to mock other kinds of interactions that your frontend might have with other applications. If you have dependencies for which stubs would cause too much maintenance overhead, it may be better to implement your own instance of the dependency—one that you have full control over. In this case, for example, manually manipulating multiple different spies to access listeners and trigger events would cause too much maintenance overhead. Besides making your tests harder to read and maintain, it would also make them much more dissimilar to what happens at run time, causing your reliability guarantees to be much weaker. The downside of this approach is that your tests’ scope will increase, making it longer for you to get feedback and making it more coarse. Therefore, you must be careful when deciding on the optimal technique for your situation.

Summary

  • The values and APIs to which JavaScript has access in the browser are different from the ones to which it has access in Node.js. Because Jest can run only within Node.js, you must accurately replicate a browser’s environment when running your tests with Jest.

  • To simulate a browser’s environment, Jest uses JSDOM, which is an implementation of web standards written purely in JavaScript. JSDOM gives you access to browser APIs in other run-time environments, like Node.js.

  • Writing tests in multiple levels of integration requires you to organize your code into separate parts. To make it easy to manage different modules in your tests, you can still use require, but then you must use a bundler like Browserify or Webpack to pack your dependencies into a file that can run in a browser.

  • In your tests, thanks to JSDOM, you have access to APIs like document.querySelector and document.getElementById. Once you have exercised the function you want to test, use these APIs to find and assert on DOM nodes in the page.

  • Finding elements by their IDs or by their position in the DOM can cause your tests to become fragile and too tightly coupled to your markup. To avoid these problems, use a tool like dom-testing-library to find elements by their contents or other attributes that are an integral part of what an element should be, such as its role or label.

  • To write more accurate and readable assertions, instead of manually accessing properties of DOM elements or writing elaborate code to perform certain checks, use a library like jest-dom to extend Jest with new assertions specifically for the DOM.

  • Browsers react to complex user interactions, like typing, clicking, and scrolling. To deal with those interactions, browsers depend on events. Because tests are more reliable when they accurately simulate what happens at run time, your tests should simulate events as precisely as possible.

  • One way to accurately reproduce events is to use the fireEvent function from dom-testing-library or the utilities provided by user-event, another library under the testing-library organization.

  • You can test events and their handlers at different levels of integration. If you want more granular feedback as you write code, you can test your handlers by directly invoking them. If you would like to trade granular feedback for more reliable guarantees, you can dispatch real events instead.

  • If your application uses Web APIs like the History or Web Storage API, you can use their JSDOM implementations in your tests. Remember that you should not test these APIs themselves; you should test whether your application interacts adequately with them.

  • To avoid making your test setup process more complex, and to get rid of the necessity to spin up a backend to run your frontend tests, use nock to intercept requests. With nock, you can determine which routes to intercept and which responses these interceptors will produce.

  • Similar to all other kinds of tests we’ve seen, WebSockets can be tested in varying levels of integration. You can write tests that directly invoke handler functions, or you can create a server through which you will dispatch real messages.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
34.228.168.200