Skip to content

UI Test Design Patterns

Page Object Model (POM)

The foundational pattern for UI test automation. One class per page or significant component. Tests call methods, never selectors.

Test  →  LoginPage.login(user, password)  →  Browser
Test  →  DashboardPage.get_welcome_text()  →  Browser

Minimal POM Implementation

from playwright.sync_api import Page


class LoginPage:
    _USERNAME = '[data-testid="username"]'
    _PASSWORD = '[data-testid="password"]'
    _SUBMIT   = '[data-testid="submit"]'
    _ERROR    = '[data-testid="error-msg"]'

    def __init__(self, page: Page) -> None:
        self._page = page

    def login(self, username: str, password: str) -> "DashboardPage":
        self._page.fill(self._USERNAME, username)
        self._page.fill(self._PASSWORD, password)
        self._page.click(self._SUBMIT)
        return DashboardPage(self._page)

    def get_error_text(self) -> str:
        return self._page.inner_text(self._ERROR)

POM Rules

Rule Reason
No assertions inside page objects Pages describe capability, not expectations
Return typed page objects after navigation Catches wrong-page bugs at call time
One locator definition per element Change once, all methods benefit
Prefer data-testid over CSS class Survives redesigns

Composition Over Inheritance

Large pages decompose into components:

class CheckoutPage:
    def __init__(self, page: Page) -> None:
        self._page = page
        self.address_form = AddressFormComponent(page)
        self.payment_form = PaymentFormComponent(page)
        self.order_summary = OrderSummaryComponent(page)

Screenplay Pattern

An actor-centric alternative to POM for complex, multi-role UI automation.

Core Concepts

Actor  →  performs  →  Task
Task   →  uses      →  Interaction
Actor  →  asks      →  Question
Actor  →  has       →  Ability (e.g., BrowseTheWeb)

When to Use Screenplay Over POM

Criterion POM Screenplay
Multi-actor scenarios (admin + user) Hard Natural
Reusable business tasks Fragile First-class
Complex, nested flows God objects Composable
Simple CRUD flows Good fit Over-engineered

Implementation Sketch

class Actor:
    def __init__(self, name: str, abilities: list) -> None:
        self.name = name
        self._abilities = {type(a): a for a in abilities}

    def can(self, ability_type: type):
        return self._abilities[ability_type]

    def attempts_to(self, *tasks) -> None:
        for task in tasks:
            task.perform_as(self)

    def asks(self, question) -> object:
        return question.answered_by(self)
class Login:
    def __init__(self, username: str, password: str) -> None:
        self._username = username
        self._password = password

    def perform_as(self, actor: Actor) -> None:
        browser = actor.can(BrowseTheWeb)
        browser.fill('[data-testid="username"]', self._username)
        browser.fill('[data-testid="password"]', self._password)
        browser.click('[data-testid="submit"]')

POM vs Screenplay — Decision Guide

Is there more than one actor in your tests?         → Screenplay
Do you have complex, reusable multi-step user flows?→ Screenplay
Is the team small and the app CRUD-focused?         → POM
Are tests written by manual QA with some Python?    → POM

Both patterns share the same rule: tests never contain selectors.