With End-to-End testing (E2E), we test a fully working application where different parts of our application work together in real-life scenarios from start to finish. The End-to-End tests might also act as regression tests that check if our latest changes haven’t broken any previous features. We might also use them as smoke tests that ensure that the new version of our application is functional and that the crucial functionalities work as expected.
Thet term “smoke testing” is an analogy to electronics. It refers to the first moments of powering up the circuit and making sure that no smoke is coming from the device.
Introducing Playwright
When performing End-to-End tests on a frontend application, we simulate how real users interact with its interface. To do that, we can use Playwright, a framework that allows us to write automated tests that interact with our application through a web browser.
The most straightforward way to start working with Playwright is to run the npm init playwright@latest command in a new, empty directory. It will create a new project with Playwright, but it will first ask us a few questions. For example, it will ask if we want to install the dependencies necessary to run the tests in the simulated browser environment.
If you have the latest version of Ubuntu (23.10), you’re out of luck – Playwright does not support it. I had to manually download the libicu70 and libffi7 deb packages and even compile libx264-163 from source code.
The Playwright framework also asks us what the name of the directory is. By default, it is called tests.
Writing our first test
Playwright is built to resemble other JavaScript testing frameworks and provides functions such as describe and test that let us structure our tests.
1import { expect, test } from '@playwright/test';
2
3test.describe('The home page', () => {
4 test.describe('when visited', () => {
5 test('should contain the correct title', async ({ page }) => {
6 await page.goto('https://wanago.io');
7
8 await expect(page).toHaveTitle(/Wanago/);
9 });
10 });
11});For each test, Playwright creates an instance of a page that provides methods to interact with a single tab in the browser.
In our test, we start by navigating to a particular page using the page.goto() function. Since it interacts with the browser, it is asynchronous. Therefore, we should wait for it to finish using the await keyword.
Then, we use the expect function to check if the page meets a particular condition. The toHaveTitle function is asynchronous and waits until the page has a specific title. Because of that, we need to use the await keyword.
Using environment variables
If all of our tests focus on a particular web page, we can add its URL to the Playwright configuration generated when we ran the npm init playwright@latest command.
1import { defineConfig } from '@playwright/test';
2import 'dotenv/config';
3
4export default defineConfig({
5 use: {
6 /* Base URL to use in actions like `await page.goto('/')`. */
7 baseURL: 'https://wanago.io',
8 // ...
9 },
10
11 // ...
12});Thanks to that, we don’t need to type it explicitly in our tests. Instead, we can provide a path relative to the baseURL we provided in the configuration.
1import { expect, test } from '@playwright/test';
2
3test.describe('The home page', () => {
4 test.describe('when visited', () => {
5 test('should contain the correct title', async ({ page }) => {
6 await page.goto('/');
7
8 await expect(page).toHaveTitle(/Wanago/);
9 });
10 });
11});There is a good chance that we will want to run our tests in various environments. When developing the application locally, we might want to point to localhost. In other cases, we will want to use a real, deployed application. To achieve this, we should use environment variables that we can configure per environment.
First, let’s create the .env file containing our application’s URL.
1BASE_URL=https://wanago.ioA good practice is to avoid commiting the .env file to the repository because it might sometimes contain sensitive invormation.
A very straightforward way to ensure our application loads the above file is to use the dotenv library.
1npm install dotenv1import { defineConfig } from '@playwright/test';
2import 'dotenv/config';
3
4/**
5 * See https://playwright.dev/docs/test-configuration.
6 */
7export default defineConfig({
8 use: {
9 /* Base URL to use in actions like `await page.goto('/')`. */
10 baseURL: process.env.BASE_URL,
11 // ...
12 },
13 // ...
14});Now, whenever we want to change the URL of the application we are testing, we can modify the .env file.
Running our tests
One way to run our tests is through the UI mode. It lets us choose which tests to run and in what browsers. We can also see Playwright interact with our website in real-time and ensure the tests run as expected.
1npx playwright test --uiWe can also run our tests in the headless mode. With this approach, no browser windows are opened, and we can see the results in the terminal. By default, the tests will run on multiple browsers.
1npx playwright testInteracting with the page
Playwright can interact with the website in many different ways. To interact with an element on our page, we should first find it using the Locators API built into Playwright. Let’s find the search input on https://wanago.io.
1const searchInput = page.getByPlaceholder('Search …');Now, we fill the input with text. It’s asynchronous because Playwright waits for the element to be actionable before interacting with it.
1const searchQuery = 'JavaScript';
2await searchInput.fill(searchQuery);We can submit the form by pressing the enter key.
1await page.keyboard.press('Enter');Finally, we can check if submitting the form caused the search results to appear.
1import { expect, test } from '@playwright/test';
2
3test.describe('When the user visits the page', () => {
4 test.describe('and fills the search input with a valid string', () => {
5 test.describe('and submits it', () => {
6 test('it should display the search term at the top of the page', async ({ page }) => {
7 await page.goto('/');
8
9 const searchQuery = 'JavaScript';
10
11 await page.getByPlaceholder('Search …').fill(searchQuery);
12
13 await page.keyboard.press('Enter');
14
15 await expect(page.getByText(`Search Results for: ${searchQuery}`)).toBeVisible();
16 })
17 })
18 });
19});The toBeVisible() function is asynchronous because Playwright waits until the element we are looking for appears on the page.
Playwright waits a maximum of 5 seconds by default.
Besides checking if there is a particular piece of text on the page, there are multiple other assertions we can peform.
Organizing our tests
If we have multiple related tests in one file, there is a high chance that they will become repetitive.
1import { expect, test } from '@playwright/test';
2
3test.describe('When the user visits the page', () => {
4 test.describe('and fills the search input with a valid string', () => {
5 test.describe('and submits it', () => {
6 test('it should display the search term at the top of the page', async ({ page }) => {
7 await page.goto('/');
8
9 const searchQuery = 'JavaScript';
10
11 await page.getByPlaceholder('Search …').fill(searchQuery);
12
13 await page.keyboard.press('Enter');
14
15 await expect(page.getByText(`Search Results for: ${searchQuery}`)).toBeVisible();
16 })
17 test('it should redirect to a correct search page', async ({
18 page,
19 baseURL,
20 }) => {
21 await page.goto('/');
22
23 const searchQuery = 'JavaScript';
24
25 await page.getByPlaceholder('Search …').fill(searchQuery);
26
27 await page.keyboard.press('Enter');
28
29 await expect(page).toHaveURL(`${baseURL}/?s=${searchQuery}`);
30 });
31 })
32 });
33});To deal with that, we can create beforeEach hooks that run before each test in a particular group of tests marked with test.describe.
1import { expect, test } from '@playwright/test';
2
3test.describe('When the user visits the page', () => {
4 test.beforeEach(async ({ page }) => {
5 await page.goto('/');
6 })
7 test.describe('and fills the search input with a valid string', () => {
8 let searchQuery: string;
9 test.beforeEach(async ({ page }) => {
10 searchQuery = 'JavaScript';
11 await page.getByPlaceholder('Search …').fill(searchQuery);
12 })
13 test.describe('and submits it', () => {
14 test.beforeEach(async ({ page }) => {
15 await page.keyboard.press('Enter');
16 })
17 test('it should display the search term at the top of the page', async ({ page }) => {
18 await expect(page.getByText(`Search Results for: ${searchQuery}`)).toBeVisible();
19 })
20 test('it should redirect to a correct search page', async ({
21 page,
22 baseURL,
23 }) => {
24 await expect(page).toHaveURL(`${baseURL}/?s=${searchQuery}`);
25 });
26 })
27 });
28});Thanks to this, our tests are easier to read and more scalable.
Summary
In this article, we’ve gone through the basics of End-to-End (E2E) tests and how to perform them with Playwright. To do that, we learned about different options for executing our tests with Playwright. We also used environment variables, interacted with our website in various ways, and learned how to organize our tests better.
Thanks to automating browser interactions, Playwright is a helpful tool for ensuring that our applications work as expected in real-life scenarios. It can be a very valuable asset for helping us make our software more reliable and bulletproof.