Setting up e2e tests in Nx monorepo with Playwright

Piotr Pliszko

My blog is, by today's standards, a moderately simple app - Next.js, MDX, and a few custom components here and there - but as most modern apps in JS/TS ecosystem, it relies on multiple dependencies. One of the biggest challenges is upgrading all those dependencies periodically without breaking anything in the process. I've managed this for a few years but as I add more content to the blog the chance of breaking something increases. Because of that, I've decided to add some e2e tests to decrease the chance of breaking anything without noticing. I will create this setup with Playwright as this is currently the most reliable solution for e2e tests in my opinion.

I have a few goals that I want to achieve with my e2e setup:

  • It must be quick to set up and simple to maintain.
  • It must be a separate e2e project in the same monorepo as my webpage.
  • At the beginning tests will run locally, but in the future, it will be a part of a CI workflow.
  • I want to take snapshots of all blog posts to see if anything visual changes after the dependency upgrade.

This post's goal is to demonstrate how to create a simple Playwright setup in nx monorepo with a few example tests. With these goals in mind, let's go through the setup.

Installation

My project lives in nx monorepo, so I'm using nx generator to setup Playwright environment. If you are using a different setup follow instructions specific to it or set it up manually.

First, I will install @nx/playwright package using nx add:

npx nx add @nx/playwright

Setting up a separate project

As I want to keep my e2e tests in a separate project, I will create one before I set up tests. First, create an empty project called e2e inside of apps directory. In the project directory create project.json file with the following content:

{
  "name": "e2e"
}

It's the only necessary thing that nx needs to pick up our project. Now we can run a generator to create sample config and files draft in our new e2e project:

npx nx g @nx/playwright:configuration --project=e2e

After running the command we can see that a few files and directories appeared, and our project.json file was populated with the project configuration. Now it should look similar to this:

{
  "name": "e2e",
  "$schema": "../../node_modules/nx/schemas/project-schema.json",
  "targets": {
    "e2e": {
      "executor": "@nx/playwright:playwright",
      "outputs": ["{workspaceRoot}/dist/.playwright/apps/e2e"],
      "options": {
        "config": "apps/e2e/playwright.config.ts"
      }
    },
    "lint": {
      "executor": "@nx/eslint:lint"
    }
  }
}

Playwright configuration

Before we start writing some tests let's take a look at the Playwright config. You can find it in a playwright.config.ts file.

First, let's update baseURL to match the port used by the blog app:

const baseURL = process.env['BASE_URL'] || 'http://localhost:4772';

Next, setup web server configuration:

webServer: {
  command: 'npm run start',
  port: 4772,
  reuseExistingServer: !process.env.CI,
  cwd: workspaceRoot,
},

I also commented out browsers other than chromium for now - maybe in the future I will also enable them, but for now, for the sake of simplicity, I've decided to stick with only one browser.

Snapshots

As one of the main goals for my setup is to take and compare snapshots of blog posts, let's create some utilities. In the main e2e project root I've created utils directory, which will contain screenshot-utils.ts file with the following content:

import { Page, expect } from '@playwright/test';

export const matchScreenshot = async (page: Page, selector: string, fileName: string): Promise<void> => {
  if (snapshotsDisabled()) {
    console.log('Snapshots are disabled. Skipping...');
    return;
  }

  await expect.soft(page.locator(selector)).toHaveScreenshot(`${fileName}.png`, {
    animations: 'disabled',
    maxDiffPixelRatio: 0.01,
  });
};

const snapshotsDisabled = (): boolean => {
  const disableSnapshots = process.env.DISABLE_SNAPSHOTS;
  return disableSnapshots === 'true' || disableSnapshots === '1';
};

I've added an ability to run Playwright with a DISABLE_SNAPSHOTS flag to be able to easily disable snapshots and run only other checks. It's useful if snapshots were generated on the other platform and will fail when running locally, but we still want to run all other checks.

Cross-platform snapshots

The proper solution for multiplatform snapshots would be to create a Docker container and run tests in it. This way, we will always generate and compare snapshots in the unified environment, without any rendering differences between various OSes. In this post, I'm focusing on a really simple setup but in the separate post, I will show how to implement an environment like that.

Writing e2e tests

Finally, we can start writing some e2e tests! To start with something simple, let's create a test for a blog page which will check for a page title to match the expected title. In the project root let's create an e2e directory, and in it let's create a blog-page.spec.ts file.

import { expect, test } from '@playwright/test';

test.describe('Blog Page', () => {
  test('has title', async ({ page }) => {
    await page.goto('/blog');

    // expect a title
    await expect(page.locator('h1')).toHaveText('Posts');
  });
});

We can also add another check below to see if the page displays correct number of posts:

test('should show 5 articles', async ({ page }) => {
  await page.goto('/blog');

  // expect 5 articles
  await expect(page.locator('article')).toHaveCount(5);
});

Now, we can create some simple tests with snapshots. Let's create a posts.spec.ts file and add a sample test:

import { expect, test } from '@playwright/test';
import { matchScreenshot } from '../utils/screenshot-utils';

test.describe('Posts', () => {
  test('XOR effect in CSS', async ({ page }) => {
    await page.goto('/blog/post/2024-04-13-xor-effect-in-css');

    // expect a title
    await expect(page.locator('h1').first()).toHaveText('XOR effect in CSS');

    // check snapshot
    await matchScreenshot(page, 'body', '2024-04-13-xor-effect-in-css');
  });
});

The first run of this test will fail because the screenshot did not exist (so matching failed), but it will generate a missing snapshot. We can find snapshots in the generated directory next to the tests file, called posts.spec.ts-snapshots. The second run should pass correctly!

Now, let's just add a script to the package.json file to easily run e2e tests:

// ...
"e2e": "nx run e2e:e2e",
// ...

And now we should be able to run our tests.

Updating snapshots

What happens if I change something intentionally that will result in a visual change? We need to tell Playwright to update snapshots. To do so, use an --update-snapshots flag like this:

# pass to npm script (remember about `--` to pass down flag)
npm run e2e -- --update-snapshots

# or pass to nx directly
nx run e2e:e2e --update-snapshots

After updating snapshots just review them manually if they match the expected result, and commit them to your branch.

Easy test development with UI mode

Playwright has a very useful mode called UI mode, which allows us to easily develop and debug tests, see the preview, easily experiment with locators, inspect network and console and much more.

Let's add one more script to the package.json file:

"e2e:ui": "nx run e2e:e2e -- --ui"

This is how UI mode looks like:

Playwright UI mode

Summary

In this post, I've created a very simple Playwright setup in nx monorepo to test if nothing unexpected changes without my knowledge. Of course, those few example tests are not enough to give me more confidence during dependency upgrades - I need to create multiple other tests to verify other blog posts and pages. We are not going to cover more detailed examples here - please refer Playwright docs for more details on how to write tests.

Also, as mentioned above, it's not a bulletproof solution in terms of the environment I take my snapshots in. In a separate post I will show you how to create a Dockerized environment to take and compare snapshots in consistent env.

Resources

If you have any questions or feedback, feel free to reach out to me on GitHub, X or LinkedIn!