I recently
[tweeted](https://twitter.com/guillaume_slls/status/1487131338935312390) a demo
of one of our e2e tests, and some people were curious to know how it worked. So
here we are!

<Element>
  <Tweet id="1487131338935312390" />
</Element>

For simplicity’s sake, I will show you how to test a basic realtime todo app,
one of our [examples](/examples).

<Figure>
  <Image
    src="/images/blog/todo-list-e2e.gif"
    alt="E2E test of multiplayer todo list"
    width={1280}
    height={480}
    quality={70}
  />
</Figure>

Before we jump into the code, I want to explain why we chose
[Puppeteer](https://github.com/puppeteer/puppeteer) and
[Jest](https://jestjs.io/) 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](https://github.com/liveblocks/liveblocks/tree/d6d6b23e6ce3a00fd445eaa7bb609e073a02e03d/e2e/demo).

## Why not Cypress?

Usually, when I have to write e2e web tests, my go-to tool is
[Cypress](https://www.cypress.io/). 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](https://docs.cypress.io/guides/references/trade-offs#Permanent-trade-offs)
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](https://playwright.dev/) 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.

```bash
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.

```js file="jest.config.js" isCollapsable isCollapsed
module.exports = {
  globalSetup: "./setup.js",
  testEnvironment: "./puppeteer_environment.js",
  // No need to reference "test/todo-list.test.js"
  // because it's detected by the default value of testRegex
  // https://jestjs.io/docs/configuration#testregex-string--arraystring
  globalTeardown: "./teardown.js",
};
```

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.

```js file="setup.js" isCollapsable isCollapsed
const { mkdir, writeFile } = require("fs").promises;
const os = require("os");
const path = require("path");
const puppeteer = require("puppeteer");

const DIR = path.join(os.tmpdir(), "jest_puppeteer_global_setup");
const NUMBER_OF_BROWSERS = 2;
const WIDTH = 640;
const HEIGHT = 480;

module.exports = async function () {
  const browsers = [];

  // Launch browsers side to side
  for (let i = 0; i < NUMBER_OF_BROWSERS; i++) {
    const browser = await puppeteer.launch({
      defaultViewport: null,
      headless: false,
      // Chrome additional arguments to set browser size and position
      args: [
        \`--window-size=\${WIDTH},\${HEIGHT}\`,
        \`--window-position=\${WIDTH * i},0\`,
      ],
    });

    browsers.push(browser);
  }

  // use the file system to expose the browsers wsEndpoint for TestEnvironments
  await mkdir(DIR, { recursive: true });
  await writeFile(
    path.join(DIR, "wsEndpoints"),
    browsers.map((browser) => browser.wsEndpoint()).join("\\n")
  );

  // store all browser instances so we can teardown them later
  // this global is only available in the teardown but not in TestEnvironments
  global.__BROWSERS__ = browsers;
};
```

Then we create a
[custom test environment](https://jestjs.io/docs/configuration#testenvironment-string)
to connect to the browsers we just started. And we expose the browsers to the
test suite with a global variable.

```js file="puppeteer_environment.js" isCollapsable isCollapsed
const { readFile } = require("fs").promises;
const os = require("os");
const path = require("path");
const puppeteer = require("puppeteer");
const NodeEnvironment = require("jest-environment-node");

const DIR = path.join(os.tmpdir(), "jest_puppeteer_global_setup");

class PuppeteerEnvironment extends NodeEnvironment {
  constructor(config, context) {
    super(config, context);
  }

  async setup() {
    await super.setup();

    const wsEndpoints = await readFile(path.join(DIR, "wsEndpoints"), "utf8");

    this.global.browsers = [];

    for (const wsEndpoint of wsEndpoints.split("\\n")) {
      // Connect puppeteer to the browsers we created during the global setup
      this.global.browsers.push(
        await puppeteer.connect({
          browserWSEndpoint: wsEndpoint,
          defaultViewport: null,
        })
      );
    }
  }
}

module.exports = PuppeteerEnvironment;
```

Now, the actual test. We use Puppeteers
[APIs](https://github.com/puppeteer/puppeteer/blob/main/docs/api.md) to interact
with the browsers/pages and then we use Jest to do the assertions.

```js file="test/todo-list.test.js" isCollapsable
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.

```js file="teardown.js" isCollapsable isCollapsed
const os = require("os");
const path = require("path");
const rimraf = require("rimraf");

const DIR = path.join(os.tmpdir(), "jest_puppeteer_global_setup");
module.exports = async function () {
  // Close all browsers
  for (const browser of global.__BROWSERS__) {
    await browser.close();
  }
  // clean-up the temporary file used to write the browsers wsEndpoints
  rimraf.sync(DIR);
};
```

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

```json
{
  "scripts": {
    "test": "jest"
  }
}
```

And execute it with:

```bash
npm test
```

And voilà!

<Figure>
  <Image
    src="/images/blog/todo-list-e2e.gif"
    alt="E2E test of multiplayer todo list"
    width={1280}
    height={480}
    quality={70}
  />
</Figure>

## 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](https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#puppeteerlaunchoptions),
simulate slow
[network](https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#pageemulatenetworkconditionsnetworkconditions)/[CPU](https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#pageemulatecputhrottlingfactor),
[take screenshots](https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#pagescreenshotoptions)
(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](https://twitter.com/guillaume_slls/status/1442841522202886146)
to find edge cases in various environments.

<Element>
  <Tweet id="1442841522202886146" />
</Element>

If you’re interested in knowing more about our engineering practices, please let
us know on [Github](https://github.com/liveblocks/liveblocks) or
[Twitter](https://twitter.com/liveblocks), or even better,
[apply to one of our open positions](https://join.team/liveblocks).