What should an ideal playwright setup look like? We know that as we start writing test automation scripts, the code maintenance and readability becomes crucial aspect of it. Therefore I think it is important to plan and structure your suite right from the start.
Here is what a decent playwright structure could look like:
π playwright/
- Root directory of the Playwright setup
βββ π tests/
- Contains test files
β βββ π login.spec.ts
- Login page test
β βββ π profile.spec.ts
- Profile page test
β βββ π product.spec.ts
- Product page test
β
βββ π pages/
- Page object models
β βββ π base.page.ts
- Base page class
β βββ π login.page.ts
- Login page interactions
β βββ π product.page.ts
- Product page interactions
β
βββ π fixtures/
- Test fixtures
β βββ π login-fixture.ts
- Login-related test fixture
β
βββ π constants/
- Stores constants like locators & URLs
β βββ π urls.ts
- URL constants
β βββ π strings.ts
- String constants
β βββ π locators/
- Contains element locators
β β βββ π login.ts
- Login page locators
β β βββ π product.ts
- Product page locators
β
βββ π utils/
- Utility functions
β βββ π helpers.ts
- General helper functions
β βββ π api/
- API interaction files
β β βββ π login.api.ts
- API calls for login
β β βββ π product.api.ts
- API calls for product
β
βββ π playwright.config.ts
- Playwright configuration file
βββ π package.json
- Project dependencies & scripts
A dilemma here can be that you might end up with a need for too many helper functions to perform a variety of different tests. In my experience I created individual methods to fill in fields or click buttons, then used them as needed in functions to basically perform a part of a test. This led to the problem of having to manage too many helper functions since e.g. the creation forms would have lots of fields followed by detail pages of those entities created etc.
It could be that you try to think of a page class as one that houses all functionalities in a given module. But you will see that a basic CRUD for any module results in a lot of code. For this reason it is recommended to start working with the largest or most complex module in your app so itβs easier to establish a direction for the remainder.
Another view point I came across was preventing yourself from overdoing the inheritance and abstraction principles. E.g. you donβt really need to write a function that takes a locator and types some text, thatβs exactly what playwrightβs locator.fill() is for.
So what do we do? Well Chatgpt says I can go into the same route as mentioned above but for each module. So it can look like this:
π pages/
βββ π product/
- Contains all product-related pages
β βββ π product.page.ts
- Main product page
β βββ π product.detail.page.ts
- Product details page
β βββ π product.create.page.ts
- Product creation page
β βββ π product.list.page.ts
- Product list page
β βββ π product.filter.page.ts
- Product filtering page
This allows for better readability and easier maintenance in future. But! hold on, itβs not that simple. You canβt just declare some classes and use them in another one, itβs not that simple. Thereβs a principle known as SOLID. SOLID is a set of five design principles for writing clean and maintainable object-oriented code:
- Single Responsibility Principle (SRP) β A class should have only one reason to change, meaning it should have only one job.
- Open/Closed Principle (OCP) β Software entities should be open for extension but closed for modification.
- Liskov Substitution Principle (LSP) β Subtypes must be substitutable for their base types without altering correctness.
- Interface Segregation Principle (ISP) β Clients should not be forced to depend on interfaces they do not use.
- Dependency Inversion Principle (DIP) β Depend on abstractions, not on concrete implementations.
To explain more, thereβs a concept known as Composition which an alternative to Inheritance. Instead of creating rigid class hierarchies, composition allows objects to be built using smaller, reusable components, making modifications and extensions easier. It also avoids the fragile base class problem, where changes in a parent class can unintentionally break subclasses. By promoting behavior delegation rather than deep inheritance trees, composition aligns better with the Open/Closed Principle, leading to more scalable and adaptable software design.
As an idea of what it might look like here are some code snippets:
// pages/product/product.list.page.ts
import { Page } from '@playwright/test';
export class ProductListPage {
constructor(private page: Page) {}
async searchForProduct(name: string) {
await this.page.locator('#search').fill(name);
await this.page.locator('#search-button').click();
}
async viewFirstProduct() {
await this.page.locator('.product-item').first().click();
}
}
// pages/product/product.detail.page.ts
import { Page } from '@playwright/test';
export class ProductDetailPage {
constructor(private page: Page) {}
async addToCart() {
await this.page.locator('#add-to-cart').click();
}
async getProductTitle() {
return await this.page.locator('.product-title').textContent();
}
}
// pages/product/product.create.page.ts
import { Page } from '@playwright/test';
export class ProductCreatePage {
constructor(private page: Page) {}
async createProduct(name: string, category: string) {
await this.page.locator('#product-name').fill(name);
await this.page.locator('#category').selectOption(category);
await this.page.locator('#save-product').click();
}
}
// pages/product/product.filter.page.ts
import { Page } from '@playwright/test';
export class ProductFilterPage {
constructor(private page: Page) {}
async filterByCategory(category: string) {
await this.page.locator('#category-filter').selectOption(category);
}
async getFilteredResultsCount() {
return await this.page.locator('.product-item').count();
}
}
// pages/product/product.page.ts
import { Page } from '@playwright/test';
import { ProductListPage } from './product.list.page';
import { ProductDetailPage } from './product.detail.page';
import { ProductCreatePage } from './product.create.page';
import { ProductFilterPage } from './product.filter.page';
export class ProductPage {
list: ProductListPage;
detail: ProductDetailPage;
create: ProductCreatePage;
filter: ProductFilterPage;
constructor(private page: Page) {
this.list = new ProductListPage(page);
this.detail = new ProductDetailPage(page);
this.create = new ProductCreatePage(page);
this.filter = new ProductFilterPage(page);
}
async open() {
await this.page.goto('/products');
}
}
// tests/product.spec.ts
import { test, expect } from '@playwright/test';
import { ProductPage } from '../pages/product/product.page';
test('Search and view product details', async ({ page }) => {
const productPage = new ProductPage(page);
await productPage.open();
await productPage.list.searchForProduct('Laptop');
await productPage.list.viewFirstProduct();
const title = await productPage.detail.getProductTitle();
expect(title).toContain('Laptop');
await productPage.detail.addToCart();
expect(await page.locator('#cart-count').textContent()).toBe('1');
});