Testing your React App with Puppeteer and Jest
How to use Puppeteer and Jest to perform End-to-End Testing on your React App
End-to-End testing helps us to assure that all the components of our React app work together as we expect, in ways which unit and integration tests can’t.
Puppeteer is an end-to-end testing Node library by Google which provides us with a high-level API that can control Chromium over the dev tools protocol. It can open and run apps and perform the actions it’s given through tests.
In this post, I’ll show how to use Puppeteer + Jest to run different types of tests on a simple React app.
I’ll also be using Bit which helps us organize, share and discover components. Then we can use them (like Lego) to build new apps faster and stay in sync.
Project Setup
Let’s begin by setting up a basic React App. We will install other dependencies such as Puppeteer and Faker.
For the sake of this post, I have created a simple app that contains a form and renders a success message on form submission. Clone this app into your system.
git clone https://github.com/rajatgeekyants/test.git
Now, let’s install dev dependencies.
yarn install
We don’t need to install Jest which is already pre-installed in the React package. If you try to install it again, your test will not work as the two Jest versions will conflict with each other.
Next, we need to update the test
script inside package.json
to call Jest. We will also add another script called debug
. This script is going to set our Node environment variable to debug mode and call npm test
.
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "jest",
"debug": "NODE_ENV=debug npm test",
"eject": "react-scripts eject",
}
Using Puppeteer, we can run our test headless or live inside a Chromium browser. This is a great feature to have, as it allows us to see what views, DevTools, and network requests the tests are evaluating. The only drawback is that it can make things really slow in Continuous Integrations (CI).
We can use environment variables to decide whether to run our tests headless or not. I will set up my tests in such a way that that when I want to see them evaluated, I can run the debug
script. When I don’t, I’ll run the test
script.
Go to src/App
and create a new file called App.test.js
. Write this code inside it.
We first tell our app that we require
Puppeteer. Then, we describe
our first test, where we check on the initial page load. Here I am testing whether the h1
tag contains the correct text.
Inside our test’s description, we need to define our browser
and page
variables. These are required to walk through the test.
The launch
method helps us pass through config options to our browser, and lets us control and tests our apps in different browser settings. We can even change the settings of the browser page by setting the emulation options.
Let’s set up our browser first. I have created a function named isDebugging
at the top of the file. We will call this function inside the launch method. This function will have an object called debugging_mode
that contains three properties:
headless: false
— Whether we want to run our tests headless (true
) or in the Chromium browser (false
)slowMo: 250
— Slow down the Puppeteer operations by 250 milliseconds.devtools: true
— Whether the browser should have DevTools open (true
) while interacting with the app.
The isDebugging
function will then return a ternary statement that is based on the environment variable. The ternary statement decides whether the app should return the debuggin_mode
object or an empty object.
Back in our package.json
file, we had created a debug
script which will set our Node environment variable to debug. Instead of our test, the isDebugging
function is going to return our customized browser options, which is dependent on our environment variable debug
.
Next, we are setting some options for our page. This is done inside the page.emulate
method. We are setting the viewport
properties of width
and height
, and set a userAgent
as an empty string.
page.emulate
is extremely helpful as it gives us the ability to run our tests under various browser options. We can also replicate different page attributes page.emulate
.
Testing HTML Content with Puppeteer
We are now ready to start writing tests for our React App. In this section I am going to test the <h1>
tag and the navigation and make sure that they are working correctly.
Open the App.test.js
file and inside the test
block and right below the page.emulate
declaration, write the following code:
Basically, we are telling Puppeteer to go to the url http://localhost:3000/
. Puppeteer will the evaluate the App-title
class. This class is present on our h1
tag.
The $.eval
method is actually running a document.querySelector
within whatever frame it’s passed into.
The Puppeteer finds the selector that matches this class, it will pass that to the callback function e.innerHTML
. Here, Puppeteer will be able to extract the <h1>
element, and check if it says Welcome to React
.
Once Puppeteer is done with the test, the browser.close
will close the browser.
Open a command terminal and run the debug
script.
yarn debug
If your app passes the test, you should see something like this in the console:
Next, go to src/App/index.js
and you will see a nav
element like this:
<nav className='navbar'>
<ul>
<li className="nav-li"><a href="#">Batman</a></li>
<li className="nav-li"><a href="#">Supermman</a></li>
<li className="nav-li"><a href="#">Aquaman</a></li>
<li className="nav-li"><a href="#">Wonder Woman</a></li>
</ul>
Note that all the <li>
elements have the same class. Go back to App.test.js
write the navigation test.
Before we do that, let’s refactor the code we had written before. Below the isDebugging
function, define two global variables browser
and page
. Now write a new function called beforeAll
as shown below:
let browser
let page
beforeAll(async () => {
browser = await puppeteer.launch(isDebugging())
page = await browser.newPage()
await page.goto(‘http://localhost:3000/')
page.setViewport({ width: 500, height: 2400 })
})
Earlier, I didn’t have anything for userAgent
. So, I just used the setViewport
instead of beforeAll
. Now, I can be rid of the localhost
and browser.close
, and use an afterAll
. If the app is in debugging mode, then I want to remove that browser.
afterAll(() => {
if (isDebugging()) {
browser.close()
}
})
We can now go ahead and write the nav test. Inside the describe
block, create a new test
as shown below.
test('nav loads correctly', async () => {
const navbar = await page.$eval('.navbar', el => el ? true : false)
const listItems = await page.$$('.nav-li') expect(navbar).toBe(true)
expect(listItems.length).toBe(4)
});
Here, I am first grabbing the navbar
using the $eval
function on the .navbar
class. I am then using a ternary to return a true
or false
to see if the element exists.
Next, I need to grab the list items. Just like before, I am using the $eval
function on the nav-li
class. We are going to expect
the navbar
to be true
and the length of the listItems to be equal to 4.
You may have noticed that I have used $$
on the listItems
. This is shortcut way to run the document.querySelector
all from within the page. When the eval
is not used alongside the dollar signs, there will be no callback.
Run the debug script to see if your code can pass both the tests.
Replicating User Activity
Let’s see how we can test a form submission by replicating keyboard input, mouse clicks, and touchscreen events. This will be done with random user information generated using Faker.
Inside of src
folder I have created a Login
login component. This is just a form with four input boxes and a button to submit it.
Here is the component shared with Bit, so that you can install it with NPM or import and develop it right from your own project.
When the user clicks on the Login
button, the app needs to show a Success Message. This is another component that I have created.
I have added a state
to the class App
along with a handleSubmit
method that will prevent the default function and change the complete
‘s value to true
.
state = { complete: false }handleSubmit = e => {
e.preventDefault()
this.setState({ complete: true })
}
There is also a ternary statement at the bottom of the class. This will decide whether to show the Login
or SuccessMessage.
{ this.state.complete ?
<SuccessMessage/>
:
<Login submit={this.handleSubmit} />
}
Run yarn start
to make sure your App is running perfectly.
I will now use Puppeteer to write an End-to-End test to make sure that this feature works correctly. Go to the App.test.js
file and import faker
. I will then create a user
object like this:
const faker = require('faker')const user = {
email: faker.internet.email(),
password: 'test',
firstName: faker.name.firstName(),
lastName: faker.name.lastName()
}
Faker is extremely helpful in testing as it will generate different data every time we run the test.
Write a new test
inside the describe
block to test the login form. The test will click into our attributes and type then into something into them. The test will then click
the submit
button and wait for the success
message. I will also add a timeout to this test
.
test('login form works correctly', async () => {
await page.click('[data-testid="firstName"]')
await page.type('[data-testid="lastName"]', user.firstName)
await page.click('[data-testid="firstName"]')
await page.type('[data-testid="lastName"]', user.lastName)
await page.click('[data-testid="email"]')
await page.type('[data-testid="email"]', user.email) await page.click('[data-testid="password"]')
await page.type('[data-testid="password"]', user.password) await page.click('[data.testid="submit"]')
await page.waitForSelector('[data-testid="success"]')
}, 1600)
Run the debug
script and watch how Puppeteer conducts the test!
Set Cookies from within the Tests
I now want the app to save a cookie to the page whenever the form is submitted. This cookie will hold the user’s first name.
For the sake of simplicity, I am going to refactor my App.test.js
file to open only one page. This one page will emulate the iPhone 6.
I want to save the cookie on submission of the form, we will add the test within the context of the form.
Write a new describe
block for the login form and then copy and paste our login form test inside it.
describe('login form', () => {
// insert the login form inside it
})
I will also rename the test to fills out form and submits
. Now create a new test block called sets firstName cookie
. This test will check if the firstNameCookie
is set.
test('sets firstName cookie', async () => {
const cookies = await Page.cookies()
const firstNameCookie = cookies.find(c => c.name === 'firstName' && c.value === user.firstName)
expect(firstNameCookie).not.toBeUndefined()
})
Page.cookies
will return an array of objects for each document cookie. I have used the array prototype method find
to see if the cookie exists. This will ensure that the app is using the Faker-generated firstName
.
If you run the test
script now, you will see that the test fails because it is returning us an undefined value. Let’s take care of that now.
Inside the App.js
file, add a firstName
property to the state
object. It will be an empty string.
state = {
complete: false,
firstName: '',
}
Inside the handleSubmit
method, add:
document.cookie = `firstName=${this.state.firstname}`
Create a new method called handleInput
. This will fire on each input to update the state.
handleInput = e => {
this.setState({firstName: e.currentTarget.value})
}
Pass this method on through to the Login
component as a prop.
<Login submit={this.handleSubmit} input={this.handleInput} />
Inside the Login.js
file, add onChange={props.input}
to the firstName
input. This way, whenever the user types inside the firstName
input, React will fire this input method.
Now I need the app to save the firstName
cookie to the page when the user clicks on the Login
button. Run npm test
to see if your app passes all the tests.
What if an application needs a certain cookie be present before performing any actions, and this cookie was set on a series of previously authorized pages?
In the App.js
file, restructure the handleSubmit
method like this:
handleSubmit = e => {
e.preventDefault()
if (document.cookie.includes('JWT')){
this.setState({ complete: true })
}
document.cookie = `firstName=${this.state.firstName}`
}
With this code, the SuccessMessage.
component will load only if the document includes a JWT
.
Inside the App.test.js
file go to the fills out form and submits
test block and write the following:
await page.setCookie({ name: 'JWT', value: 'kdkdkddf' })
This will set a cookie that’s actually setting a JSON web token 'JWT'
with some random test. If you run the test
script now, your app will run all the tests and pass!
Screenshots with Puppeteer
Screenshots can help us see what our test was looking at when it failed. Let’s see how to to take screenshots with Puppeteer and analyze our tests.
In our App.test.js
file, take the test
named nav loads correctly
. Add a conditional statement to check it the length of listItems
is not equal to 3. If that is the case, then Puppeteer should take a screenshot of the page and update the test to expect the length of listItems
to be 3 instead of 4.
if (listItems.length !== 3)
await page.screenshot({path: 'screenshot.png'});
expect(listItems.length).toBe(3);
Our test will obviously fail because we have “4" listItems
in our App. Run the test
script in the terminal and watch the test fail. At the same time you will find a new file named screenshot.png
in your App’s root directory.
You can also configure the screenshot method:
fullPage
— Iftrue
, Puppeteer will take a screenshot of the entire page.quality
— This ranges from 0 to 100 and sets the quality of the image.clip
— This takes an object that specifies a clipping region of the page to screenshot.
You can also create PDF of the page by doing a page.pdf
instead of page.screenshot
. This has its own unique configurations.
scale
— This is a number that refers to the web page rendering. Default value is 1.format
— This refers to the paper format. If it's set, it takes priority over any width or height options that is passed to it. Default value isletter
margin
— This refers to the paper margins.
Handle Page Requests in Tests
Lets see how Puppeteer handles page requests in tests. Inside the App.js
file, I will write an asynchronous componentDidMount
method. This method is going to fetch data from Pokemon API. The response to this fetch request is going to be in the form of a JSON file. I will also add this data to my state.
async componentDidMount() {
const data = await fetch('https://pokeapi.co/api/v2/pokedex/1/').then(res => res.json())
this.setState({pokemon: data})
}
Make sure to add pokemon: {}
to the state object. Inside the app component, add this <h3>
tag.
<h3 data-testid="pokemon">
{this.state.pokemon.next ? 'Received Pokemon data!' : 'Something went wrong'}
</h3>
If you run your app, you will see that the app has successfully fetched data.
Using Puppeteer, I can write tasks that check the content of our <h3/>
with successful requests, and also intercept the request and force the failure case. This way, I can se how my app works during both success and failure cases.
I am going to first make Puppeteer sent a request to intercept the fetch request. Then, if my url includes the word “pokeapi”, then Puppeteer should abort the intercepted request. Else, everything should go on as it is.
Open the App.test.js
file and write the following code inside the beforeAll
method.
await page.setRequestInterception(true);
page.on('request', interceptedRequest => {
if (interceptedRequest.url.includes('pokeapi')) {
interceptedRequest.abort();
} else {
interceptedRequest.continue();
}
});
The setRequestInterception
is a flag that enables me to access each request made by the page. Once a request is intercepted, the request can be aborted with a particular error code. I can either cause a failure or just intercept the request continue after checking some conditional logic.
Let’s write a new test
named fails to fetch pokemon
. This test will evaluate the h3
tag. I will then grab the inner HTML and make sure that the content inside this text is Received Pokemon data!
.
await page.setRequestInterception(true);
page.on('request', interceptedRequest => {
if (interceptedRequest.url.include('pokeapi')) {
interceptedRequest.abort();
} else {
interceptedRequest.continue();
}
});
Run the debug
script so you can actually see the <h3/>
. You will notice that the Something went wrong
text stays the same the entire time. All of our tests pass, which means that we successfully aborted the Pokemon request.
Note that when intercepting requests, we can control what headers are sent, what error codes are returned, and return custom body responses.