Dealing with Asynchrony when Writing End-To-End Tests with Puppeteer + Jest
Albert Alises
Posted on September 21, 2018
In this article we present an overview on how to deal with asynchrony when performing end-to-end tests, usingPuppeteeras a web scraper andJestas an assertion library. We will learn how to automate user action on the browser, wait for the server to return data and for our application to process and render it, to actually retrieving information from the website and comparing it to the data to see if our application actually works as expected for a given user action.
So you got your wonderful web application up and running, and the time for testing has come.... There are many types of test, from Unit tests where you test the individual components that compound your application, to Integration tests where you test how these components interact with eachother. In this article we are gonna talk about yet another type of tests, the End-To-End (e2e) tests.
End-to-end tests are great in order to test the whole application as a user perspective. Tha means testing that the outcome, behavior or data presented from the application is as expected for a given user interaction with it. They test from the front-end to the back-end, treating the application as a whole and simulating real case scenarios. Here it is a nice article talking about what e2e tests are and their importance.
To test javascript code, one of the most common frameworks for assertions is Jest, which allows you to perform all kinds of comparisons for your functions and code, and even testing React components. In particular, to perform e2e tests, a fairly recent tool, Puppeteer, comes to the rescue. Basically it is a web scraper based on Chromium. According to the repo, it is a "Node library which provides a high-level API to control Chrome or Chromium over the DevTools Protocol".
It provides some methods so you can simulate the user interaction on a browser via code, such as clicking elements, navigating through pages or typing on a keyboard. As having a highly trained monkey perform real case tasks on your application π
You can find the github repos of both testing libraries here:
Puppeteer is a JavaScript library which provides a high-level API to control
Chrome or Firefox over the
DevTools Protocol or WebDriver BiDi
Puppeteer runs in the headless (no visible UI) by default
npm i puppeteer # Downloads compatible Chrome during installation.
npm i puppeteer-core # Alternatively, install as a library, without downloading Chrome.
Example
importpuppeteerfrom'puppeteer';// Or import puppeteer from 'puppeteer-core';// Launch the browser and open a new blank pageconstbrowser=awaitpuppeteer.launch();constpage=awaitbrowser.newPage();// Navigate the page to a URL.awaitpage.goto('https://developer.chrome.com/');// Set screen size.awaitpage.setViewport({width: 1080,height: 1024});// Type into search box.awaitpage
Given so, the Puppeteer + Jest become has become a nice, open source way of testing web applications, by opening the application on the headless browser provided by puppeteer and simulating user input and/or interaction, and then checking that the data presented and the way our application reacts to the different actions is as expected with Jest.
In this article we will not cover the whole workflow of testing with puppeteer + jest (such as installing, or setting the main structure of your tests, or testing forms), but focus on one of the biggest things that we have to take into account when doing so: Asynchrony.
Because almost all of the web applications contain indeed some sort of asynchrony. Data is retrieved from the back-end, and that data is then rendered on screen. However, Puppeteer performs all the operations sequentially, so... how can we tell him to wait until asynchronous events have happened?
Puppeteer offers you a way to wait for certain things to happen, using the waitFor functions available for the Page class. The changes you can track are visual changes that the user can observe. For instance you can see when something in your page has appeared, or has changed color, or has disappeared, as a result of some asynchronous call. Then one would compare these changes to be what you would expect from the interaction, and there you have it. How does it work?
Waiting, Waiting, Waiting...β°
The waitFor function set in Puppeteer helps us deal with asynchrony. As these functions return Promises, usually the tests are performed making use of the async/await ES2017 feature. These functions are:
Whenever a user action causes the page to navigate to another route, we sometimes have to wait a little bit before all the content is loaded. For that we have the waitForNavigation function. it accepts a options object in where you can set a timeout (in ms), or waitUntil some condition is met. The possible values of waitUntil are (according to the wonderful puppeteer documentation):
load (default): consider navigation to be finished when the load event is fired.
domcontentloaded: consider navigation to be finished when the DOMContentLoaded event is fired.
networkidle0: consider navigation to be finished when there are no more than 0 network connections for at least 500 ms.
networkidle2: consider navigation to be finished when there are no more than 2 network connections for at least 500 ms.
Tipically, you will want to wait until the whole page is loaded ({waitUntil: load}), but that does not guarantee you that everything is operative. What you can do is wait for a specific DOM element to appear that assures you that the whole page is loaded. you can do that with the following function:
waitForSelector
This function waits for a specific CSS selector to appear, indicating that the element it matches to is on the DOM.
awaitpage.goto("https://www.example.com",{waitUntil:"load"});awaitpage.click("#show-profileinfo-button");//Triggers a navigation/*
We can either wait for the navigation or wait until a selector that indicates
that the next page is operative appears
*/awaitpage.waitForNavigation({waitUntil:"load"});awaitpage.waitForSelector("#data-main-table");
waitForFunction
The last one waits for some function to return true in order to proceed. It is commonly used to monitor some property of a selector. It is used when the selector is not appearing on the DOM but some property of it changes (so you cannot wait for the selector because it is already there on the first place). It accepts a string or a closure as arguments.
For example, you want to wait until a certain message changes. The testing would be performed by first getting the message using the evaluate() Puppeteer function. Its first parameter is a function which is evaluated in the browser context (as if you were typing into the Chrome console).We then perform the asynchronous operations that change the message (clicking a button or whatever π±π¨), and then waiting for the message to change.
constpreviousMessage=awaitpage.evaluate(()=>document.querySelector('#message').innerHTML);//Async behaviour...awaitpage.waitForFunction(`document.querySelector('#message').innerHTML !== ${previousMessage}`);//Wait until the message changes
Using these waitFor functions we can detect when something in our page changes after an async operation, now we just need to retrieve the data we want to test.
Retrieving data from selectors after an Async operation
Once we have detected the changes caused by our asynchronous code, we tipically want to extract some data from our application that we can later compare to the expected visual result from a user interaction. We do that using evaluate() .The most common cases that you face when retrieving data are:
- Checking that a DOM element has appeared
A pretty common case is checking that a given element has been rendered on the page, hence appearing on the DOM. For instance, after saving a post, you should find it in the saved posts section. Navigating there and querying if indeed the DOM element is there is the basic type of data that we can assert (as a boolean assertion).
Find below an example of checking if a given post with an id post-id, where the id is a number we know, is present on the DOM. First we save the post, we wait for the post to be saved, go to the saved posts list/route and see that the post is there.
constid='243';awaitpage.click(`#post-card-${id} .button-save-post`);//The class could be added when the post is saved (making a style change on the button)awaitpage.waitForSelector(`#post-card-${id} .post-saved`);awaitpage.click('#goto-saved-posts-btn');awaitpage.waitForNavigation();constpost=awaitpage.evaluate(id=>{returndocument.querySelector(`#post-${id}`)?true:false},id);expect(post).toEqual(true);
In there, we can observe a couple of things.
The aforemenctioned need to have unique id's for the tested elements. Given so, querying the selector is way easier and we do not need to do nested queries that get the element based on its position on the DOM (Hey, get me the first tr element from the first row of that table ππΌ).
We see how we can pass arguments to the evaluate function and use it to interpolate variables into our selectors. As the fucntion is being evaluated in another scope, you need to bind the variables from node to that new scope, and you can do that via that second parameter.
- Checking for matching property values (e.g innerHTML, option...)
Now imagine that instead of checking that an element is on the DOM, you actually want to check if the list of saved posts rendered on the page actually are the posts you have saved. That is, you want to compare an array of strings with the post names, e.g ["post1,"post2"], with the saved posts of a user (which you can know beforehand for a test user, or retrieve from the server response).
For doing that, you need to query all the title elements of the posts and obtain a given property from them (as it could be their innerHTML, value, id...). After that, you have to convert that information to a serializable value (the evaluate function can only return serializable values or it will return null, that is, always return arrays, strings or booleans, for instance, not HTMLElements...).
An example performing that testing would be:
constlikedPosts=["post1","post2","post3"];constlist=awaitpage.evaluate(()=>{letout=[]/*
We get all the titles and get their innerHTML. We could also query
some property e.g title.getAttribute('value')
*/consttitles=document.querySelectorAll("#post-title");for(lettitleoftitles){out.push(title.innerHTML)}returnout;});expect(list).toEqual(likedPosts);
Those are the most basic cases (and the ones you will be using most of the time) for testing the data of your application.
-Asserting that the waitFor is succesful
Another thing you can do instead of evaluate() is, in case you just want to assert a boolean selector or a particular DOM change, is just assign the waitFor() call to a variable and check if it is true. The downside of that method is that you will have to set an estimated timeout to the function that is less than the Jest timeout set at the start. β³
If that timeout is exceeded the test will fail. It requires you to put an estimate timeout that you think is enough for the element to be rendered on your page after the request is made (Hmm yeah, I think that around 3 seconds should be enough... π€).
For example, we want to check if a new tag has been added to a post querying the number of tag elements present before and after adding tags, that is, comparing their length and see if it has increased, denoting that the tag has indeed been added.
constpreviousLength=awaitpage.evaluate(()=>document.querySelectorAll('#tag').length);//Add tag async operation...//Wait maximum 5 seconds to see if the tag has been added.consthasBeenAdded=awaitpage.waitForFunction(previousLength=>{returndocument.querySelector('#tag')length>previousLength},{timeout:5000},previousLength);expect(hasChanged).toBeTruthy();
Note that you also have to bind the variables to the waitForFunction() as the third element if you specify the waitForFunction parameter as a closure.
In order to get the data to compare with the information we have retrieved from the page, i.e our ground truth, an approach is to have a controlled test user in which we know what to expect for each one of the tests (number of liked posts, written posts). In this approach we can then hardcode π± the data to expect, such as the post titles, number of likes, etc... like we did on the examples of the previous section
You can also fake the response data from the server. That way you can test that the data obtained from the back-end is consistent with what is rendered into the application by responding predictable, inmutable data which you know beforehand. This serves for testing if the application responds predictably (parses correctly) the data returned from the server for a given call.
On the next section we will see how to hijack the requests and provide custom data which you know. Puppeteer provides a method to achieve that, but if you want to dwelve more into faking XMLHttpRequest and pretty much all the data your test manages, you should take a look into Sinon.js π
Intercepting Requests and faking Requests with Puppeteer
Imagine that you want to check if the list of saved posts rendered on the page is indeed correct given a list of saved posts for a certain user that we can obtain from an endpoint called /get_saved_posts. To enable requestInterception on puppeteer we just have to set when launching puppeteer
awaitpage.setRequestInterceptionEnabled(true);
With that, we can set all the request to intercept and mock the response data. Of course, that requires knowing the structure of the data returned by the back-end. Tipically, one would store all the fake data response objects into a separate class and then, on request interception, query the endpoint called and return the corresponding data. This can be done like that, using the page.on() function:
Disclaimer: For the API we assume the somewhat typical format https://api.com/v1/endpoint_name, so the parsing to retrieve the endpoint is specific to that format for exemplification purposes, you can detect the Request made based on other parameters of course, your choice πͺ
constresponses={"get_saved_posts":{status:200,//Body has to be a stringbody:JSON.stringify({data:{posts:["post1","post2"]}});}}page.on('request',interceptedRequest=>{constendpoint=interceptedRequest.url.split('/').pop();if(responses[endpoint]){request.respond(responses[endpoint]);}else{request.continue();}});
One can easily see that, depending on the size of your API, this can be quite complex, and also one of the charms of the e2e testing is to also test if the back-end is giving the correct information and data π΅.
All these methods require for you to enter known data, hardcoded as a response or as a variable to compare. Another way to get the data to compare is to intercept the response and store the data into the variable.
You can also intercept responses and get the data from there. For instance, we can intercept the get_saved_posts response and store all the posts into a variable.
constposts=[];page.on('response',response=>{/*
Grab the response for the endpoint we want and get the data.
You could then switch the endpoint and retrieve different data
from the API, such as the post id from before, remember?
*/constendpoint=response.url().split('/').pop();if(endpoint==="get_saved_posts"){constresponseBody=awaitresponse.json();posts=responseBody.data.posts}})
βπΌ Note: The page.on() methods for request and response are tipically placed on the beforeAll() method of your tests, after declaring the page variable, as they define a global behavior of your test.
So after your application has rendered everything you can query the DOM elements and then compare with the posts variable to see that your app effectively renders everything as expected.
Summary
In this article we provided a comprehensive overview on how to test applications that present asynchronous data fetched from a server, by using Puppeteer + Jest.
We have learned how to implement waiting for certain events to happen (e.g DOM mutations), that trigger visual changes caused by our asynchronous data. We have gone through the pipeline of detecting, querying and comparing those changes with known data so we can assess that the application works as expected from a user perspective.
Got any questions?! Go out here, start implementing tests like a madman and pray for them to pass, happy coding! ππ»