< Back

Designing a Clean and Modular Playwright Framework

Author

Pieter De Bie en Wim Raes

Date

18/03/2024

Share this article

In the world of automated testing, creating a well-structured framework is crucial for maintainability and scalability. One aspect often overlooked in Playwright is the management of the configuration file and project dependencies.  

Of course, every framework structure depends on the complexity of the System Under Test (SUT). While I might discuss specific subjects in this blogpost that aren’t directly relevant to your framework, it could give you a better idea of overall structure and way of thinking when setting up a new framework! 

We'll provide a brief overview of what the folder structure of your framework could look like according to best practice standards. 

We'll also explore a design approach that helps your framework be manageable and scale well by introducing important parameters into your config file. Additionally, we will show how using project dependencies enables us to create a reliable setup and teardown for your tests. 

Note that, not every framework will need the exact setup/teardown we’ll use in this blogpost, it simply serves as a reference to show what the overall structure of the framework could look like when using project dependencies. 

The System Under Test I use as reference for the code snippets is: https://www.saucedemo.com 

Structure

When structuring your Playwright project, it's essential to organize your files and directories in a clear and logical manner to maintain scalability and ease of maintenance. Here's a recommended directory structure: 

Tests Directory

Contains all the test files for your project.

It's common to further organize tests based on functionality or features.

Page Objects Directory

Stores Page Object Model (POM) classes representing web pages or components.

Each page or component should have its own class to encapsulate its behaviour and elements.

Utilities Directory

Houses utility functions or helper modules that can be reused across tests and page objects.

Common functionalities like handling browser navigation, custom general functions, or assertions can reside here.

Reports Directory

Contains generated test reports or logs for reference and analysis.

Reports can be in various formats like HTML, JSON, or XML.

Assets Directory

Holds any static files or resources needed for tests, such as images, mock data, or configuration files.

Setup Directory 

Typically, contains files responsible for initializing and configuring the test environment before running the tests. This includes setting up any necessary dependencies, such as logging in to a system or configuring test data. Additionally, it may include files for tearing down or cleaning up the environment after the tests have been executed.

Auth

This directory can be used to store files related to authentication, such as tokens, user data, or configuration files regarding authentication settings.

Playwright.config

The playwright.config.ts file serves as the central configuration hub for your Playwright framework. It allows you to define various settings, including browsers to use, device emulation, and global setup/teardown scripts. Additionally, you can configure settings specific to different projects within your Playwright framework, enabling greater flexibility and customization across your test suites.

Playwright config management

The configuration file plays a crucial role in crafting a well-organized framework, serving as the cornerstone for defining project-specific settings and directing the behaviour of your Playwright projects. This is where your design process should start.

While the default configuration file already includes some useful options, most of them are defined as global settings and shared between all projects. However, specifying certain options on a project-level basis allows for finer control and customization of each project.

For example, project-specific settings such as the output and test directory paths, baseURL configurations, project dependencies, and more can be tailored to match the unique requirements of each project.

Our System Under Test (SUT) is located on a single website, so we can define the baseURL globally to ensure consistency across projects.

use: { 
    baseURL: 'https://www.saucedemo.com/', 
} 

Setting the testDir option per project is advantageous because we will group all project related tests within a single folder. 

projects: [ 
  { 
    name: 'products', 
    testDir: 'tests/products', 
  }, 
]; 

Setup and authentication 

To implement a modular design strategy, it is good practice to separate the setup and teardown steps from the actual test suite. 

For the SUT we need to authenticate before accessing the site, so we will handle the authentication in a setup project. If your SUT does not contain any authentication step, the setup could for example contain the creation of certain data needed to run your tests. 

The setup should contain a key step which your tests always rely on. 

Create a .spec file that handles the login or authentication process of your SUT and place it in a directory called setup.

// setup/login.setup.ts 
// Your authentication setup code goes here, example: 

import { test as setup, expect } from '@playwright/test' 
import { LoginPage } from '../pageObjects/loginPage.ts';  
  
setup('can login with standard_user', async ({ page }) => { 
    const loginPage = new LoginPage(page) 
    await loginPage.login(process.env.USERNAME!, process.env.PASSWORD!)) 
    await loginPage.saveAuthState() 
}); 

The file was named login.setup.ts instead of login.spec.ts. Playwright will recognize this file as being a test file due to the parameter testMatch: 'setup/login.setup.ts' defined next. 

// pageObjects/loginPage.ts 
// Inside the LoginPage class 

import { authState } from '../playwright.config'; 

async saveAuthState() { 
    await this.page.context().storageState({ path: authState }); 
 } 
  • navigate to the baseURL 

  • fill in username and password 

  • click the login button 

  • store the current browser context state (storageState) in the authState path 

Use a page object for the login page:  

// pageObjects/loginPage.ts 

import { Page } from '@playwright/test'; 
import { authState } from '../playwright.config'; 
  
export class LoginPage { 
  private readonly page: Page; 

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

  private async open() { 
    await this.page.goto('/'); 
  } 
  
  private async fillUsername(username: string) { 
    await this.page.locator('[data-test="username"]').fill(username); 
  }  

  private async fillPassword(password: string) { 
    await this.page.locator('[data-test="password"]').fill(password); 
  } 

  private async clickLoginButton() { 
    await this.page.locator('[data-test="login-button"]').click(); 
  } 

  async login(username: string, password: string) { 
    await this.open(); 
    await this.fillUsername(username); 
    await this.fillPassword(password); 
    await this.clickLoginButton(); 
 } 

    async saveAuthState() { 
    await this.page.context().storageState({ path: authState }); 
  } 
} 

Configuring the setup project in playwright.config 

Configuring the setup project to include the authentication state would appear as follows: 

// playwright.config.ts 

export const authState = 'auth/auth-state.json'; 
  
projects: [ 
  { 
    name: 'setup', 
    testMatch: 'setup/login.setup.ts', 
  }, 
  // Other projects can be added here 
]; 
  • Define the authState constant that holds the path where the authentication state will be stored, in this case, in a folder called ‘auth’. 

  • Define a project called ‘setup’ with the testMatch parameter 

Creating project dependency 

The main project “products” will contain all our subsequent tests and is dependent on the setup project.  

Update the configuration for the main project 'products' by specifying dependencies and instructing it to use the stored authentication state. 

// playwright.config.ts 

projects: [ 
  { 
    name: 'setup', 
    testMatch: 'setup/login.setup.ts', 
  }, 
  { 
    name: 'products', 
    dependencies: ['setup'], 
    use: { 
      storageState: authState, 
    }, 
    testDir: 'tests/products', 
  }, 
]; 

This configuration ensures that the 'products' project depends on the 'setup' project and utilizes the stored authentication state during testing. 

Why not use globalSetup? 

Altough Playwright offers a parameter to configure a global setup and teardown, I recommend creating a separate project and make your main project dependent on it.  

The reason behind this choice is to facilitate traceability in the CI artifacts. When using the globalSetup/globalTeardown parameters in the config, you won’t have access to any trace if either of those go wrong.  

Being able to retrace your steps, and figuring out where things went wrong is a crucial aspect every testing framework should have! 

Teardown Feature for Clean Environment 

To enhance the testing environment even further, let's introduce a teardown feature. This feature ensures a clean state after each test execution. 

In the playwright.config.ts file, add a teardown option to the setup project, specifying the teardown script. 

The feature works as follows: once all projects dependent on the setup project complete their execution, the teardown project will run. 

In this case: when the products project (which depends on the setup project) finishes running, all the scripts in the teardown config of the setup project will run. 

// playwright.config.ts 

projects: [ 
  { 
    name: 'setup', 
    testMatch: 'setup/login.setup.ts', 
    teardown: 'setup/teardown.ts' 
  }, 
  { 
    name: 'products', 
    dependencies: ['setup'], 
    use: { 
      storageState: authState, 
    }, 
    testDir: 'tests/products', 
  }, 

Create an appropriate teardown script (setup/teardown.ts) to reset and clean the environment after test execution. This could be cleaning up created users or other created test-data, etc. 

By adopting this design approach and introducing the teardown feature, your Playwright framework becomes not only modular and scalable but also ensures a clean testing environment.  

Creating custom fixtures to complete the framework 

So we’ve introduced a very solid base to create a test automation framework so far, but the cherry on top would be creating our own custom fixtures!  

When your project starts to grow, and you start adding more page-objects to your framework, then it could get tedious to declare your page-objects in every test: 

test('can add product to cart', async ({ page }) => { 
const productPage = new ProductPage(page); 
//do stuff 
}); 

 
test('can remove product from cart', async ({ page }) => { 
const productPage = new ProductPage(page); 
//do stuff 
}); 

test('can change amount of products in cart', async ({ page }) => { 
const productPage = new ProductPage(page); 
//do stuff 
}); 

We could solve this by adding our page-objects to a custom fixture, and adding the custom fixture much like we add the page fixture that Playwright provides. This way we can immediately start creating tests: 

test('can add product to cart', async ({ productPage }) => { 
await productPage.addProduct(product); 
}); 
 
test('can remove product from cart', async ({ productPage }) => { 
await productPage.removeProduct(product); 
}); 
 
test('can change amount of products in cart', async ({ productPage }) => { 
await productPage.changeProductAmount(product, amount); 
}); 

This process contains the following steps: 

  1. Creating a file that contains your custom fixtures 

  2. Extending the Playwright base fixtures with our custom fixtures 

  3. Importing the extended fixtures from our custom fixtures file 

  4. Adding the fixture to our test 

In the above code snippet, you already saw step 4! Let’s show you how we get there. 

Let’s start by creating a custom fixture file in a directory customFixtures called drumroll pages.fixture.ts! 

In this file we will include the following to extend Playwrights base structure to also utilize our ProductPage page-object: 

import { test as base } from '@playwright/test'; 
import { ProductPage } from 'pageObjects/product.page.ts'; 

type CustomFixtures = { 
productPage: ProductPage; 
} 

export const test = base.extend<CustomFixtures>({ 
productPage: async ({ page }, use) => { 
await use(new ProductPage(page)); 
}, 
}); 
export { expect } from '@playwright/test'; //we can use the expect library in the same import this way 

And now all the remains is to use this extended test object instead of the base import from the Playwright library by importing it from this pages.fixture.ts file in our tests: 

import { test } from '../customFixtures/pages.fixture';

By using the extended test we imported from our custom fixture we can now freely add this productPage fixture in every test for that .spec file. 

Note that you can add more page objects to the same custom fixture file as your framework grows! You could do the same for custom functions you create that handle certain APIs and add these in a different fixture file, plenty of possibilities! 

Organizing the file structure, managing project dependencies with incorporated teardown features and using custom fixtures contribute to a robust and maintainable automated testing solution.