This article introduces a design pattern for end-to-end testing using Playwright. This pattern is an extension of the Page Object Model, aimed at improving test code readability and reducing the increase in code volume when adding more test scenarios or test data variations. This pattern is adopted by SVQK. A working implementation example and its test results are available in the following repositories:
Test Architecture
First, the structure of end-to-end testing using Playwright is illustrated below:
- TestRunner: The Playwright test execution engine. It calls the test code (TestCode) and manages the start, progression, and completion of test scenarios. In implementation, it is the test function provided by Playwright.
- TestCode: The main implementation of test scenarios and test cases. It describes user actions and expected behaviors, delegating browser control to BrowserController.
- BrowserController: Directly controls the browser (Browser) using Playwright's browser operation APIs. It handles page transitions, button actions, element validations, etc. This is the page instance in Playwright.
- Browser: A browser instance controlled by Playwright. It provides the user interface and accesses the application under test.
- TestSubject: The actual web application under test. It is operated externally via the browser and its behavior is validated.
Component Structure
Next is the component structure of TestCode
:
- Spec: The entry point for Playwright tests. It generates Models (test data) using the Factory, and executes test scenarios using the Facade and PageObject.
- Facade: Executes a series of screen operations and validations across multiple screens using PageObject.
- PageObject: Executes a series of operations and validations within a single screen using PageElement. One class per screen and one method per operation sequence within the screen.
- PageElement: Uses APIs provided by BasePageElement to perform UI operations and validations. One class per screen, and one method per screen element operation/validation.
- Factory: Generates Models used in testing.
Each component is explained in detail below.
Spec
The Spec component runs the test scenario. The scenario is implemented as a Playwright test case, and operations and validations are implemented using the Facade and PageObject. Here is an example scenario for "Registering a task":
- Log in as user
admin
- Select project
Demo project
- Register a task
import { DryRun } from '@arch/DryRun';
import LoginFacade from '@facades/LoginFacade';
import ProjectFactory from '@factories/ProjectFactory';
import TaskFactroy from '@factories/TaskFactory';
import UserFactory from '@factories/UserFactory';
import TopPage from '@pages/top/TopPage';
import { test, expect } from '@playwright/test';
test('Register a task', async ({ page }) => {
const dryRun = DryRun.build();
const topPage = new TopPage({ page, dryRun });
const loginFacade = new LoginFacade();
const admin = UserFactory.createAdmin();
const demoProject = ProjectFactory.createDemoProject();
const projectPage = await loginFacade.loginToProject(topPage, admin, demoProject);
const taskInputPage = await projectPage.gotoTaskInputPage();
const task = TaskFactroy.createTask();
await taskInputPage.input(task);
});
Using Factories, Facades, and PageObjects instead of raw element manipulation makes the scenario's data (Model) and transitions clearer.
PageObject
PageObject handles a series of operations and validations within a screen. These are implemented using PageElement. Here's an example PageObject for a login screen:
import ChangePasswordPage from '@pages/changePassword/ChangePasswordPage';
import LoginPageElement from './LoginPageElement';
import BasePageElement from '@arch/BasePageElement';
import TopPage from '@pages/top/TopPage';
import { UserModel } from '@models/UserModel';
export default class LoginPage {
private readonly loginPageEl: LoginPageElement;
constructor(page: BasePageElement) {
this.loginPageEl = new LoginPageElement(page);
}
async login(user: UserModel) {
await this.loginPageEl.inputUserName(user.username);
await this.loginPageEl.inputPassword(user.password);
await this.loginPageEl.clickLoginButton();
return new TopPage(this.loginPageEl);
}
async firstLogin(user: UserModel) {
await this.login(user);
return new ChangePasswordPage(this.loginPageEl);
}
}
Each method in PageObject represents a screen-level action. If it includes a screen transition, the next PageObject is returned.
PageElement
PageElement is responsible for operations and validations of individual screen elements. One method per element operation/validation is implemented using BasePageElement. Example:
import BasePageElement from '@arch/BasePageElement';
export default class LoginPagePageElement extends BasePageElement {
get pageNameKey() {
return 'login';
}
async inputUserName(userName: string) {
await this.inputText('#username', userName);
}
async inputPassword(password: string) {
await this.inputText('#password', password);
}
async clickLoginButton() {
await this.click('#login-form input[name="login"]');
}
}
This allows developers to write logic using only screen-specific selectors, without being aware of Playwright APIs.
BasePageElement
BasePageElement is the superclass of PageElement, providing utility methods using the Playwright API. These methods standardize element access and allow abstracting complex UI behavior. Example:
import { expect, type Page } from '@playwright/test';
export default abstract class BasePageElement {
page: Page;
constructor(page: BasePageElement | { page: Page }) {
this.page = page.page;
}
protected async open(path: string) {
await this.page.goto(path);
}
protected async inputText(selector: string, value: any) {
await this.page.locator(selector).fill(value.toString());
}
protected async select(selector: string, value: any) {
const select = this.page.locator(selector);
await select.click();
const ariaControls = await select
.locator('div.ng-input > input')
.getAttribute('aria-controls');
await this.page.locator(`#${ariaControls}`).getByText(value.toString()).click();
}
}
The select
method handles custom UI components not based on standard HTML.
- Closed state:
- Open state:
Facade
Facade coordinates multiple screen-level actions using PageObjects. Each method starts with one PageObject and returns another. Example:
import ProjectModel from '@models/ProjectModel';
import { UserModel } from '@models/UserModel';
import TopPage from '@pages/top/TopPage';
export default class LoginFacade {
async loginToProject(topPage: TopPage, user: UserModel, project: ProjectModel) {
const loginPage = await topPage.open();
await loginPage.login(user);
const projectPage = await topPage.selectProject(project);
projectPage.gotoWorkPackages();
return projectPage;
}
}
Factory
Factory is used to generate test Models. This enables reuse across scenarios. Example:
import { UserModel } from '@models/UserModel';
export default class UserFactory {
static createAdmin() {
return {
username: 'admin',
password: 'admin'
} as UserModel;
}
}
Conclusion
This article introduced a design pattern that extends the Page Object Model. While the component structure is more complex than the basic POM, it enables scaling test scenarios and data patterns with clarity and minimal code. SVQK offers a reference implementation and a code generation tool to automate this structure. You can try it out in the Quickstart.