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.
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.
<!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.
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
".
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.
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.
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
❸ 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
.
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"
.
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.
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.
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.
// ... 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.
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
.
<!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.
{
// ...
"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.
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.
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.
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.
<!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.
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 };
❷ 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.
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.
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.
// ... 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.
// ...
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.
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.
< !-- ... --> <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.
// ...
test("updates the DOM with the inventory items", () => {
const inventory = { /* ... */ };
updateItemList(inventory);
const itemList = document.getElementById("item-list"); ❶
expect(itemList.childNodes).toHaveLength(3);
// ...
});
// ...
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.
// ... 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 };
❷ 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.
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.
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.
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.
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.
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
.
module.exports = { setupFilesAfterEnv: ['<rootDir>/setupJestDom.js'], };
Within setupJestDom.js
, call expect.extend
and pass it jest-dom
’s main export.
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.
// ... 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.
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.
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.
<!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
.
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
.
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.
<!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.
// ... 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.
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.
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.
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.
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.
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.
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.
// ... 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
.
<!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.
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.
// ... 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.
// ...
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.
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.
// ... 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
.
// ...
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.
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.
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.
// ...
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.
// ... 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.
// ... 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.
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.
// ... 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.
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.
// ...
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
.
// ... 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.
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.
<!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.
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.
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.
// ... 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.
To find and detach event listeners, add the following code to your tests.
// ... 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.
// ...
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.
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:
The topmost beforeEach
hook assigns the initialHtml
to the document’s body innerHTML
.
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.
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
.
The test itself runs. It pushes a state to the history, exercises handlePopstate
, and checks whether the page contains the expected elements.
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.
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.
// ...
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.
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
❹ 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.
// ... 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.
// ... 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.
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.
Then, add the afterEach
hook with detachPopstateHandlers
.
// ...
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.
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.
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.
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.
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.
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.
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.
// ...
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.
// ... 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.
// ... 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.
// ...
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.
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.
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
.
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
.
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.
// ...
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.
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
.
// ... 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.
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.
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 };
❷ 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.
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.
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.
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.
// ...
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.
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.
34.228.168.200