Optimizing End-to-End Testing with Playwright: A Practical Guide

Testing
ui-image Optimizing End-to-End Testing with Playwright: A Practical Guide
Image generated by ChatGPT (DALL·E)

In this article, we’ll explore how to make End-to-End (E2E) testing more efficient using Playwright. We’ll examine how to enhance test readability with fixtures, test authentication flows, handle data tables, and ensure responsive design (RWD) and CSS behaviors work as expected. We’ll also cover performance budgets to maintain a smooth user experience.

Understanding E2E Testing

E2E testing verifies an application’s entire user journey from start to finish, simulating real-world scenarios to catch unexpected behaviors across different systems. There are two primary approaches to E2E testing:

  • Horizontal Testing: Focuses on testing the complete workflow.
  • Vertical Testing: Inspects only critical modules.

Key aspects of E2E testing include:

  • User Journey Testing: Simulates real user interactions.
  • Functional Testing: Verifies UI components.
  • API Integration Testing: Ensures API calls work correctly.
  • Performance Testing: Confirms pages load efficiently within acceptable limits.
  • Cross-Browser & Cross-Device Testing: Evaluates behavior across Chrome, Firefox, Safari, Edge, and multiple devices.
  • Error Handling & Edge Cases: Tests invalid inputs, incorrect user actions, and server failures.

Why Playwright?

Having worked with Selenium, Cypress, and Puppeteer, I ultimately settled on Playwright. While I was fond of Puppeteer, it lacked key testing capabilities. Playwright, being a successor of Puppeteer, focuses on testing and includes:

  • A built-in testing framework with extensive web-specific assertions.
  • Auto-wait functionality that ensures elements are actionable before performing actions.
  • Support for all modern rendering engines and native mobile emulation.
  • Developer tools like Codegen, Inspector, and Trace Viewer for debugging.

However, the auto-wait feature isn’t flawless—it sometimes fails unpredictably, making debugging difficult.

Functional Testing with Playwright

We’ll demonstrate functional testing using this demo app . Let’s start with the login flow.

Demo app - Login form

Setting Up Playwright

To begin using Playwright, generate a boilerplate project with the following command:

npm init playwright@latest

You can do this in your development environment or use a Docker container:

docker run --network=host -it --rm --mount type=bind,source=/var/www/E2E,target=/var/www -w /var/www --ipc=host mcr.microsoft.com/playwright:v1.51.1-noble /bin/bash

Ensure that your test projects are stored in /var/www/E2E. This setup will create a project structure with:

  • A playwright.config.js file in the root directory.
  • A test folder containing an initial test suite (example.spec.js).
  • A tests-examples folder (which we can remove, as it contains sample tests for a TODO MVC app).

Structuring Clean and Efficient Tests

To keep our tests clean and maintainable, we will separate locators and functional logic per scopes (e.g. pages) and the test logic, which is mostly assertions. Let start with global constants:

lib/constants.js

import { join } from "path";

export const BASE_URL = process.env.BASE_URL;
export const RES_PATH = join( __dirname, "..", "test-results" );

export const saveAs = ( subPath, fullPage = false ) => {
    subPath = subPath.replace( /[^a-zA-Z0-9-_]/g, "-" );
    return { 
        path: join( RES_PATH, subPath + ".png" ),
        fullPage,
        timeout: 3000
    };
};
export const TEST_LOGIN_EMAIL = "[email protected]";
export const TEST_LOGIN_PASSWORD = "Password1";

export const VIEWPORTS = {
    WUXGA: {
      width: 1920,
      height: 1200,
    }, 
    IPAD_MINI: {
        width: 768,
        height: 1024
    },
    IPHONE8: {
        width: 375,
        height: 667
    }
};

In this file, we define:

  • BASE_URL – The application’s base URL.
  • VIEWPORTS – Predefined device names for tests instead of specific resolutions.
  • TEST_LOGIN_EMAIL & TEST_LOGIN_PASSWORD – Test credentials for the login flow.
  • saveAs function – Saves screenshots under test-results/ with filenames normalized from plain text descriptions (e.g., “Login flow valid credentials” → Login-flow-valid-credentials.png).

Structuring Page Objects

To enhance test readability and reusability, we will extend the Playwright test with fixtures, allowing us to separate locators and logic into scopes (pages).

Abstract Page

pageRepo/AbstractPage.js

import { BASE_URL, VIEWPORTS } from "../lib/constants";

export class AbstractPage {

	constructor( page ) {
		this.page = page;
    }

    async openPage({ viewport, path } = { viewport: null, path: null }) {
        viewport = viewport ?? VIEWPORTS.WUXGA;
        this.viewport = viewport;
        await this.page.setViewportSize( viewport );
        await this.page.goto( BASE_URL + ( path ?? this.baseUrl ));
    }
}

This defines the base openPage method, which:

  • Uses a predefined baseUrl (e.g., /login) to open pages without repetition.
  • Sets the viewport on every call (default unless specified otherwise).

Login Page

pageRepo/LoginFlow.js

import { AbstractPage } from "./AbstractPage";
export default class LoginFlow extends AbstractPage {

    baseUrl = "/login";

    constructor( page ) {
		super( page );        
        this.emailLoc = this.page.getByLabel( `Email` );
        this.passwordLoc = this.page.getByLabel( `Password` );
        this.submitBtnLoc = this.page.getByRole( "button", { name: /Login/i });
        this.errorAlertLoc = this.page.locator( `.ant-alert.ant-alert-error .ant-alert-description` );
        this.successAlertLoc = this.page.locator( `.ant-alert.ant-alert-success .ant-alert-description` );
		this.spinnerLoc = this.page.locator( `.ant-modal-content .ant-spin` );        
    }   
}

This file contains:

  • The baseUrl for the login page.
  • All necessary locators for login-related tests.

Test Fixture

lib/baseTest.js

import { test as baseTest } from "@playwright/test";
import LoginFlow from "../pageRepo/LoginFlow";

export const test = baseTest.test.extend({

  loginFlow: async ({ page }, use ) => {
    await use( new LoginFlow( page ) );
  }
  
});

export { expect } from '@playwright/test';

This fixture defines the base structure for our tests.

Test project file structure

Testing Authentication

Now, let’s talk about the tests. We’ll start with a positive scenario, where the user logs in successfully using valid credentials.

tests/login-flow.spec.js

import { test, expect } from "../lib/baseTest";
import { TEST_LOGIN_EMAIL, TEST_LOGIN_PASSWORD, saveAs } from "../lib/constants";

test.describe( "Login Flow", () => {
  test( "should allow users to log in with valid credentials", async ({ loginFlow: $, page }) => {
    
    await $.openPage();   
    await $.emailLoc.fill( TEST_LOGIN_EMAIL );
    await $.passwordLoc.fill( TEST_LOGIN_PASSWORD );

    await $.submitBtnLoc.click();
    
    await page.screenshot( saveAs( "Login flow valid credentials" ) );
    await expect( $.successAlertLoc ).toBeVisible();
    await expect( $.successAlertLoc ).toContainText(/You have been successfully logged in/);
  });

});

Generated screenshot for login flow with valid credentials

In this test, we:

  • Open the login page.
  • Fill in both input fields with valid credentials.
  • Click the Login button.

I recommend taking a screenshot at this point, not just for debugging, but also as a record of what was tested in the last session. Screenshots can serve as a quick reference for colleagues to understand the functionality of the project.

We also assert that the success alert is visible and contains the expected text.

Notice that our loginFlow fixture is passed as an argument within the test scope. To avoid repetition in the test body, I alias it as $. Immediately after simulating the mouse click event on the Submit button, we assert the results. Once the button is clicked, the app sends a login request to the server and freezes the form until a response is received.

Playwright’s auto-wait feature helps handle this scenario, so we don’t have to manually manage intermediate states. However, if the response takes too long, auto-wait will fail. Here’s a trick to handle that:

await page.waitForResponse("**/api/v1/login");

Place this command after the click but before the assertions. The **/api/v1/login argument is a glob pattern matching the API request URL.

Testing the Loading State

We can also use this approach to test the loading state of the login form:

tests/login-flow.spec.js

test( "should switch the form in loading state when waiting", async ({ loginFlow: $, page }) => {
    
    await $.openPage();   
    await $.emailLoc.fill( TEST_LOGIN_EMAIL );
    await $.passwordLoc.fill( TEST_LOGIN_PASSWORD );

    await page.route("**/api/v1/login", async (route) => {
        await new Promise( resolve => setTimeout( resolve, 1000 )); // Simulate a 1s delay
        route.fulfill({ status: 200, body: JSON.stringify({ success: true }) });
    });

    await $.submitBtnLoc.click();
    
    await page.screenshot( saveAs( "Login form loading state" ) );
    await expect( $.spinnerLoc ).toBeVisible();
    await expect( $.submitBtnLoc ).toBeDisabled();
  });
Generated screenshot for login form in loading state

Using page.route, we delay the response by 1 second. This ensures that subsequent assertions and screenshots capture the frozen state of the form.

Testing Negative Scenarios

Now, let’s test failure cases:

tests/login-flow.spec.js

  test( "should fail when invalid credentials", async ({ loginFlow: $, page }) => {
    
    await $.openPage();   
    await $.emailLoc.fill( "[email protected]" );
    await $.passwordLoc.fill( "WrongPassword" );
    await $.submitBtnLoc.click();    
    await page.screenshot( saveAs( "Login flow invalid credentials" ) );
    await expect( $.errorAlertLoc ).toBeVisible();
    await expect( $.errorAlertLoc ).toContainText(/Wrong email address or password./);
  });

  test( "should fail when no credentials", async ({ loginFlow: $, page }) => {
    
    await $.openPage();   
    await $.submitBtnLoc.click();    
    await page.screenshot( saveAs( "Login flow no credentials" ) );
    await expect( page.getByText( "Field is required" ).nth( 0 ) ).toBeVisible();
    await expect( page.getByText( "Field is required" ).nth( 1 ) ).toBeVisible();
  });

Generated screenshot for login flow with no credentials
Generated screenshot for login flow with invalid credentials

A new element here is page.getByText("Field is required"), which matches multiple elements. To specify a particular element, we use .nth(index).

Running the Tests

Execute the tests with the following command:

BASE_URL=http://127.0.0.1:9100 npx playwright test
Login flow test report

Handling Email Confirmation in Registration

Unlike login, the registration flow often requires users to confirm their request by clicking a link in a verification email. To automate this, we can use the Restmail.net service. For example, I use the following helper:

lib/helper.js

const fetch = require ( "node-fetch" ),
      parseRestMail = ( json ) => {
          const parseActionLink = ( text ) => {
                  const re = /<a href=\"([^\"]+)\"/i,
                        res = text.match( re );
                  return res ? res[ 1 ].replace( "=\r\n", "" ) : null;
                };

          if ( !json.length ) {
            return null;
          }
          const unseen = json.shift();
          return parseActionLink( unseen.html );
        };
		
/**
 * @typedef {object} PollParams
 * @property {string} url
 * @property {number} interval in ms
 * @property {number} timeout in ms
 * @property {function} parserFn
 * @param {object} [parserPayload] - extra payload for callback (e.g. email, timestamp)
 * @param {function} [requestFn] - optional function to replace the default one
 */
/**
 * poll given URL for value
 * @param {PollParams} params
 * @returns {Promise}
 */
export function pollForValue({ url, interval, timeout, parserFn =  null, parserPayload = {}, requestFn = null }) {
  parserFn = parserFn ?? parseRestMail;
  const request = requestFn ? requestFn : async ( url ) => {
    const rsp = await fetch( url );
    if ( rsp.status < 200 || rsp.status >= 300  ) {
      return {};
    }
    return await rsp.json();
  };

  return new Promise(( resolve, reject ) => {
    const startTime = Date.now();
    pollForValue.attempts = 0;

    async function attempt() {
      if ( Date.now() - startTime > timeout ) {
        return reject( new Error( `Polling: Exceeded timeout of ${ timeout }ms` ) );
      }
      const value = parserFn( await request( url ), parserPayload );
      pollForValue.attempts ++;
      if ( !value ) {
        return setTimeout( attempt, interval );
      }
      resolve( value );
    }
    attempt();

  });
}

Now, we can integrate it into our tests like this:

 await $.submitRegistrationForm({ email: `${ NAME }@restmail.net`, firstname: `John`, lastname: `Doe` });	
 const link = await pollForValue({
            url: `http://restmail.net/mail/${ NAME }`,
            interval: 1000,
            timeout: 60000,
            requestFn: null,
            parserPayload: {
              sentAt: Date.now()
            }
          });
 await page.goto( link );

Testing the Data Table

The demo app includes a mock UI for managing projects, where the available projects are displayed in a data table. Example:

Projects page

Our goal is to verify that the table’s content updates correctly when navigating between pages.

pageRepo/Projects.js

import { AbstractPage } from "./AbstractPage";
export default class Projects extends AbstractPage {

    baseUrl = "/";

    constructor( page ) {
		super( page );        
        this.tableContainerLoc = this.page.locator( `.ant-table-container` );
        this.paginationBtnLoc = this.page.locator( `.ant-pagination-item-2` );
    }    
}

tests/projects.spec.js

import { test, expect } from "../lib/baseTest";
import { saveAs } from "../lib/constants";

test.describe( "Projects data-table", () => {
  test( "should update the table when navigating between pages", async ({ projects: $, page }) => {    
    await $.openPage();   
    await page.screenshot( saveAs( "Projects 1st page" ) );
    const firstPageData = await $.tableContainerLoc.allInnerTexts();
    await $.paginationBtnLoc.click();
    await page.waitForResponse( "**/api/v1/projects*&current=2*" );
    const secondPageData = await $.tableContainerLoc.allInnerTexts();
    await page.screenshot( saveAs( "Projects 2nd page" ) );
    expect( secondPageData ).not.toEqual( firstPageData );    
  });
});

In this test, we first store the initial HTML content of the table in a variable. Then, we simulate a mouse click on the button for the second page in the pagination control. This scenario is one where auto-wait mechanisms may fail. To handle this, we use waitForResponse to ensure the request has completed before proceeding. Next, we compare the previously saved table content with the updated content to confirm the change. Alternatively, we could validate specific table data by checking if certain column values match an expected pattern.

Testing Responsive Web Design

End-to-end (E2E) tests can simulate different screen sizes—mobile, tablet, and desktop—to ensure that UI elements adjust correctly. These tests verify proper element positioning, grid alignment, and media query functionality.

Let’s look at an example by testing this blog layout:

Layout on desktop
Layout on mobile

As you can see, on a wide screen, the main navigation is visible, while on mobile, it is replaced with a burger menu. Additionally, the sidebar appears next to the main content on a wide screen but moves below the main content on mobile.

To automate this verification, we define locators for the relevant elements in a new fixture object:

pageRepo/Layout.js

import { AbstractPage } from "./AbstractPage";
export default class Layout extends AbstractPage {

    baseUrl = "/";

    constructor( page ) {
		super( page );   
        this.mainNavLoc = this.page.locator( `nav.main-nav` );
        this.burgerLoc = this.page.locator( `label.burger-icon` );
        this.contentLoc = this.page.locator( `div.posts` );
        this.sidebarLoc = this.page.locator( `aside.sidebar` );
    }

    async openPage({ viewport, path } = { viewport: null, path: null }) {
        viewport = viewport ?? VIEWPORTS.WUXGA;
        this.viewport = viewport;
        await this.page.setViewportSize( viewport );
        await this.page.goto( `https://dsheiko.com` + ( path ?? this.baseUrl ));
    }

    
}

We also modify the openPage method, hardcoding the base URL for dsheiko.com.

Now, let’s move on to the tests:

tests/index.spec.js

import { test, expect } from "../lib/baseTest";
import { saveAs, isLeftTo, isAbove, VIEWPORTS } from "../lib/constants";

test.describe( "Layout", () => {
  test( "should display main menu on wide screens", async ({ layout: $, page }) => {    
    await $.openPage({ viewport: VIEWPORTS.WUXGA });   
    await page.screenshot( saveAs( "Layout on wide screen" ) );
    await expect( $.mainNavLoc ).toBeVisible();
    await expect( $.burgerLoc ).not.toBeVisible();
    
  });

  test( "should display burger menu on mobile", async ({ layout: $, page }) => {    
    await $.openPage({ viewport: VIEWPORTS.IPAD_MINI });   
    await page.screenshot( saveAs( "Layout on mobile" ) );
    await expect( $.mainNavLoc ).not.toBeVisible();
    await expect( $.burgerLoc ).toBeVisible(); 
  });

  test( "should display content is left to sidebar on wide screen", async ({ layout: $, page }) => {    
    await $.openPage({ viewport: VIEWPORTS.WUXGA });
    await expect( isLeftTo( await $.contentLoc.boundingBox(), await $.sidebarLoc.boundingBox() ), 
      "Content is left to sidebar" ).toBeTruthy();
  });

  test( "should display content is above to sidebar on mobile", async ({ layout: $, page }) => {    
    await $.openPage({ viewport: VIEWPORTS.IPAD_MINI });
    await expect( isAbove( await $.contentLoc.boundingBox(), await $.sidebarLoc.boundingBox() ), 
      "Content is above to sidebar" ).toBeTruthy();
  });

});

The first two tests are straightforward. We open the page on both a wide and a narrow screen, verifying the visibility of the main navigation and burger menu. To check the positions of the main content and sidebar, we use the previously defined helper functions isLeftTo and isAbove. These functions compare element coordinates and sizes to determine their relative positioning.

Testing Visual Regression

In complex web designs with many reusable elements, CSS changes can sometimes cause unintended side effects. While the element you’re focusing on may appear correct, other elements on different pages or under varying conditions might break. This is especially tricky when dealing with subtle shifts of just 1-2 pixels, which can be hard to detect.

Fortunately, we can use visual comparisons to catch these issues automatically by running visual regression tests. To implement a visual regression test for the login flow, create the following test file:

tests/login-flow.spec.js

 test( "should match the predefined visual design", async ({ loginFlow: $, page }) => {
    await $.openPage();
    await expect( page ).toHaveScreenshot( "login-form.png" );
  });

Next, generate reference screenshots using the following command:

npx playwright test --update-snapshots

On subsequent test runs, Playwright captures new screenshots and compares them with the reference images. If any differences are detected, the test runner reports them, allowing you to catch unintended visual changes early.

Testing Performance Budget

A web performance budget is essential for maintaining a fast, efficient, and user-friendly website. It enhances user experience, improves SEO rankings, reduces costs, and increases conversions and sales. Additionally, a performance budget serves as a guideline for developers, preventing unnecessary bloating.

To test the performance budget, install the following additional packages:

npm i playwright-lighthouse get-port

Next, extend the test scope by adding two new fixtures:

lib/baseTest.js

import { test as baseTest } from "@playwright/test";
import { chromium } from "playwright";
//...
export const test = baseTest.test.extend({

  //...
  port: [
    async ({}, use) => {
      // this way to prevent: Error: require() of ES Module /node_modules/get-port/index.js
      const { default: getPort } = await import( "get-port" );
      // Assign a unique port for each playwright worker to support parallel tests
      const port = await getPort();
      await use(port);
    },
    { scope: "worker" },
  ],

  browser: [
    async ({ port }, use) => {
      const browser = await chromium.launch({
        args: [`--remote-debugging-port=${port}`],
      });
      await use(browser);
    },
    { scope: "worker" },
  ],
  
});

export { expect } from "@playwright/test";

Finally, include an extra test in the login flow:

tests/index.spec.js


  test( "should not exceed the performance budget", async ({ loginFlow: $, page, port }) => {
    const { playAudit } = await import( "playwright-lighthouse" );
    await $.openPage();   
    await $.emailLoc.waitFor();
    await playAudit({
      page,
      port,
      thresholds: {
        performance: 50,
        accessibility: 50,
        "best-practices": 50,
        seo: 50
      }
    });
  });
  

Once the tests are executed, the results obtained from the Lighthouse API will be displayed in the report:

Performance test report

You can compare these results with the corresponding values in the Lighthouse tab within DevTools in your browser.

Conclusion

Playwright offers a powerful and efficient solution for End-to-End (E2E) testing, providing a robust framework for testing various aspects of web applications, from authentication and data tables to responsive design and performance budgets. By leveraging Playwright’s unique features like auto-wait, fixtures, and powerful testing utilities, you can enhance the readability and maintainability of your tests. Moreover, Playwright’s support for visual regression and cross-browser testing ensures that your application delivers a seamless user experience across different devices and screen sizes. With its modern architecture, Playwright simplifies the complexities of E2E testing, enabling developers to catch potential issues early, improve app performance, and ultimately create a smoother, more reliable user journey. Whether you’re testing UI elements, ensuring performance, or validating security measures, Playwright provides the tools needed to build comprehensive, efficient tests that scale with your project.