Page Object Model¶
CWIQ E2E tests use the Page Object Model (POM) pattern to keep test code maintainable as the UI evolves.
The Page Object Model separates how to interact with a page from what to assert about it. Page object classes encapsulate locators and actions. Spec files contain only test logic — they call methods on page objects rather than working with raw Playwright locators directly.
Directory Layout¶
platform/ui/e2e/
├── pages/ # Page Object classes
│ ├── login.page.ts
│ ├── dashboard.page.ts
│ ├── workflows.page.ts
│ └── ...
├── fixtures/ # Playwright fixtures — shared setup for tests
│ ├── auth.fixture.ts # Provides an authenticated page context
│ ├── org.fixture.ts # Provides an organization context
│ └── test-data-factory.ts # Creates test data via API before tests run
├── helpers/ # Low-level API helper functions
│ └── api.ts # Typed wrappers around fetch() for the backend API
└── specs/ # Test specification files
├── auth/
│ └── login.spec.ts
├── admin/
│ └── users.spec.ts
└── workflows/
└── create.spec.ts
Naming conventions:
- Page object files:
{name}.page.ts - Fixture files:
{name}.fixture.ts - Spec files:
{name}.spec.ts
Writing a Page Object¶
A page object is a TypeScript class that accepts a Playwright Page and exposes typed locators and action methods. Locators are declared as readonly class properties.
// e2e/pages/login.page.ts
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign In' });
this.errorMessage = page.getByRole('alert');
}
async goto(): Promise<void> {
await this.page.goto('/login');
}
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async loginAndWaitForDashboard(email: string, password: string): Promise<void> {
await this.login(email, password);
await this.page.waitForURL('/dashboard');
}
}
Prefer getByRole, getByLabel, and getByTestId locators.
These locators are tied to the semantic structure of the page — not to CSS class names or DOM hierarchy. They are far less likely to break when the UI is restyled. Avoid .locator('div.some-class > button') unless there is no accessible alternative.
Writing a Spec File¶
Spec files import page objects and fixtures. They do not contain locators or Playwright API calls directly.
// e2e/specs/auth/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../../pages/login.page';
// This spec matches the unauthenticated project pattern
// so it runs with a clean browser session
test.describe('Login', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('redirects to dashboard after successful login', async ({ page }) => {
await loginPage.loginAndWaitForDashboard(
process.env.E2E_ADMIN_USERNAME!,
process.env.E2E_ADMIN_PASSWORD!,
);
await expect(page).toHaveURL('/dashboard');
});
test('shows error message for invalid credentials', async () => {
await loginPage.login('invalid@example.com', 'wrong-password');
await expect(loginPage.errorMessage).toBeVisible();
});
});
Fixtures¶
Fixtures extend Playwright's built-in test object to provide shared setup that multiple specs need. They are declared in e2e/fixtures/ and re-exported from a central index.ts.
auth.fixture.ts¶
Provides a page that is already at a specific application route with the authenticated storage state loaded.
// e2e/fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
type AuthFixtures = {
authenticatedPage: { goto: (path: string) => Promise<void> };
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// storageState is already loaded by the authenticated project config
await use({
goto: async (path: string) => {
await page.goto(path);
await page.waitForLoadState('networkidle');
},
});
},
});
test-data-factory.ts¶
Creates and tears down test data via the backend API before and after test runs. Using the API for setup is faster and more reliable than driving the UI to create test data.
// e2e/fixtures/test-data-factory.ts
import type { APIRequestContext } from '@playwright/test';
export class TestDataFactory {
constructor(private readonly request: APIRequestContext, private readonly baseURL: string) {}
async createWorkflow(name: string): Promise<{ id: string }> {
const response = await this.request.post(`${this.baseURL}/api/v1/workflows`, {
data: { name, description: 'Created by E2E test' },
});
return response.json();
}
async deleteWorkflow(id: string): Promise<void> {
await this.request.delete(`${this.baseURL}/api/v1/workflows/${id}`);
}
}
A Complete Test Using Fixtures¶
// e2e/specs/workflows/create.spec.ts
import { test, expect } from '@playwright/test';
import { WorkflowsPage } from '../../pages/workflows.page';
import { TestDataFactory } from '../../fixtures/test-data-factory';
test.describe('Workflow creation', () => {
test('creates a workflow and displays it in the list', async ({ page, request }) => {
const factory = new TestDataFactory(request, process.env.PLAYWRIGHT_BASE_URL!);
const workflowsPage = new WorkflowsPage(page);
await workflowsPage.goto();
await workflowsPage.openCreateDialog();
await workflowsPage.fillName('My Test Workflow');
await workflowsPage.submit();
// Assert the new workflow appears in the list
await expect(workflowsPage.workflowItem('My Test Workflow')).toBeVisible();
// Clean up
const workflows = await factory.createWorkflow('My Test Workflow');
await factory.deleteWorkflow(workflows.id);
});
});
Locator Guidelines¶
| Locator | Use When |
|---|---|
getByRole('button', { name: '...' }) |
Buttons, links, checkboxes, comboboxes |
getByLabel('...') |
Form inputs associated with a <label> |
getByText('...') |
Static text content that uniquely identifies an element |
getByTestId('...') |
Elements without semantic roles — add data-testid to the component |
getByPlaceholder('...') |
Inputs identified only by placeholder text |
Avoid:
- CSS selectors tied to Tailwind class names — they change with styling updates.
- XPath — verbose and brittle.
- Index-based selectors (
.nth(0)) unless there is no other option.
Related Documentation¶
- E2E Testing Overview — Test projects, directory structure, authentication flow
- Playwright Setup — Configuration, global setup, and running tests locally
- CI Integration — How the test job is configured in the pipeline