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.

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.

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.

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.

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

const TIMEOUT = 60 * 1000;
const URL = "https://liveblocks.io/examples/todo-list?room=e2e-" + Date.now();
describe("liveblocks.io / todo-list example", () => { test( "creating a todo on page A should appear on page B", async () => { /** * By default, puppeteer.launch opens a window * with a single tab so we use it here. * It's also possible to open a new page and close it * for each test with beforeEach / afterEach. */ const pageA = await getFirstTab(browsers[0]); const pageB = await getFirstTab(browsers[1]);
for (const page of [pageA, pageB]) { // Navigate to the todo list on all browsers. await page.goto(URL); // Close an overlay that we have on all our examples await page.click("#close-example-info"); }
// The input appears once the todo list is loaded const input = await pageA.waitForSelector("input");
// Types some text and press enter to create a todo await input.type("Do a blog post about puppeteer & jest\\n", { delay: 50, // This slows down the typing });
// Validate text of the first todo expect(await getTextContent(pageB, "#todo-0")).toBe( "Do a blog post about puppeteer & jest" );
// waiting for the sake of the demo await wait(1000);
// cleanup await pageA.click("#delete-todo-0");
// waiting for the sake of the demo await wait(1000); }, TIMEOUT );});
async function getTextContent(page, selector) { const element = await page.waitForSelector(selector); return await element.evaluate((el) => el.textContent);}
async function getFirstTab(browser) { const [firstTab] = await browser.pages(); return firstTab;}
function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms));}

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

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

{  "scripts": {    "test": "jest"  }}

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.