Skip to content

Test Design Patterns

Design patterns keep your test code organized, reusable, and easy to maintain.


Page Object Model (POM)

The most important pattern for UI tests. Each page of your app gets a class.

Why Use POM?

  • One place to update when UI changes
  • Tests read like user actions, not code
  • Easy to reuse page interactions

Implementation

from playwright.sync_api import Page

class LoginPage:
    def __init__(self, page: Page) -> None:
        self.page = page
        self._username = page.locator("[data-testid='username']")
        self._password = page.locator("[data-testid='password']")
        self._submit = page.locator("[data-testid='submit']")
        self._error = page.locator("[data-testid='error-message']")

    def navigate(self) -> None:
        self.page.goto("/login")

    def login(self, username: str, password: str) -> None:
        self._username.fill(username)
        self._password.fill(password)
        self._submit.click()

    def get_error_message(self) -> str:
        return self._error.text_content() or ""

Using POM in Tests

def test_successful_login(page: Page):
    login_page = LoginPage(page)
    login_page.navigate()
    login_page.login("alice", "password123")
    assert page.url.endswith("/dashboard")

def test_login_with_wrong_password(page: Page):
    login_page = LoginPage(page)
    login_page.navigate()
    login_page.login("alice", "wrong")
    assert login_page.get_error_message() == "Invalid credentials"

API Client Pattern

Wrap API calls in a reusable client class.

import requests

class UserClient:
    def __init__(self, base_url: str, token: str) -> None:
        self.base_url = base_url
        self.session = requests.Session()
        self.session.headers["Authorization"] = f"Bearer {token}"

    def create(self, name: str, email: str) -> dict:
        response = self.session.post(
            f"{self.base_url}/users",
            json={"name": name, "email": email},
        )
        response.raise_for_status()
        return response.json()

    def get(self, user_id: int) -> dict:
        response = self.session.get(f"{self.base_url}/users/{user_id}")
        response.raise_for_status()
        return response.json()

    def delete(self, user_id: int) -> None:
        response = self.session.delete(f"{self.base_url}/users/{user_id}")
        response.raise_for_status()

Using API Client in Tests

import pytest

@pytest.fixture
def user_client() -> UserClient:
    return UserClient("https://api.staging.com", "test-token")

def test_create_and_get_user(user_client: UserClient):
    created = user_client.create("Alice", "alice@test.com")
    fetched = user_client.get(created["id"])
    assert fetched["name"] == "Alice"

Factory Pattern

Create test data without repeating yourself.

from dataclasses import dataclass, field

@dataclass
class UserFactory:
    _counter: int = field(default=0, init=False, repr=False)

    @classmethod
    def build(
        cls,
        name: str | None = None,
        email: str | None = None,
        role: str = "user",
    ) -> dict:
        cls._counter += 1
        return {
            "name": name or f"User_{cls._counter}",
            "email": email or f"user_{cls._counter}@test.com",
            "role": role,
        }
def test_user_list():
    users = [UserFactory.build() for _ in range(3)]
    assert len(users) == 3
    assert users[0]["name"] != users[1]["name"]

def test_admin_user():
    admin = UserFactory.build(name="Admin", role="admin")
    assert admin["role"] == "admin"

Builder Pattern

For complex test data with many fields:

class RequestBuilder:
    def __init__(self) -> None:
        self._data: dict = {}
        self._headers: dict[str, str] = {}
        self._params: dict[str, str] = {}

    def with_body(self, data: dict) -> "RequestBuilder":
        self._data = data
        return self

    def with_auth(self, token: str) -> "RequestBuilder":
        self._headers["Authorization"] = f"Bearer {token}"
        return self

    def with_param(self, key: str, value: str) -> "RequestBuilder":
        self._params[key] = value
        return self

    def build(self) -> dict:
        return {
            "data": self._data,
            "headers": self._headers,
            "params": self._params,
        }
request = (
    RequestBuilder()
    .with_body({"name": "Alice"})
    .with_auth("my-token")
    .with_param("format", "json")
    .build()
)

Pattern Summary

Pattern Use For Key Benefit
POM UI tests One place to update locators
API Client API tests Reusable request logic
Factory Test data Quick, unique test data
Builder Complex objects Readable object construction

Best Practices

  • Use POM for all UI tests — never put locators directly in tests
  • Use API clients for all API tests — never use raw requests in tests
  • Use factories for generating test data
  • Keep patterns simple — do not over-engineer
  • Store page objects in a separate folder (e.g., pages/)
  • Store API clients in a separate folder (e.g., clients/)