Writing Playwright Tests
Learning objectives
- You know how to write end-to-end tests with Playwright.
- You know of hydration and how it can affect testing.
Let's next look into writing Playwright tests. As the application that we wish to test, we use the book management application that we worked on in the previous chapter. When considering what to test, one can consider the functionalities of the application. In our case, there would be at least the following scenarios that we would wish to test: (1) opening up the page, (2) creating a book, and (3) viewing a book (the listing of books is implicit). Let's start by writing a test that opens up the page.
Opening up the page
Let's start by writing a test that opens up the page and verifies that the page contains a heading with the text "Books". For the checks, we will use on Playwright Assertions. As a starting point, we have a test that goes to the main page using await page.goto("/")
.
const { test, expect } = require("@playwright/test");
test("Application has heading 'Books!'.", async ({ page }) => {
await page.goto("/");
});
When we run the tests with the command docker compose run --rm --entrypoint=npx playwright playwright test
, we see that the test passes. This indicates that the server is running and that the page can be opened. Let's next add the functionality to check that the page has a heading with the text "Books". For this, we use a locator that is used to search for specific content. We rely on a locator that searches for an element with a specific role, which is a way to identify elements that are used for a specific purpose. In our case, we wish to find a heading with the role heading
and the name Books
. This is written as page.getByRole("heading", { name: "Books" })
. To check that the page has a single element with the given role and name, we use expect(locator).toHaveCount(expected). As we want that the page has a single heading with the text "Books", the assertion is written as expect(page.getByRole("heading", { name: "Books" })).toHaveCount(1)
.
const { test, expect } = require("@playwright/test");
test("Application has heading 'Books'.", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Books" })).toHaveCount(1);
});
Now, when we run the test, we see that it passes.
Creating a book
Let's continue with creating a book. We start by writing a new test, where we enter the page. To check that a book is created, we first create a book, and then check that the book is listed on the page. When creating a new book, we identify the input fields on the page based on their label using a getByLabel locator, and then fill the specific input field using the fill function. We also use randomness in the name, as we are running the tests against an application that is not shut between the tests. Filling in the form fields with random values would be done in the following "Creating a book." test.
const { test, expect } = require("@playwright/test");
test("Application has heading 'Books'.", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Books" })).toHaveCount(1);
});
test("Creating a book.", async ({ page }) => {
await page.goto("/");
const randomName = `Book ${10000 + Math.floor(Math.random() * 90000)}`;
const randomPages = `${Math.floor(Math.random() * 1000)}`;
const randomIsbn = `${Math.floor(Math.random() * 10000000000000)}`;
await page.getByLabel("Book name:").fill(randomName);
await page.getByLabel("Number of pages:").fill(randomPages);
await page.getByLabel("ISBN:").fill(randomIsbn);
});
When we run the test, the test again passes. The test for creating a book does not really add the book, as we do not click on the button that is used to submit the form. Let's add that next. To click on the button, we use the click function of the locator. As the button does not have a label, we use a getByRole locator to identify the button. The test for creating a book is now as follows.
test("Creating a book.", async ({ page }) => {
await page.goto("/");
const randomName = `Book ${10000 + Math.floor(Math.random() * 90000)}`;
const randomPages = `${Math.floor(Math.random() * 1000)}`;
const randomIsbn = `${Math.floor(Math.random() * 10000000000000)}`;
await page.getByLabel("Book name:").fill(randomName);
await page.getByLabel("Number of pages:").fill(randomPages);
await page.getByLabel("ISBN:").fill(randomIsbn);
await page.getByRole("button", { name: "Add" }).click();
});
When the tests are run, they still work.
The next step is to look up the book that was created. The book appears in the list, so we can look up for a list element that has the text of the book. To do this, we use a locator to search for li
elements, and filter the results based on text within the li
element. Now, the tests is as follows.
test("Creating a book.", async ({ page }) => {
await page.goto("/");
const randomName = `Book ${10000 + Math.floor(Math.random() * 90000)}`;
const randomPages = `${Math.floor(Math.random() * 1000)}`;
const randomIsbn = `${Math.floor(Math.random() * 10000000000000)}`;
await page.getByLabel("Book name:").fill(randomName);
await page.getByLabel("Number of pages:").fill(randomPages);
await page.getByLabel("ISBN:").fill(randomIsbn);
await page.getByRole("button", { name: "Add" }).click();
await expect(page.locator("li").filter({ hasText: randomName })).toHaveCount(1);
});
When the tests are run, we see that the test for creating a book fails.
A brief story about hydration
When a browser loads a client-side application, it first loads the HTML and then the included JavaScript code. The JavaScript code is then executed, which can alter the shown page. Hydration refers to attaching functionality to the existing HTML elements. This can cause problems when testing the application, as content that is initially shown on the page might change.
The reason for the failure is that the content shown to the user might not be ready for interaction. Playwright is running the tests as fast as it can, and it does not wait for the JavaScript code to be executed. In our case, it identifies the input elements before hydration, which leads to the situation that the values typed into the elements are not registered, and adding the book fails.
To fix this, we need to wait for the page to be reloaded. To do this, we first try the waitForTimeout function of the page to wait for one second before we start running the tests. With this change, the tests look now as follows.
test("Creating a book.", async ({ page }) => {
await page.goto("/");
const randomName = `Book ${10000 + Math.floor(Math.random() * 90000)}`;
const randomPages = `${Math.floor(Math.random() * 1000)}`;
const randomIsbn = `${Math.floor(Math.random() * 10000000000000)}`;
await page.waitForTimeout(1000);
await page.getByLabel("Book name:").fill(randomName);
await page.getByLabel("Number of pages:").fill(randomPages);
await page.getByLabel("ISBN:").fill(randomIsbn);
await page.getByRole("button", { name: "Add" }).click();
await expect(page.locator("li").filter({ hasText: randomName })).toHaveCount(1);
});
Now, when we run the tests, the tests pass.
Wait for a second..
The second is arbitrary and waiting for a specific amount of time is not a good idea. A better approach would be to wait for a specific element to appear on the page. As we wish to know that hydration has happened, one possible location is the end of the onMount
call in +layout.svelte
. Modify the +layout.svelte
to match the following.
<script>
import { onMount } from 'svelte';
import { initBooks } from '$lib/stores/books.svelte.js';
onMount(() => {
initBooks();
document.body.classList.add('ready-for-testing');
});
</script>
<slot />
Now, when the page is loaded, we load the books and add a class to the body element. We can use this class to wait for the page to be loaded. To do this, we use the waitFor functionality of locator. The function takes a selector as a parameter; the selector is used to identify the element that we wish to wait for. In our case, we wish to wait for the body element to have the class ready-for-testing
-- a selector for it would be written as .ready-for-testing
. After the modification, the test is now as follows.
test("Creating a book.", async ({ page }) => {
await page.goto("/");
const randomName = `Book ${10000 + Math.floor(Math.random() * 90000)}`;
const randomPages = `${Math.floor(Math.random() * 1000)}`;
const randomIsbn = `${Math.floor(Math.random() * 10000000000000)}`;
await page.locator(".ready-for-testing").waitFor();
await page.getByLabel("Book name:").fill(randomName);
await page.getByLabel("Number of pages:").fill(randomPages);
await page.getByLabel("ISBN:").fill(randomIsbn);
await page.getByRole("button", { name: "Add" }).click();
await expect(page.locator("li").filter({ hasText: randomName })).toHaveCount(1);
});
Viewing a book
The next step is to test that viewing a book works. We can take the above adding a book as a starting point for the new test, as we wish to view a specific book.
test("Viewing a book.", async ({ page }) => {
await page.goto("/");
const randomName = `Book ${10000 + Math.floor(Math.random() * 90000)}`;
const randomPages = `${Math.floor(Math.random() * 1000)}`;
const randomIsbn = `${Math.floor(Math.random() * 10000000000000)}`;
await page.locator(".ready-for-testing").waitFor()
await page.getByLabel("Book name:").fill(randomName);
await page.getByLabel("Number of pages:").fill(randomPages);
await page.getByLabel("ISBN:").fill(randomIsbn);
await page.getByRole("button", { name: "Add" }).click();
await expect(page.locator("li").filter({ hasText: randomName })).toHaveCount(1);
});
We could also extract the adding functionality to a separate function. However, following the rule of three, this is not yet needed.
Let's continue building the test. The next step is to identify the button next to the added book. To do this, we must identify the li
element that contains the specific book, and then identify the button within the li
element. We've previously used locator
with filter
to identify a specific list item, and getByRole
to identify a specific button.
Playwright allows chaining locators, so we can combine what we have used so far. The following looks for the specific list element, and then looks for a button within the list element, and finally clicks the button.
await page.locator("li").filter({ hasText: randomName }).getByRole("button", { name: "View" }).click();
When the button has been clicked, we can check that the book details are present. For this, we can use a getByText locator that allows identifying elements based on text; we would combine it with an expectation that there is one such element.
As a whole, the test would look as follows.
test("Viewing a book.", async ({ page }) => {
await page.goto("/");
const randomName = `Book ${10000 + Math.floor(Math.random() * 90000)}`;
const randomPages = `${Math.floor(Math.random() * 1000)}`;
const randomIsbn = `${Math.floor(Math.random() * 10000000000000)}`;
await page.locator(".ready-for-testing").waitFor()
await page.getByLabel("Book name:").fill(randomName);
await page.getByLabel("Number of pages:").fill(randomPages);
await page.getByLabel("ISBN:").fill(randomIsbn);
await page.getByRole("button", { name: "Add" }).click();
await expect(page.locator("li").filter({ hasText: randomName })).toHaveCount(1);
await page.locator("li").filter({ hasText: randomName }).getByRole("button", { name: "View" }).click();
await expect(page.getByText(`Name: ${randomName}`)).toHaveCount(1);
});
Now, when we run the tests, they again pass.
What to test?
The key functionality that users of the application will rely on needs to be tested. Having automated tests in place allows modifying the application with a relative peace of mind, as there are safeguards in place that highlight if something has been broken. Aalto University offers also specific testing courses that go significantly deeper into the topic.