Using Test Steps in Playwright

How to make your tests more organized, readable, and debuggable

ยท

4 min read

What are steps?

Suppose you have a long test that you want to break into sections to keep track of what is happening at each stage of the test. Playwright's test steps are a great way to do that, helping your code be more organized, readable, and debuggable.

If you'd like to follow along with this post and need to know how to get started with Playwright, see my article about getting started with Playwright.

The problem

Let's take a look at a Playwright test that I wrote. (For a breakdown of the steps, see this post).

There's nothing wrong with this test - it will pass - but let's change a few details so it won't pass and see what the error output looks like.

// @ts-check
const { test, expect } = require('@playwright/test');

test('verify main page content is visible and links are clickable', async ({ page }) => {
  await page.goto('https://madelinecaples.hashnode.dev/');

  // Expect a title "to contain" a substring.
  await expect(page).toHaveTitle('Madeline\'s Machine Learning Log');

  // Expect header to contain image and correct title
  const headerTitle = page.getByRole('link', { name: 'Madeline\'s Machine Learning Log'}); 
  await expect(headerTitle).toBeVisible();
  const headerTitleImage = headerTitle.getByRole('img'); 
  await expect(headerTitleImage).toBeVisible(); 
  await expect(headerTitleImage).toHaveAttribute('alt', 'Madeline Caples'); 

  // check if blog author container has the expected elements 
  const blogAuthorContainer = page.locator('.blog-author-container'); 
  const blogAuthorImage = blogAuthorContainer.getByRole('img'); 
  await expect(blogAuthorImage).toBeVisible(); 
  await expect(blogAuthorImage).toHaveAttribute('alt', 'Madeline Caples'); 
  const blogAuthorTitle = blogAuthorContainer.getByRole('link').last();
  await expect(blogAuthorTitle).toHaveText('Madeline Caples'); 
  const headline = blogAuthorContainer.getByRole('paragraph'); 
  await expect(headline).toHaveText('I explain machine learning concepts in plain English'); 

  // expect all links on page to be enabled 
  for (const link of await page.getByRole('link').all()) {
    await expect(link).toBeEnabled(); 
  }
});

Let's change the expected alt for blogAuthorImage to be just "Madeline." Since the test is expecting a specific string, this will cause the test to fail.

Here's the error output.

Screenshot of test output

You can see the line where the test failed is displayed in the console. What you can't see is the comment that divides what section of the test this line comes from. This can be a little disorienting, as it requires you to go back to your test file to find out what comment section of the test that line of code is under.

Besides that, the test is getting rather long and unwieldy, so it would be nice if there were a way to break the test up, but without necessarily having to divide the test into smaller tests.

The solution

It turns out that there is! Instead of using comments to divide up the sections of the test, we can use Playwright's steps.

Syntax for steps

The syntax for steps is very similar to the syntax for writing a test itself, except the step will go within the test.

We'll begin with the await keyword, followed by test.step(). Inside the parenthesis, we'll insert the title of our step as a string. The title should be descriptive of what the step is doing - I chose to just copy and paste my comments as my step titles. Then you'll throw a comma after your step title, an async keyword and a callback function (that takes no arguments). Within the body of the callback, you'll add your test step - what this portion of the test is going to do. This process gives us the following first step of our test:

test('verify main page content is visible and links are clickable', async ({ page }) => {
  await page.goto('https://madelinecaples.hashnode.dev/');

  // test step begins here: 
  await test.step('Expect a title "to contain" a substring', async () => {
    await expect(page).toHaveTitle('Madeline\'s Machine Learning Log');
  });

Our next step in our test will contain the line of code that throws an error.

await test.step('Expect header to contain image and correct title', async () => {
    const headerTitle = page.getByRole('link', { name: 'Madeline\'s Machine Learning Log'}); 
    await expect(headerTitle).toBeVisible();
    const headerTitleImage = headerTitle.getByRole('img'); 
    await expect(headerTitleImage).toBeVisible(); 
    await expect(headerTitleImage).toHaveAttribute('alt', 'Madeline'); 
  });

Then when we have an error, what will it look like?

Screenshot of test output with steps

We've got a nice error output that shows the step where the test failed.

Adding in test.step() instead of comments to show the different steps of the test can greatly improve your debugging process. The report shows you what step of the test you're on, instead of you having to manually look up the comment for that part of the code.

Why not just write multiple tests?

Why not skip test.step() altogether and just write a separate test for each feature you want to check up on, with a separate title for each new test? While you could do that, Playwright's best practices recommend keeping tests longer, rather than writing lots of tests. Remember, the goal is to simulate an end user's actual interaction with the webpage, so we want our test to mirror an entire user flow.

Going further

There's another way we can improve the readability - and reusability - of our code. Stay tuned for the next article, where I talk about Playwright's Page Object Model. ๐Ÿ˜Š