Setting up e2e tests in Nx monorepo with Playwright
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.
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:
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.