Here's what we cover in this article:
- Motivation to perform e2e testing on browser extensions.
- Why Puppeteer and known limitations.
- A complete example of a functional chrome extension and a sample React application.
- A complete example of a puppeteer + jest set up to run your automation.
All the code referenced in this article can be found in our repo tweak-extension/puppeteer-test-browser-ext.
Introโ
If you develop browser extensions, you've probably realized that extensions are complex to test. Unlike the conventional web application where everything happens within the same app, with extensions, you'll constantly interact across different applications - at least the website in the active browser tab and the extension itself. Unit tests are fantastic, they'll help us validate specific aspects of the implementation, but they feel like a tiny grain of sand, when attempting to validate real-world use cases, between two components that run in isolation.
Manual testing and the development lifecycle of extensions, takes a lot of time, much more than traditional applications. Although you might have a nice setup in place, with hot reloads and so on, you'll always have to:
- Make a set of code changes.
- Reload the extension.
- Make sure the latest extension is loaded in the browser - sometimes, a manual extension reload is required.
- Make sure no previous stored data is lingering around in the extension storage.
- Finally, load the extension to interact with the target website and the extension to validate the desired behavior.
End-to-end (E2E) testingโ
It would be amazing to just spin up a browser instance and start interacting with the chrome extension and the target application, just like you do for e2e testing the typical single page application. But how do you even interact with a chrome extension programmatically? Or first, how to load a chrome extension? And how can I keep interacting with both the extension and the website? Seems like a lot of work.
If you have the above questions, this article is for you. The tweak team makes extensive use of Puppeteer to automate our extension tests and simulate real-world use-cases.
Puppeteerโ
"Puppeteer is a Node library that provides a high-level API to control Chrome or Chromium over the DevTools Protocol. Puppeteer runs headless by default but can be configured to run full (non-headless) Chrome or Chromium.", from pptr.dev
In our opinion Puppeteer is the go-to library if you are working with the Chrome platform. Unlike selenium which supports many programming languages, Puppeteer is tailored to the JavaScript land (for better or worse). Although you can run Puppeteer in Firefox - as mentioned in the official documentation - as far as we can tell, browser extensions are not yet supported. Also, if you need to run your tests across different browser vendors, this is not the best solution for you.
Testing strategyโ
The goal is to run our e2e tests where we interact with both an extension and a target application, just like our users would. Our high-level automation flow is the following:
- Open a browser tab with the target application.
- Load the browser extension and open the popup in a new tab
- Do interactions with the extensions.
- Go back to the target app, check if the extension produced the desired changes on the page.
Why do we open the popup in a new tab? At this time is not possible to test popups (or content scripts).
Case studyโ
We've set up a small open-source repo puppeteer-test-browser-ext containing the case study we'll reference in this article to lay clear expectations and make sure you have something tangible moving forward.
Our case study repo contains:
- Replace text extension - a minimalist chrome extension whose only feature is to replace text on a web page. Receives a "from" and a "to" words to replace one with the other.
- Target react app - A React app with a single component. The app has a button that, once clicked, shows a paragraph of text. That's all. This text is the one that our chrome extension is going to modify.
- Puppeteer tests - our end-to-end test and other auxiliary code to make our tests run.
By the end, we'll detail all the steps to achieve the automated test run you see in the below GIF.
Browser extensionโ
Above we saw how the extension looks like. It's composed by two input fields and a button. Below is the most crucial bit of the extension.
function performReplacement(tabId) {
chrome.scripting.executeScript({
target: { tabId },
func: replaceText,
args: [valueFrom('from'), valueFrom('to')],
});
}
We execute a function that replaces text in a target web page using script injection. This is the function we'll call when clicking on the replace
button.
This extension is a Manifest V3 extension. We're using the API chrome.scripting.executeScript
, which is relatively recent. All the browser extension code can be found in puppeteer-test-browser-ext/replacer-chrome-extension.
React appโ
We've seen the React app in the previous screenshot. Here's the App
component. Similarly, all the application code can be found in puppeteer-test-browser-ext/src.
import React, { useState } from 'react';
import './App.css';
function App() {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="app">
<button className="action" onClick={() => setIsExpanded(!isExpanded)}>{`click to ${isExpanded ? 'hide' : 'show'} text`}</button>
{isExpanded && <p className="text">Rock <b>music</b> is a broad genre of popular <b>music</b> that originated...</p>}
</div>
);
}
export default App;
Here we'll use the class
attribute to select elements in our test. This is not a good practice, use data attributes test identifiers to make your tests less prone to change due to modifications in the component.
Test caseโ
It's a good practice to reflect on what exactly we want to test before getting hands-on. Here's our test plan.
- When a user opens the React application.
- The user should see a button on the web page.
- Then the user clicks the button to display the text.
- When the user goes to the chrome extension.
- When the user writes the word "music" and its replacement "**TEST**" in the extension and the user clicks on the "replace" button.
- When the user goes back to the website.
- Then the user should see the string "**TEST**" on the page.
- Then the user should no longer see the string "music" on the page.
Implementationโ
Now that we have the test case, let's implement it. We'll describe the most critical aspects of how we set up our tests within our sample codebase, and last we'll deep dive into the specifics of the test implementation.
Project overviewโ
.
โโโ e2e // e2e tests
โย ย โโโ bootstrap.js
โย ย โโโ sampleTest.spec.js
โโโ jest.config.js
โโโ package.json
โโโ public // React app entry point
โย ย โโโ index.html
โโโ replacer-chrome-extension // chrome extension
โย ย โโโ background.js
โย ย โโโ manifest.json
โย ย โโโ popup.html
โย ย โโโ popup.js
โโโ src // React app
โโโ App.css
โโโ App.js
โโโ index.css
โโโ index.js
Here we provide a bit more insights into our test case setup. We have:
- A
package.json
where you'll find"puppeteer"
amongst all the pre-packed create-react-app dependencies. - A
jest.config.js
file that contains a minimalist setup. - An ejected
create-react-app
, and its code undersrc/**
. - A
e2e
where we'll write our test,e2e/sampleTest.spec.js
contains the test implementation ande2e/bootstrap.js
contains auxiliary code to run our tests.
Jest has the role of test runner. Puppeteer is the glue between your test and an actual browser session. You'll be able to perform assertions and use the whole Jest API as per usual.
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
.
Launch Puppeteerโ
Under e2e/bootstrap.js
you'll find all the code necessary to load the target application and the browser extension into a puppeteer-controlled session.
Let's break down this script into smaller steps.
First we must run pupppeter.lauch
.
const { devtools = false, slowMo = false, appUrl } = options;
const browser = await puppeteer.launch({
headless: false,
devtools,
args: [
'--disable-extensions-except=./replacer-chrome-extension',
'--load-extension=./replacer-chrome-extension',
],
...(slowMo && { slowMo }),
});
There are a few things to notice here:
- Puppeteer does not load extensions in the headless mode, thus the
headless: false
flag. - We parameterize
devtools
andslowMo
because they are incredibly relevant for debugging. - We use the options
--load-extension
and--disable-extensions-except
to make sure we bootstrap a clean environment where our extension is the only one loaded in the session.
Following open a page with the target app.
const appPage = await browser.newPage();
await appPage.goto(appUrl, { waitUntil: 'load' });
Next, we find the extension id, which will allow us to load the extension in a new tab by referencing the chrome-extension://
namespace to access the extension resources.
const extensionTarget = targets.find(target => target.type() === 'service_worker');
const partialExtensionUrl = extensionTarget.url() || '';
const [, , extensionId] = partialExtensionUrl.split('/');
๐ฅ How to retrieve the extension id for a Manifest V2 extension?
const targets = await browser.targets();
extensionTarget = targets.find(target => target.url().includes('chrome-extension'));
const partialExtensionUrl = extensionTarget.url() || '';
const [, , extensionID] = partialExtensionUrl.split('/');
Once we figure out the id, we load the extension in a tab, and similarly to the application, we grab a page reference and return it.
const extPage = await browser.newPage();
const extensionUrl = `chrome-extension://${extensionId}/popup.html`;
await extPage.goto(extensionUrl, { waitUntil: 'load' });
return {
appPage,
browser,
extensionUrl,
extPage,
};
Launch script full implementationโ
Toggle to see the full implementation
const puppeteer = require('puppeteer');
async function bootstrap(options = {}) {
const { devtools = false, slowMo = false, appUrl } = options;
const browser = await puppeteer.launch({
headless: false,
devtools,
args: [
'--disable-extensions-except=./replacer-chrome-extension',
'--load-extension=./replacer-chrome-extension',
],
...(slowMo && { slowMo }),
});
const appPage = await browser.newPage();
await appPage.goto(appUrl, { waitUntil: 'load' });
const targets = await browser.targets();
// NOTE: below code does not work for Manifest V2 extensions, you can find the snippet
// in this article: TBD
const extensionTarget = targets.find(target => target.type() === 'service_worker');
const partialExtensionUrl = extensionTarget.url() || '';
const [, , extensionId] = partialExtensionUrl.split('/');
const extPage = await browser.newPage();
const extensionUrl = `chrome-extension://${extensionId}/popup.html`;
await extPage.goto(extensionUrl, { waitUntil: 'load' });
return {
appPage,
browser,
extensionUrl,
extPage,
};
}
module.exports = { bootstrap };
Writing the testโ
The most complex work is done. We can reference our test plan and now focus our efforts on implementing the test itself.
The test is split into 3 phases. Let's look into them.
In the first two steps we check that the target app is loaded, and we make sure the text we want to replace is expanded by clicking on the "show text"
button.
// 1. When a user opens the React application
appPage.bringToFront();
// 1.1. The user should see a button on the web page
const btn = await appPage.$('.action');
const btnText = await btn.evaluate(e => e.innerText);
expect(btnText).toEqual('click to show text');
// 2. Then the user clicks the button to display the text
await btn.click();
Following steps 3 and 4, we switch to the extension, set up a replacement word, and click the "replace"
button.
// 3. When the user goes to the chrome extension
await extPage.bringToFront();
// 4. When the user writes the word "music" and its replacement "**TEST**"
// in the extension and the user clicks on the "replace" button
const fromInput = await extPage.$('#from');
await fromInput.type('music');
const toInput = await extPage.$('#to');
await toInput.type('**TEST**');
const replaceBtn = await extPage.$('#replace');
await replaceBtn.click();
Last, in step 5, we verify the text is replaced in the target app.
// 5. When the user goes back to the website
appPage.bringToFront();
const textEl = await appPage.$('.text');
const text = await textEl.evaluate(e => e.innerText);
// 5.1. Then the user should see the string "**TEST**" on the page
expect(text).toEqual(expect.stringContaining('**TEST**'));
// 5.2 Then the user should no longer see the string "music" on the page
expect(text).toEqual(expect.not.stringContaining('music'));
Test full implementationโ
Toggle to see the full implementation
const { bootstrap } = require('./bootstrap');
describe('test text replacer extension with react app', () => {
let extPage, appPage, browser;
beforeAll(async () => {
const context = await bootstrap({ appUrl: 'http://localhost:3000'/*, slowMo: 50, devtools: true*/ });
extPage = context.extPage;
appPage = context.appPage;
browser = context.browser;
});
it('should render a button in the web application', async () => {
// 1. When a user opens the React application
appPage.bringToFront();
// 1.1. The user should see a button on the web page
const btn = await appPage.$('.action');
const btnText = await btn.evaluate(e => e.innerText);
expect(btnText).toEqual('click to show text');
// 2. Then the user clicks the button to display the text
await btn.click();
// 3. When the user goes to the chrome extension
await extPage.bringToFront();
// 4. When the user writes the word "music" and its replacement "**TEST**"
// in the extension and the user clicks on the "replace" button
const fromInput = await extPage.$('#from');
await fromInput.type('music');
const toInput = await extPage.$('#to');
await toInput.type('**TEST**');
const replaceBtn = await extPage.$('#replace');
await replaceBtn.click();
// 5. When the user goes back to the website
appPage.bringToFront();
const textEl = await appPage.$('.text');
const text = await textEl.evaluate(e => e.innerText);
// 5.1. Then the user should see the string "**TEST**" on the page
expect(text).toEqual(expect.stringContaining('**TEST**'));
// 5.2 Then the user should no longer see the string "music" on the page
expect(text).toEqual(expect.not.stringContaining('music'));
});
afterAll(async () => {
await browser.close();
});
});
Running the testโ
At last, let's run our test. We just need to make sure we're serving our react app and trigger the test run.
npm run start
npm run e2e
Note about the chrome extensionโ
If you look closely at the extension implementation, at the top of the popup.js file, we write some additional logic to ensure the extension can figure which browser to interact with. This is not an ideal solution but the only workaround that we know. Because we can not interact with the popup, we need to load the extension in a new tab, which adds the overhead on the application to be flexible enough to discover the proper tab, depending on the execution context. When running automation tests vs. when running in a production environment. We assume that during the puppeteer session, the target application will always be loaded in the tab with index 1
.
Conclusionโ
We hope this guide gets you started on your journey to test your chrome extension. You can find all this code and more in tweak-extension/puppeteer-test-browser-ext.
If you liked this article, consider sharing (tweeting) it to your followers.