-
Notifications
You must be signed in to change notification settings - Fork 9
E2E Testing the GUI with Playwright
We use Playwright for end-to-end testing during development and prior to deployment via our CI system.
Playwright uses similar semantics as Jest for unit tests, except the tests are executed in a headlesss browser environment. They usually consist of actions a typical user might take (like creating or deleting processes) and then use assertions on certain DOM nodes to check if the website functions as expected.
You can find the documentation for Playwright here and also some common best practices.
The following code snippet shows how a Playwright test might look like.
It encapsulates a test case with test('what is expected', async () => { /* test code */ })
.
The page
prop is used to navigate, use locators to find DOM nodes and execute user actions.
By wrapping a locator in expect()
you can assert that certain conditions are true and thus that the test should succeed.
import { test, expect } from '@playwright/test';
test('allows to add a new process', async ({ page }) => {
// Add a new process.
await page.goto('https://localhost:33083/#/process');
await page.getByTestId('directions').click(); // HTML element needs a data-testid="directions" attribute
await page.getByRole('button', { name: 'Add' }).click();
await page.getByLabel('Name*').fill('My Process');
await page.getByLabel('Description').fill('Very important process!');
await page.getByLabel('Department', { exact: true }).click();
await page.getByRole('option', { name: 'Marketing' }).locator('i').click();
await page.getByRole('option', { name: 'Purchasing' }).locator('i').click();
await page.locator('div').filter({ hasText: 'Add Process' }).first().click(); // just an example, locator() is not recommended to use since it relies on the DOM structure
await page.getByRole('button', { name: 'Add Process' }).click();
// Check the process editor was opened.
await expect(page).toHaveURL(/#\/process\/bpmn\/.*/);
await expect(page.getByText('My Process Latest Version')).toBeVisible();
await expect(page.locator('rect').first()).toBeVisible(); // locator() is not recommended to use since it relies on the DOM structure
// Check the new process is listed.
await page
.getByRole('tablist')
.filter({ hasText: 'My Process Clear tab bar' })
.getByRole('button')
.nth(1) // nth() is not recommended to use since it relies on the DOM structure
.click();
await expect(page.getByText('My Process')).toBeVisible();
await expect(page.getByRole('cell', { name: 'Marketing Purchasing' })).toBeVisible();
});
From:
...which element to use when multiple elements match, through locator.first(), locator.last(), and locator.nth(). These methods are not recommended because when your page changes, Playwright may click on an element you did not intend. Instead, follow best practices above to create a locator that uniquely identifies the target element.
Testing by test ids is the most resilient way of testing as even if your text or role of the attribute changes the test will still pass. QA's and developers should define explicit test ids and query them with page.getByTestId(). However testing by test ids is not user facing. If the role or text value is important to you then consider using user facing locators such as getByRole() and getByText() locators.
- it is good to use explicit IDs (
data-testid=""
) on the highest level- it is difficult to use IDs on reusable components
-
getByRole()
undgetByText()
should be used if we want to explicitly test the UI - it should have a reason (don't test every text on your page - test the functionality
When to use testid locators: You can also use test ids when you choose to use the test id methodology or when you can't locate by role or text.
CSS and XPath are not recommended as the DOM can often change leading to non resilient tests. Instead, try to come up with a locator that is close to how the user perceives the page such as role locators or define an explicit testing contract using test ids.
- That means
locator(...)
should not be used
All our tests should be located in the top-level tests
folder to encourage de-coupling from implementation details.
We create separate folders for each "page" in our application. That might be one menu item (e.g. "Tasklist") or a sufficiently complex part of the GUI to justify its own dedicated space for tests (e.g. the BPMN editor as part of the processes tab).
- tests
-
tasklist
- tasklist.page.ts
- tasklist.fixtures.ts
- tasklist.spec.ts
-
processes
- processes.page.ts
- processes.fixtures.ts
- processes.spec.ts
...
-
In general, the scaffolding for an e2e test consists of three files. The first is a file for commonly re-used actions and locators (tasklist.page.ts
above). This might look like the following example:
import { Locator, Page } from '@playwright/test';
export class TasklistPage {
readonly page: Page;
readonly emptyTasklist: Locator;
constructor(page: Page) {
this.page = page;
this.emptyTasklist = page.getByText('There are currently no tasks in your queue.');
}
async goto() {
await this.page.goto('/#/tasklist');
}
async addTask() {
const page = this.page;
// Start instance.
await page.getByRole('link', { name: 'Executions' }).click();
await page
.getByRole('row', { name: 'My Process' })
.getByRole('button', { name: 'Show Instances' })
.click();
await page.locator('span:nth-child(4) > .v-btn').first().click();
await page.getByRole('link', { name: 'Tasklist' }).click();
}
}
The second file is used to setup fixtures for our tests. Fixtures are helpful to re-use setup and clean-up code in multiple test files. They can also be composed to build more complex helpers while keeping the test files clean and readable. You can learn more about fixtures in the Playwright docs.
import { test as base } from '@playwright/test';
import { TasklistPage } from './tasklist.page';
// Declare the types of your fixtures.
type MyFixtures = {
tasklistPage: TasklistPage;
};
// Extend base test by providing "tasklistPage".
// This new "test" can be used in multiple test files, and each of them will get the fixtures.
export const test = base.extend<MyFixtures>({
tasklistPage: async ({ page }, use) => {
// Set up the fixture.
const tasklistPage = new TasklistPage(page);
await tasklistPage.createUsertaskProcess();
await tasklistPage.goto();
// Use the fixture value in the test.
await use(tasklistPage);
// Clean up the fixture.
await tasklistPage.removeAll();
},
});
export { expect } from '@playwright/test';
The fixture can then be used in the tasklist.spec.ts
file like page
in the example above. Here the actual tests are defined which ensure our tested parts of the application function as expected.
In order to develop and debug Playwright tests, we recommend installing the "Playwright Tests for VS Code" extension from within the extension store in VS Code.
This extension will add a "Testing" tab to the menu where you can see your Playwright files and tests. Each file and their individual tests can be run by clicking on the arrow next to their name. If the test succeeds, it will be marked with a green icon. If it fails, a red icon will be shown. Additionally, you can run the tests in debug mode by clicking the other arrow and make use of breakpoints.
At the bottom of the sidemenu there are multiple useful options. With "Pick locator" you can select an element in the browser window and see the corresponding locator, which will be copied to your clipboard.
With "Record at cursor" you can perform actions in the browser window and Playwright will record them at your current position in the test file. This is useful to avoid tedious manual writing of locators and actions.
The best practices site of the Playwright docs provide useful hints for developing good end-to-end tests. Especially the section on test isolation and preferring longer tests to a lot of short ones are principles we try to follow in our tests.