Guides

How to set up end-to-end tests for multiplayer apps using Puppeteer and Jest

Learn how to set up end-to-end tests with multiple browser windows using Puppeteer and Jest.

February 4th, 2022
Picture of Guillaume Salles
Guillaume Salles
@guillaume_slls
How to set up end-to-end tests for multiplayer apps using Puppeteer and Jest

I recently tweeted a demo of one of our e2e tests, and some people were curious to know how it worked. So here we are!

For simplicity’s sake, I will show you how to test a basic real‑time todo app, one of our examples.

E2E test of multiplayer todo list

Before we jump into the code, I want to explain why we chose Puppeteer and Jest instead of other popular e2e test solutions. If you only want to see how it works, you can skip the following sections or directly look at the code on Github.

Why not Cypress?

Usually, when I have to write e2e web tests, my go-to tool is Cypress. If you’re not familiar with Cypress, it’s a complete solution to write/record/debug/run e2e tests with a slick API/interface. It’s a great open-source tool overall.

However, Cypress has a significant limitation that I can’t ignore in the context of Liveblocks. Cypress made the permanent trade-off of not supporting multiple tabs/browsers. This trade-off is understandable and fair! Supporting multiple tabs/browsers could negatively impact API surface/developer experience for the common e2e tests.

Because we’re building APIs for collaboration, 99% of the end-to-end tests we do involve multiple browsers. Mocking is not an option because we want to go through all our infrastructure.

Why not Playwright?

I didn’t know it existed before I started writing e2e tests at Liveblocks 🙃 I never tried it, so I don’t have any opinion. Playwright supports a multi-browsers environment, so it would be a high contender for refactoring if one day Puppeteer & Jest are not enough!

Why Puppeteer & Jest?

Puppeteer is a node package that lets you control Chrome (or Firefox). It’s not made to write end-to-end tests, so you have to use it with the test framework of your choice. I decided to go with Jest because we use it for unit/integration tests.

Enough about the “Why”, show me the code!

First of all, we install a few dependencies.

npm install jest puppeteer jest-environment-node

The testing process is pretty simple. Every step has its own file that we reference in the jest.config.js.


  1. setup.js - Start a few browsers before launching any tests.
  2. puppeteer_environment.js - Create a jest testing environment that will expose the browsers to a test suite. This is where we use jest-environment-node.
  3. test/todo-list.test.js - Navigate to the URL you want to test and validate that everything works as expected.
  4. teardown.js - Close the browsers once all the tests are over.

jest.config.js

1
module.exports = {
2
globalSetup: "./setup.js",
3
testEnvironment: "./puppeteer_environment.js",
4
// No need to reference "test/todo-list.test.js"
5
// because it's detected by the default value of testRegex
6
// https://jestjs.io/docs/configuration#testregex-string--arraystring
7
globalTeardown: "./teardown.js",
8
};

In setup.js, we’re starting multiple browsers before executing any test suite. To keep it simple, the code below launches two instances of Chrome side to side. It should be relatively easy to update the code to change the number of browsers and their positions.

Puppeteer controls Chrome and Firefox via a websocket. Once the browsers are ready, we write these websocket endpoints in a temporary file that will be read from the test environment.

setup.js

1
const { mkdir, writeFile } = require("fs").promises;
2
const os = require("os");
3
const path = require("path");
4
const puppeteer = require("puppeteer");
5
6
const DIR = path.join(os.tmpdir(), "jest_puppeteer_global_setup");
7
const NUMBER_OF_BROWSERS = 2;
8
const WIDTH = 640;
9
const HEIGHT = 480;
10
11
module.exports = async function () {
12
const browsers = [];
13
14
// Launch browsers side to side
15
for (let i = 0; i < NUMBER_OF_BROWSERS; i++) {
16
const browser = await puppeteer.launch({
17
defaultViewport: null,
18
headless: false,
19
// Chrome additional arguments to set browser size and position
20
args: [
21
`--window-size=${WIDTH},${HEIGHT}`,
22
`--window-position=${WIDTH * i},0`,
23
],
24
});
25
26
browsers.push(browser);
27
}
28
29
// use the file system to expose the browsers wsEndpoint for TestEnvironments
30
await mkdir(DIR, { recursive: true });
31
await writeFile(
32
path.join(DIR, "wsEndpoints"),
33
browsers.map((browser) => browser.wsEndpoint()).join("\n")
34
);
35
36
// store all browser instances so we can teardown them later
37
// this global is only available in the teardown but not in TestEnvironments
38
global.__BROWSERS__ = browsers;
39
};

Then we create a custom test environment to connect to the browsers we just started. And we expose the browsers to the test suite with a global variable.

puppeteer_environment.js

1
const { readFile } = require("fs").promises;
2
const os = require("os");
3
const path = require("path");
4
const puppeteer = require("puppeteer");
5
const NodeEnvironment = require("jest-environment-node");
6
7
const DIR = path.join(os.tmpdir(), "jest_puppeteer_global_setup");
8
9
class PuppeteerEnvironment extends NodeEnvironment {
10
constructor(config, context) {
11
super(config, context);
12
}
13
14
async setup() {
15
await super.setup();
16
17
const wsEndpoints = await readFile(path.join(DIR, "wsEndpoints"), "utf8");
18
19
this.global.browsers = [];
20
21
for (const wsEndpoint of wsEndpoints.split("\n")) {
22
// Connect puppeteer to the browsers we created during the global setup
23
this.global.browsers.push(
24
await puppeteer.connect({
25
browserWSEndpoint: wsEndpoint,
26
defaultViewport: null,
27
})
28
);
29
}
30
}
31
}
32
33
module.exports = PuppeteerEnvironment;

Now, the actual test. We use Puppeteers APIs to interact with the browsers/pages and then we use Jest to do the assertions.

test/todo-list.test.js

1
const TIMEOUT = 60 * 1000;
2
3
const URL = "https://liveblocks.io/examples/todo-list?room=e2e-" + Date.now();
4
5
describe("liveblocks.io / todo-list example", () => {
6
test(
7
"creating a todo on page A should appear on page B",
8
async () => {
9
/**
10
* By default, puppeteer.launch opens a window
11
* with a single tab so we use it here.
12
* It's also possible to open a new page and close it
13
* for each test with beforeEach / afterEach.
14
*/
15
const pageA = await getFirstTab(browsers[0]);
16
const pageB = await getFirstTab(browsers[1]);
17
18
for (const page of [pageA, pageB]) {
19
// Navigate to the todo list on all browsers.
20
await page.goto(URL);
21
// Close an overlay that we have on all our examples
22
await page.click("#close-example-info");
23
}
24
25
// The input appears once the todo list is loaded
26
const input = await pageA.waitForSelector("input");
27
28
// Types some text and press enter to create a todo
29
await input.type("Do a blog post about puppeteer & jest\n", {
30
delay: 50, // This slows down the typing
31
});
32
33
// Validate text of the first todo
34
expect(await getTextContent(pageB, "#todo-0")).toBe(
35
"Do a blog post about puppeteer & jest"
36
);
37
38
// waiting for the sake of the demo
39
await wait(1000);
40
41
// cleanup
42
await pageA.click("#delete-todo-0");
43
44
// waiting for the sake of the demo
45
await wait(1000);
46
},
47
TIMEOUT
48
);
49
});
50
51
async function getTextContent(page, selector) {
52
const element = await page.waitForSelector(selector);
53
return await element.evaluate((el) => el.textContent);
54
}
55
56
async function getFirstTab(browser) {
57
const [firstTab] = await browser.pages();
58
return firstTab;
59
}
60
61
function wait(ms) {
62
return new Promise((resolve) => setTimeout(resolve, ms));
63
}

Finally, we close all the browsers after the tests are over.

teardown.js

1
const os = require("os");
2
const path = require("path");
3
const rimraf = require("rimraf");
4
5
const DIR = path.join(os.tmpdir(), "jest_puppeteer_global_setup");
6
module.exports = async function () {
7
// Close all browsers
8
for (const browser of global.__BROWSERS__) {
9
await browser.close();
10
}
11
// clean-up the temporary file used to write the browsers wsEndpoints
12
rimraf.sync(DIR);
13
};

Now, you only need to run jest to execute your test. Add the following script in your package.json.

1
"scripts": {
2
"test": "jest"
3
},

And execute it with:

npm test

And voilà!

E2E test of multiplayer todo list

Conclusion

At this point, you probably have a good idea about how Puppeteer & Jest can work together to test multi-player apps. Puppeteer is a powerful tool; it’s also possible to launch Firefox, simulate slow network/CPU, take screenshots (for UI testing), and much more.

At Liveblocks, we’re running e2e tests before every deployment/release to ensure that all our components work well together. We also do e2e fuzz testing to find edge cases in various environments.

If you’re interested in knowing more about our engineering practices, please let us know on Github or Twitter, or even better, apply to one of our open positions.