~/

Back

Tips for End to End Testing with Puppeteer

#javascript #software-testing
Posted on 08, Jun, 2020
This article is available in Korean | 한국어 가능

These are exciting times for End to End (E2E) testing in the JavaScript world. In the last couple of years, tools such as cypress and Puppeteer have flooded the JavaScript community and gain a fast adoption.

Today I’m writing about Puppeteer. I want to share a pragmatic list of tips and resources that can help you get a fast overall understanding of things to consider when using Puppeteer, and what it has to offer.

»Topics I’ll Cover
  1. Getting things running
  2. Writing Tests
  3. Debugging
  4. Performance Automation
  5. Browser Support

»Getting things running

In this section, I discuss the main aspects of running a test with Puppeteer, including some interoperability aspects that we should consider, such as the usage of an underlying testing library/framework such as Jest.

»Running tests in parallel

To launch different browser instances to run your test suite, you can rely on your chosen test runner. For example, with Jest, I leverage the config maxWorkers to define how many browser sessions I allow to run concurrently.

»Be aware of the global timeout value

You want to increase the default global value for a test to timeout. E2E tests might take up several seconds to run. If you’re using Jest, you can configure the timeout value with the property testTimeout, which for Jest 26.0 defaults to 5 seconds.

Here’s an example of my jest.config.js with the mentioned configurations.

jest.config.js
module.exports = {
verbose: true,
rootDir: '.',
testTimeout: 30000,
maxWorkers: 3,
}

If you’re using (for example) mocha, you can add this.timeout(VALUE_IN_SECONDS); at the top level of your describe block.


»Abstracting puppeteer.launch

To bootstrap your test, you have to run puppeteer.lauch. I recommend you to be abstract this call within a wrapper function. Doing so allows you to centralize all your test environment customizations easily. I’m referring to making the following things configurable:

boot.js
import puppeteer from 'puppeteer'
export default async function boot(options = {}) {
let page = null
let browser = null
const { goToTargetApp = true, headless = true, devtools = false, slowMo = false } = options
browser = await puppeteer.launch({
headless,
devtools,
...(slowMo && { slowMo }),
})
if (goToTargetApp) {
page = await browser.newPage()
// I'm assuming there's some environment variable here
// that points towards the app we're going to test
await page.goto(process.env.APP_URL)
}
return { page }
}

I like to have my launch function just dealing with the bootstrap configuration aspects of my test environment and launch the application. I try to keep it as slimmer as possible, but sometimes I feel the urge to add more stuff here. There’s a saying:


“Functions should do one thing. They should do it well. They should do it only.”

source: Clean Code by Robert C. Martin


»Throttling Network Connection Speed

You can run your tests under different network speed conditions. Let me share the pattern I use based on this gist that I luckily found.

If you abstract puppeteer.launch, your test could switch between network presets just by doing the following.

boot.js
import puppeteer from 'puppeteer'
import NETWORK_PRESETS from './network-presets'
export default async function boot(options = {}) {
let page = null
let browser = null
const { goToTargetApp = true, headless = true, devtools = false, slowMo = false } = options
browser = await puppeteer.launch({
headless,
devtools,
...(slowMo && { slowMo }),
})
if (goToTargetApp) {
page = await browser.newPage()
// I'm assuming there's some environment variable here
// that points towards the app we're going to test
await page.goto(`${process.env.TARGET_APP_URL}${targetAppQueryParams}`)
if (network && NETWORK_PRESETS[network]) {
// setup custom network speed
const client = await page.target().createCDPSession()
await client.send('Network.emulateNetworkConditions', NETWORK_PRESETS[network])
}
}
return { page }
}

network-presets.js
network-presets.js
// source: https://gist.github.com/trungpv1601/2ccd3cc998149a84ba80ed7a4c9ef562
export default {
GPRS: {
offline: false,
downloadThroughput: (50 * 1024) / 8,
uploadThroughput: (20 * 1024) / 8,
latency: 500,
},
Regular2G: {
offline: false,
downloadThroughput: (250 * 1024) / 8,
uploadThroughput: (50 * 1024) / 8,
latency: 300,
},
Good2G: {
offline: false,
downloadThroughput: (450 * 1024) / 8,
uploadThroughput: (150 * 1024) / 8,
latency: 150,
},
Regular3G: {
offline: false,
downloadThroughput: (750 * 1024) / 8,
uploadThroughput: (250 * 1024) / 8,
latency: 100,
},
Good3G: {
offline: false,
downloadThroughput: (1.5 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
latency: 40,
},
Regular4G: {
offline: false,
downloadThroughput: (4 * 1024 * 1024) / 8,
uploadThroughput: (3 * 1024 * 1024) / 8,
latency: 20,
},
DSL: {
offline: false,
downloadThroughput: (2 * 1024 * 1024) / 8,
uploadThroughput: (1 * 1024 * 1024) / 8,
latency: 5,
},
WiFi: {
offline: false,
downloadThroughput: (30 * 1024 * 1024) / 8,
uploadThroughput: (15 * 1024 * 1024) / 8,
latency: 2,
},
}


»Loading a Browser Extension

Here’s how you can load a browser extension.

// 1. launch puppeeter pass along the EXTENSION_PATH within your project
// a relative path that points to the directory you output your extension assets
browser = await puppeteer.launch({
// extension are allowed only in head-full mode
headless: false,
devtools,
args: [`--disable-extensions-except=${process.env.EXTENSION_PATH}`, `--load-extension=${process.env.EXTENSION_PATH}`],
...(slowMo && { slowMo }),
})
// 2. find the extension by the title
// you might want to tackle this differently
// depending on your use case
const targets = await browser.targets()
const extensionTarget = targets.find(({ _targetInfo }) => {
return _targetInfo.title === 'my extension page title'
})
// 3. getting the extensionId from the URL
// if you have a fixed extensionId you can just pass in an
// environment variable with that value, otherwise this works fine
const partialExtensionUrl = extensionTarget._targetInfo.url || ''
const [, , extensionID] = partialExtensionUrl.split('/')
// here the entry point of the extension is an html file called "popup.html"
const extensionPopupHtml = 'popup.html'
// 4. open the chrome extension in a new tab
// notice that to properly build the extension URL you need the
// extensionId and the entrypoint resource
extensionPage = await browser.newPage()
extensionUrl = `chrome-extension://${extensionID}/${extensionPopupHtml}`
await extensionPage.goto(extensionUrl)
// ... now use extensionPage to interact with the extension

If you want to read through about testing chrome extensions with Puppeteer, I recommend this article: Automate the UI Testing of your chrome extension by Gokul Kathirvel.


»Writing Tests

Apart from the last subsection, what I discuss next, can be easily found in the official documentation. I’m just going to step on those topics that I consider to be essential parts of the Puppeteer API.

»Working with page.evaluate

You’ll need to get used to the detail that when using page.evaluate, you run on the page context, meaning even if you’re using arrow functions as an argument to page.evaluate, you can’t refer to things out of the scope of that function. You need to provide all the data you’ll need as the third argument of page.evaluate. Keep this in mind.

// extracting the "value" from an input element
const inputValue = await inputEl.evaluate((e) => e.value)

»page.waitForSelector & page.waitForFunction

Quickly getting familiar with the APIs page.waitForSelector and page.waitForFunction can reveal itself very productive. If you have a couple of tests to write changes that you’ll need to wait for some condition to be met in the UI before you allowing your test to proceed, are high. Suspend the test flow and wait for the UI is a common practice, not exclusive to Puppeteer. See the below examples for some basic usages.

// this function waits for the menu to appear before
// proceeding, this way we can ensure that we can interact
// with the list items in the menu
const getSmuiSelectOptions = async () => {
const selector = '.mdc-menu-surface li'
await page.waitForSelector(selector, { timeout: 1000 })
return await page.$$(selector)
}
// wait for a snackbar to appear when some item is deleted in the application
await extensionPage.waitForFunction(
() => !!document.querySelector('*[data-testid="global-snackbar"]').innerText.includes('deleted'),
{
timeout: 2000,
},
)

There’s a decision you need to make. The choice is whereas you should have a higher or lower timeout. I usually try to advocate for lower as possible, because we want to keep our tests fast. Running E2E tests against systems where you need to perform (not mocked) network requests, means that you need to account for network instability, altough usually you’ll run under perfect network conditions, you might want to cut some slack to the timeout value.

»element.select

I like the way it’s possible to select options on a native HTML select element. It works both for single and multiple selections, and it feels natural.

// selecting an HTTP method in a select element with id "custom-http-method"
const selectEl = await page.$('#custom-http-method')
await selectEl.select('POST')

While element.select it’s convenient, you’ll probably have to approach this differently for custom select fields built on a div > ul > li structure with a hidden input field, for instance select Material UI components.

»Screenshots

For specific test cases, I like to output a collection of screenshots that build a timeline of how my application looks throughout the test. Screenshotting in between your test helps you get an initial pointer to what you should be debugging in a failing test. Here’s my small utility that wraps page.screenshot API.

test-utils.js
// wrapping the call to `page.screenshot` just to avoid it
// breaking my test in case the screenshot fails
export async function prtScn(page, path = `Screenshot ${new Date().toString()}`) {
try {
await page.screenshot({ path, type: 'png', fullPage: true })
} catch (error) {
// eslint-disable-next-line no-console
console.error(error)
// eslint-disable-next-line no-console
console.info('Failed to take screenshot but test will proceed...')
return Promise.resolve()
}
}
import * as utils from 'test-utils'
// (...)
// Note: you can make this utility a Class and pass along the page
// as context so that you don't need to pass it in everytime you
// need to take a screenshot
await utils.prtScn(page)


»Page Reloads

With the page.reload API. You can specify a set of options that allow you to wait for specific underlying browser tasks to idle before proceeding.

await page.reload({ waitUntil: ['networkidle0'] })

In this above example, we reload the page with networkidle0, which does not allow the test to proceed unless there are no HTTP requests within a half a second period.

»Clearing Text from an Input Field

I was stunned not to find a very out-of-the-box way to clear an input field. A few developers have expressed interest in this feature, but it seems there’s no interest on the other end. I’ve found a way to do it:

test-utils.js
/**
* Clears an element
* @param {ElementHandle} el
*/
export async function clear(el) {
await el.click({ clickCount: 3 })
await el.press('Backspace')
}

It only works on Chrome as it takes advantage of the functionality where three consecutive clicks in a text area/input field select the whole text. After that, you need to trigger a keyboard event to clear the entire field.


»Debugging

I want to highlight some debugging techniques. Especially the slowMo option.

»Debugging with slowMo

You’ll want to use slowMo to debug individual tests. The option allows you to slow down the interactions (the steps) of your E2E test so that you can see what’s going on, almost like seeing an actual human interacting with your application. I can’t emphasize enough how valuable this is.

page.launch({ slowMo: 50 })

In the following GIFs you can see the difference of running without and with the slowMo option respectively. E2E test on the tweak chrome extension without slowMo. You can’t possibly understand what’s going on.

without slow motion


with slow motion

In these examples I’m using the tweak browser extension to demo the different use cases.

For more awesome tips for debugging, I highly recommend this short article on Debugging Tips from Google.

»Using debugger

I got this one from Google debugging tips. I had the habit of throwing a sleep statement to stop my tests for X seconds and inspect the application to see why the tests were breaking. But now I completely shifted to this.

await page.evaluate(() => {
debugger
})

For more great debugging tips, I highly recommend this short article on Debugging Tips from Google.


»Performance Automation

There’s quite a buzz going on about using Puppeteer to automate web performance testing. I couldn’t write this article without giving a shout out to Addy Osmani on the work developed on addyosmani/puppeteer-webperf, which I couldn’t recommend more. Within the project README.md you’ll find the most organized set of examples to tune your performance automation.


»Browser Support

According to the official documentation, you can use Puppeteer with Firefox, with the caveat that you might encounter some issues since this capability is experimental at the time of this writing. You can specify which browser to run via puppeteer.launch options API that I’ve covered in this section.



What are your favorite bits of Puppeteer? What would you recommend me to learn next?

Last modified on 12, Oct, 2024