Skip to main content

Mastering Playwright Fixtures: Hands-On Test Automation

DSC03135

In this collaborative article, we spotlight the contributions of Anabela Carvalho and Bárbara Salgado, Senior QA Engineers at Blip, as they share their use case with Playwright Fixtures. We'll explore how this powerful feature helps streamline test setup and ensure consistency across different scenarios. Read on to learn about their experiences with the tool and to access a hands-on guide for effectively implementing Playwright Fixtures in your testing.

 

Introduction

Quality Assurance teams at Blip are constantly innovating to ensure the reliability and quality of our platforms, especially given the dynamic and unpredictable nature of the data we handle. Developing and maintaining reliable regression test suites is challenging, but the benefits far outweigh the effort. Our applications are continually evolving, and we must be creative in testing scenarios that reflect real-world situations. For example, in the sports betting industry, we often recreate complex scenarios like penalty shoot-outs in football, where each kick is either untaken, scored, or missed. While these events don't happen every match, we use mocked data to simulate them regularly, ensuring our system performs flawlessly. In this context, Playwright, a versatile browser automation framework, plays a crucial role in enhancing our test coverage.

 

Testing Dynamic Content with Playwright

Playwright is a versatile tool for automating browser interactions and tests across different browsers and devices. It’s an open-source framework that provides a straightforward API that simplifies scripting even for complex scenarios. Because of this, we think it’s an ideal choice for modern web development and testing environments.

 

Reliable Movie and Book Lists on a Library Platform

Let’s start with our test subject – a library website that provides lists of available movies and books. Our website is very simple: it features a landing homepage with a navigation bar containing three options: Home, Movies or Books. 

When the user visits one of the content pages, let’s say Books, either by clicking on the navbar item ‘Books’ or accessing /books, they will land on the Books page. Our website will then consult an API to retrieve whichever books are available and show them to the user. 

 

 

Here’s an example request and example response for the API: 
 
HTTP GET /api/books

{ 
    "books": [ 
      { 
        "isbn": "1234", 
        "title": "Book title", 
        "author": "Author name", 
        "publish_date": "2020-06-04T08:48:39.000Z", 
        "publisher": "Publisher name", 
      } 
    ] 
}

If by any chance there are no books available, the website will show a message indicating so. 

 

And if we get an error while fetching the API, we then show another screen: 

And that’s basically every screen we may encounter while navigating this website. 

 

Test Pyramid Approach

For the test coverage of our Library we’ll follow the concept of the test pyramid which advocates for a balanced approach to automated testing by structuring tests into three levels: 

  1. Unit Tests: At the base, these are fast, isolated tests that cover individual components or functions. 
  2. Integration Tests: In the middle, these tests verify the interaction between integrated components or systems. 
  3. End-to-End (E2E) Tests: At the top, these tests validate the entire application flow from start to finish, mimicking real user scenarios but are slower and more brittle. 

The pyramid suggests having more unit tests, fewer integration tests, and even fewer E2E tests to ensure a robust, efficient testing strategy. For this experiment, we will only focus on E2E and, more importantly, integration tests – ensuring our website functions correctly by testing it independently from the API that supplies its data. 

 

Test scenarios 

  • Scenario 1 - End-2-end test: visit books page

For this scenario we’ll access our Library website and navigate directly to the Books page as a real user would.


test('E2E – visit books page', async ({ page }) => { 
 
	// navigating to our books website 
	await page.goto('http://localhost:3000/books'); 
 	
	// creating some constants to interact with page elements 
	const header = page.getByTestId('header'); 
	const bookList = page.getByTestId('bookList'); 
 	
	// asserting on the page's title 
	await expect(header).toHaveText("Blip's Library"); 
 	
	// asserting that there's a book list visible 
	await expect(bookList).toBeVisible(); 
}); 

As you can see, we’re using Playwright’s <page.goto> to access our Books page url, then asserting on some static content from our books page. 

 

  • Scenario 2 – Integration test (mocked data): No books available 

Now, if we want validate a scenario where the API returns no books, we can’t rely on end2end tests, as this is unlikely to happen frequently. We still need, however, to cover all our functional paths – and we do show a message for ‘no books available!’ as seen previously. We will use mocked data to generate this condition and guarantee our website functions as expected when this situation arises. 

By using playwright’s <page.route>, we’re be able to intercept the API call to /api/books and modify it to our needs. As we know the contract of this API, we’re able to mock its response with an empty array.


test('there are no books available', async ({ page }) => { 

  // before navigating to website, we're going to mock our API. 
  // body will return an empty books array 
  await page.route('http://localhost:3001/api/books', (route) => { 
      route.fulfill({ 
      body: JSON.stringify({ "books": [] }), 
    }); 
  }); 

  // now we can navigate to books page 
  await page.goto('http://localhost:3000/books'); 

  // creating some constants to interact with elements 
  const header = page.getByTestId('header'); 
  const bookList = page.getByTestId('bookList'); 
  const noBooksMessage = page.locator('.no-books') 

  // asserting on the page's title 
  await expect(header).toHaveText("Blip's Library"); 

  // assert on no books available message 
  await expect(noBooksMessage).toHaveText('No books available!') 
 
});

This way, we can guarantee that we will always get this scenario covered and can now add it with confidence to our regression test battery.

 

  • Scenario 3 - Integration test (mocked data): 2 books available 

Now, since in our initial end-2-end test couldn’t validate the actual content of a book because the API response can’t be predicted, we will use mocked data to present two books and assert its contents. 

As we did in the last scenario, using Playwright’s <page.route>, we’ll intercept the browser call to /api/books and modify it to our needs – we’ll create a valid response with two fake books.

 


test('there are 2 books available', async ({ page }) => {

  // before navigating to website, we're going to mock our API. 
  // body will return two fake books 

  await page.route('http://localhost:3001/api/books', (route) => {
    route.fulfill({
      body: JSON.stringify({
        "books": [{
          "isbn": "123",
          "title": "The Automation Blueprint",
          "subTitle": "Mastering QA in the Age of Continuous Delivery",
          "author": "Barbara Salgado",
          "publish_date": "2020-06-04T08:48:39.000Z",
          "publisher": "Blip QAs Editions",
          "description": "a book about automation in the QA world"
        },
        {
          "isbn": "124",
          "title": "Breaking the Flaky Test Cycle",
          "subTitle": "A Pragmatic Guide to Reliable Test Automation",
          "author": "Anabela Carvalho",
          "publish_date": "2020-06-04T08:48:39.000Z",
          "publisher": "Blip QAs Editions",
          "description": "a book about handling flaky tests"
        }]
      }),
    });
  });

  // now we can navigate to books page 
  await page.goto('http://localhost:3000/books');

  // creating some constants to interact with page elements 
  const bookList = page.getByTestId('bookList');

  // asserting book list is visible and taking a screenshot of its contents 
  await expect(bookList).toBeVisible();
  await bookList.screenshot({ path: 'booklist-2-results.png' });
});

In this example we are even able to cover this functional path with visual tests, because we are making sure that the results are always the same. This is what our test would find while running: 

 

  • Scenario 4 - Integration test (mocked data): Error fetching API 

For this scenario we will once again use Playwright’s <page.route> to intercept the browser’s API call to /api/books but this time we won’t care about the response, we just want a status code 500. This way we can prompt the website to react and adapt its visual content to this edge case.


test('there was an error fetching the API', async ({ page }) => {

  // before navigating to website, we're going to mock our API. 
  // body will return an empty books array 
  await page.route('http://localhost:3001/api/books', (route) => {
    route.fulfill({
      status: 500,
    });
  });

  // now we can navigate to books page 
  await page.goto('http://localhost:3000/books');

  // creating some constants to interact with elements 
  const header = page.getByTestId('header');
  const bookList = page.getByTestId('bookList');
  const errorMessage = page.locator('.error')

  // asserting on the page's title 
  await expect(header).toHaveText("Blip's Library");

  // assert on no books available message 
  await expect(errorMessage).toHaveText('Error fetching API!')

}); 

 

 

 

Creating Fixtures

In Playwright, fixtures are a powerful way to set up the environment or context for your tests, allowing you to share setup and teardown code across multiple tests. They help ensure that each test has the necessary context and resources, and they promote code reusability by allowing shared setup and teardown logic. Looking at our previous test examples, every single test has a common shared setup: every test will instanciate some elements and every test will open the same url. This shared setup is a good example where a Fixture can be useful. The purpose of this new Fixture will be the following:

  • Load page objects associated with this page, so we can easily interact with elements in our tests.
  • Open the respective page by doing a <page.goto> to the correct URL.

import { Page, test as base } from '@playwright/test';

import { BooksPO } from '../page-objects/books.po';

// our new type of fixtures is called PageFixtures 
// for now we only have one Fixture, BooksPage 
export type PageFixtures = {
  BooksPage: BooksPO;
};

// extending playwright's basic test by providing a BooksPage Fixture 
const test = base.extend({
  BooksPage: async ({ page }, use) => {
    const booksPagePO = new BooksPO(page);
    await booksPagePO.openPage();
    await use(booksPagePO);
  },

export { test };

As you can see, for this  <PageFixtures> we extend Playwright’s Test and by doing so, we’re creating our own context of what our test can have access to. We now know that if use a BooksPage Fixture we’ll have this setup preloaded. At the beginning of each test, we’ll have the page ready to start validations. 

Let’s see how our E2E test will look like when we use this new fixture:

 

  • Scenario 1 – End-to-End test: Visit books page (using fixtures) 
import { expect } from '@playwright/test'; 
 
import { test } from '../fixtures/page.fixture'; 
 
    // start a test using BooksPage fixture as a preloaded setup 
    test('E2E - visit books page, async ({ BooksPage, page }) => { 
   
        // using header and welcomeMessage from BooksPage PO 
        await expect(BooksPage.header).toHaveText("Blip's Library"); 
        await expect(BooksPage.welcomeMessage).toHaveText("Welcome to Blip's library!"); 
    });

As you can see, we created a lot less code this time, as BooksPage fixture is already encapsulating the navigation to the page and the declaration of the needed elements. Notching up the complexity, let’s see where we can apply Fixtures to our integration tests. 

Looking at our integration tests examples we can see that in the beginning of each test we use page.route to simulate different data: 

  • An empty book list 
  • A book list with 2 books 
  • An error  

Applying the same concept as we did for BooksPage before, we can create new type of Fixture that preloads the needed mocked data as a setup for our test.


import { Page } from '@playwright/test'; 
// importing test from our previous state 
// test will now be extended with both BooksPage and these new Fixtures 
// allowing us to use both Fixture types in the same test 
import { test as base } from '../fixtures/page.fixture'; 
 
// adding a new type of Fixtures, BooksMockFixture with three new Fixtures 
export type BooksMockFixtures = { 
    WhenIHaveTwoBooks: any; 
    WhenIHaveNoBooks: any; 
    WhenThereIsAnError: any; 
}; 
 
// extending basic test by providing a new Fixture for each different simulated scenario 
const test = base.extend({ 
    WhenIHaveTwoBooks: async ({ page }, use) => { 
        await page.route('**/api/books', (route) => { 
            route.fulfill({ 
              body: JSON.stringify( 
                { 
                  "books": [ 
                    { 
                      "isbn": "123", 
                      "title": "The Automation Blueprint", 
                      "author": "Barbara Salgado", 
                      "publisher": "Blip QAs Editions" 
                      }, 
                      { 
                      "isbn": "124", 
                      "title": "Breaking the Flaky Test Cycle", 
                      "author": "Anabela Carvalho", 
                      "publisher": "Blip QAs Editions" 
                      } 
 
                  ] 
                } 
              ), 
            }); 
        }); 
        await use(page); 
    }, 
    WhenIHaveNoBooks: async ({ page }, use) => { 
        await page.route('**/api/books', (route) => { 
            route.fulfill({ 
              body: JSON.stringify({ "books": [] }), 
            }); 
        }); 
        await use(page); 
    }, 
    WhenThereIsAnError: async ({ page }, use) => { 
        await page.route('**/api/books', (route) => { 
            route.fulfill({ 
              status: 500, 
            }); 
        }); 
        await use(page); 
    }, 
}); 
 
export { test }; 

Now we’re able to update our three remaining integration test scenarios with our both BooksPage fixture and BooksMockFixtures. 

 

  • Scenario 2 – Integration test: no books (using fixtures)

// the order of the parameter Fixtures is important 
// first we want to load the mocked data through page.route 
// then we want to open the page 
test('there are no books available', async ({ WhenIHaveNoBooks, BooksPage }) => { 
   await expect(BooksPage.noBooksMessage).toHaveText('No books available!') 
}); 
  • Scenario 3 – Integration test: 2 books available (using fixtures)

test('I should see 2 book results', async ({ WhenIHaveTwoBooks, BooksPage }) => { 
	await expect(BooksPage.booksList).toBeVisible(); 
	await BooksPage.booksList.screenshot({ path: 'booklist-2results.png' }); 
}); 

  • Scenario 4 – Integration test: Error fetching API (using fixtures)

test('I should see error message', async ({ WhenThereIsAnError, BooksPage }) => { 
	await expect(BooksPage.errorMessage).toHaveText('Error fetching API!'); 
}); 

As you can see, our tests are now solely focused on expectations as our Fixtures are doing all the setup for us. Because we’re being descriptive in titles and naming, the test remains readable (we still could improve readability using test steps). 
 

Conclusion 

Fixtures encapsulate setup and teardown in one place, are reusable across test files, and are initialised only when needed. They are composable, allowing complex behaviors through dependencies, and flexible, enabling precise environments without impacting other tests. Fixtures also simplify test grouping by eliminating the need for setup in describes. Nonetheless, Fixtures can introduce some drawbacks that might make them a less-than-ideal solution in certain contexts – they can introduce complexity, making tests harder to understand and manage, especially for new developers, by hiding setup logic and tightly coupling tests to specific configurations. When tests fail, debugging can be harder when fixtures are involved, since the failure might originate from fixture setup/teardown and tracking down the root cause of issues can take longer. Fixtures can provide powerful advantages in test organidation and flexibility, but in our opinion we should use them wisely to avoid added complexity, debugging challenges, and unnecessary overhead in simpler scenarios. 

 

 

Related Articles

Cactus Day2 034

Test Drive: The Challenges of Race Conditions in Security Testing

Blip has a dedicated Security Testing team that performs penetration testing, continuous testing, and red teaming. By being part of S-SDLC (Secure Software Development Life Cycle) process, the team handles development and infrastructure security…

View More
Blog Post Deni Junior 5

Protecting Your Frontend from Impossible Scenarios

This article is not about error screens or user experience. It is about how development-level decisions can protect our customers from impossible scenarios and give the developer more confidence while writing code.

View More
BLOG POST 16MAR 2 (2)

Learning at Scale for Engineering at Scale

For software development teams, the moment we stop learning is the moment our technical stack starts becoming obsolete. It’s not about how many languages or tools we know. It’s about how quickly we adapt when they evolve, get replaced, or transform…

View More