DESIGN PATTERNS
FOR TESTING
Page Object Model • Screenplay Pattern • Test Fixtures
From Working Scripts to Maintainable Frameworks
A Beginner-Friendly Guide with Practical Examples
Study Reference Document
Introduction: Why Testing Needs Design Patterns
When you first start writing automated tests, they work fine. You write a script, it clicks buttons,
fills in forms, and checks results. But as your application grows, so does your test suite — and
suddenly everything becomes fragile. One small UI change breaks dozens of tests. Sound
familiar?
Design patterns for testing solve this exact problem. They are proven strategies for
organizing your test code so it’s reusable, readable, and resilient to change. This is the
difference between a script that “just works” and a professional testing framework.
The Core Problem
Without patterns, a single button rename (e.g., "Submit" → "Send") could break 50 tests. With
patterns, you change it in ONE place and all 50 tests keep working.
The Three Patterns We’ll Cover
Pattern What It Does Best For
Page Object Model (POM) Wraps each page/screen into a UI test automation (Selenium,
class with its elements and Playwright, Cypress)
actions
Screenplay Pattern Models tests as actors Complex workflows, BDD, teams
performing tasks toward goals that want highly readable tests
Test Fixtures Manages setup and teardown of Any kind of testing — unit,
test data and environment integration, end-to-end
Part 1: The Page Object Model (POM)
What is POM?
The Page Object Model is a design pattern where you create a class (a "page object") for each
page or major component in your application. This class contains all the locators (how to find
elements on the page) and actions (what a user can do on that page). Your tests then use these
page objects instead of directly interacting with the UI.
1.1 The Problem POM Solves
Imagine you have a login page. Without POM, every test that needs to log in will contain the
same CSS selectors and the same click/type logic scattered everywhere:
Without POM — The Messy Way
# test_login.py
def test_successful_login():
driver.find_element([Link], "username").send_keys("alice")
driver.find_element([Link], "password").send_keys("secret123")
driver.find_element(By.CSS_SELECTOR, "[Link]-btn").click()
assert driver.find_element(By.CLASS_NAME, "welcome").text == "Hi Alice"
# test_checkout.py — same login code duplicated!
def test_checkout_as_logged_in_user():
driver.find_element([Link], "username").send_keys("alice")
driver.find_element([Link], "password").send_keys("secret123")
driver.find_element(By.CSS_SELECTOR, "[Link]-btn").click()
# ... then do checkout steps
# test_profile.py — duplicated AGAIN!
def test_update_profile():
driver.find_element([Link], "username").send_keys("alice")
driver.find_element([Link], "password").send_keys("secret123")
driver.find_element(By.CSS_SELECTOR, "[Link]-btn").click()
# ... then do profile steps
The Danger
If the developer changes the login button from "[Link]-btn" to "[Link]-btn", you must
find and fix EVERY test that logs in. In a real project, that could be 30, 50, or even 100+ tests.
1.2 The POM Solution
With POM, all knowledge about the login page lives in ONE place — the LoginPage class. Tests
call simple, descriptive methods instead of dealing with selectors directly.
Step 1: Create the Page Object
# pages/login_page.py
class LoginPage:
# --- Locators (all in one place!) ---
URL = "/login"
USERNAME_INPUT = ([Link], "username")
PASSWORD_INPUT = ([Link], "password")
LOGIN_BUTTON = (By.CSS_SELECTOR, "[Link]-btn")
WELCOME_MSG = (By.CLASS_NAME, "welcome")
ERROR_MSG = (By.CLASS_NAME, "error-message")
def __init__(self, driver):
[Link] = driver
# --- Actions (readable method names) ---
def open(self):
[Link]([Link])
return self
def login(self, username, password):
[Link].find_element(*self.USERNAME_INPUT).send_keys(username)
[Link].find_element(*self.PASSWORD_INPUT).send_keys(password)
[Link].find_element(*self.LOGIN_BUTTON).click()
return self
def get_welcome_message(self):
return [Link].find_element(*self.WELCOME_MSG).text
def get_error_message(self):
return [Link].find_element(*self.ERROR_MSG).text
Step 2: Use It in Your Tests
# test_login.py
def test_successful_login(driver):
login_page = LoginPage(driver)
login_page.open().login("alice", "secret123")
assert login_page.get_welcome_message() == "Hi Alice"
# test_checkout.py — reuses the same page object!
def test_checkout_as_logged_in_user(driver):
LoginPage(driver).open().login("alice", "secret123")
checkout = CheckoutPage(driver)
checkout.add_item("Widget").complete_purchase()
# test_profile.py — same clean pattern!
def test_update_profile(driver):
LoginPage(driver).open().login("alice", "secret123")
profile = ProfilePage(driver)
profile.update_name("Alice Smith")
Now When the Button Changes...
You update ONE line in LoginPage (the LOGIN_BUTTON locator) and ALL tests keep working.
Zero duplication, zero headaches.
1.3 A Bigger Example: E-Commerce Test Suite
Here’s how a real project might organize its page objects. Each page in the app gets its own
class:
project/
├── pages/ # All page objects live here
│ ├── base_page.py # Shared logic (wait, scroll, etc.)
│ ├── login_page.py
│ ├── home_page.py
│ ├── product_page.py
│ ├── cart_page.py
│ └── checkout_page.py
├── tests/ # Test files use page objects
│ ├── test_login.py
│ ├── test_search.py
│ ├── test_cart.py
│ └── test_checkout.py
└── [Link] # Shared fixtures (driver setup, etc.)
The Base Page (shared utilities)
# pages/base_page.py
from [Link] import WebDriverWait
from [Link] import expected_conditions as EC
class BasePage:
def __init__(self, driver):
[Link] = driver
[Link] = WebDriverWait(driver, 10)
def click(self, locator):
[Link](EC.element_to_be_clickable(locator)).click()
def type_text(self, locator, text):
element = [Link](EC.visibility_of_element_located(locator))
[Link]()
element.send_keys(text)
def get_text(self, locator):
return [Link](EC.visibility_of_element_located(locator)).text
def is_visible(self, locator):
try:
[Link](EC.visibility_of_element_located(locator))
return True
except:
return False
A Page Object inheriting from BasePage
# pages/product_page.py
from pages.base_page import BasePage
class ProductPage(BasePage):
PRODUCT_TITLE = (By.CSS_SELECTOR, "[Link]-name")
PRICE_LABEL = (By.CLASS_NAME, "price")
QTY_INPUT = ([Link], "quantity")
ADD_TO_CART = ([Link], "add-to-cart-btn")
SUCCESS_TOAST = (By.CLASS_NAME, "toast-success")
def get_product_name(self):
return self.get_text(self.PRODUCT_TITLE)
def get_price(self):
return float(self.get_text(self.PRICE_LABEL).replace("$", ""))
def add_to_cart(self, quantity=1):
self.type_text(self.QTY_INPUT, str(quantity))
[Link](self.ADD_TO_CART)
return self
def is_success_shown(self):
return self.is_visible(self.SUCCESS_TOAST)
1.4 POM Golden Rules
Rule Why It Matters
Page objects contain locators + actions only Tests decide what to assert; pages just provide
data
Never put assertions inside page objects Keeps page objects reusable across many
different tests
Return self or the next page object from actions Enables method chaining:
login_page.open().login(...)
Use a BasePage for shared logic Avoids duplicating waits, clicks, and scroll
methods
One file per page (or major component) Easy to find and maintain
Name methods from the user’s perspective login(), add_to_cart(), search_for() — not
click_button_3()
Part 2: The Screenplay Pattern
What is the Screenplay Pattern?
The Screenplay Pattern models tests around Actors who have Abilities and perform Tasks to
achieve Goals. Instead of thinking "click this button, fill this field," you think "the user logs in, then
adds a product to their cart." It reads like a story.
2.1 How It Differs from POM
POM organizes code by pages. Screenplay organizes code by user intentions. The key building
blocks are:
Concept What It Represents Example
Actor A person or system using the "Alice", "Admin User", "API
app Client"
Ability Something the actor CAN do Browse the web, call an API,
query a database
Task A business-level action (can "Log in", "Place an order",
include many steps) "Reset password"
Interaction A single low-level action Click, type, scroll, read text
Question Something the actor wants to "What is the page title?", "Is the
know cart empty?"
2.2 Screenplay in Practice
Below is a simplified Python example to show the concept. Real implementations (like
Serenity/JS or ScreenPy) provide full frameworks, but the idea is the same.
Define Abilities
class BrowseTheWeb:
"""Ability: the actor can use a web browser."""
def __init__(self, driver):
[Link] = driver
class CallTheApi:
"""Ability: the actor can make API requests."""
def __init__(self, base_url):
self.base_url = base_url
Define the Actor
class Actor:
def __init__(self, name):
[Link] = name
[Link] = {}
def who_can(self, *abilities):
for ability in abilities:
[Link][type(ability)] = ability
return self
def uses_ability(self, ability_class):
return [Link][ability_class]
def attempts_to(self, *tasks):
for task in tasks:
task.perform_as(self)
def asks(self, question):
return question.answered_by(self)
Define Tasks (business-level actions)
class LoginAs:
def __init__(self, username, password):
[Link] = username
[Link] = password
def perform_as(self, actor):
driver = actor.uses_ability(BrowseTheWeb).driver
[Link]("/login")
driver.find_element([Link], "username").send_keys([Link])
driver.find_element([Link], "password").send_keys([Link])
driver.find_element([Link], "login-btn").click()
class AddToCart:
def __init__(self, product_name):
self.product_name = product_name
def perform_as(self, actor):
driver = actor.uses_ability(BrowseTheWeb).driver
[Link](f"/products/{self.product_name}")
driver.find_element([Link], "add-to-cart-btn").click()
Define Questions (what the actor wants to know)
class CartItemCount:
def answered_by(self, actor):
driver = actor.uses_ability(BrowseTheWeb).driver
text = driver.find_element([Link], "cart-count").text
return int(text)
The Test — Reads Like Plain English
def test_add_product_to_cart(driver):
alice = Actor("Alice").who_can(BrowseTheWeb(driver))
alice.attempts_to(
LoginAs("alice", "secret123"),
AddToCart("Blue Widget")
)
assert [Link](CartItemCount()) == 1
Notice How Readable That Is?
Even someone who doesn’t know Python can understand: "Alice logs in, adds a Blue Widget to
the cart, and then we check that her cart has 1 item."
2.3 When to Choose Screenplay Over POM
Use POM When... Use Screenplay When...
Your app is mostly page-based navigation Workflows span many pages/channels
Your team is new to test automation You need tests readable by non-developers
You want a simple, proven pattern You test APIs, UIs, and databases together
Your test suite is small to medium Your test suite is large and complex
You need a quick start You want maximum reusability of actions
Practical Advice
Most teams should start with POM. It’s simpler, widely understood, and well supported by every
test tool. Move to Screenplay when POM starts to feel limiting — for example, when your tasks
span multiple pages or mix UI and API interactions.
Part 3: Test Fixtures
What Are Test Fixtures?
A test fixture is everything that needs to be in place BEFORE a test runs and cleaned up AFTER
it finishes. This includes creating test data, launching a browser, connecting to a database,
setting up mock services, and tearing it all down afterward. Well-designed fixtures make tests
independent, repeatable, and clean.
3.1 The Problem Without Proper Fixtures
Without fixtures, setup and teardown code gets duplicated everywhere, or worse, tests start
depending on each other:
# BAD: Setup logic copy-pasted into every test
def test_user_can_edit_profile():
driver = [Link]() # duplicated
[Link]("[Link] # duplicated
driver.find_element(...).send_keys() # login duplicated
driver.find_element(...).click() # login duplicated
# ... actual test logic
[Link]() # easy to forget!
def test_user_can_change_password():
driver = [Link]() # same setup again!
[Link]("[Link] # same setup again!
driver.find_element(...).send_keys() # login again!
driver.find_element(...).click() # login again!
# ... actual test logic
[Link]() # repeated cleanup
3.2 Fixtures with pytest (Python)
pytest is one of the most popular test frameworks for Python, and its fixture system is
exceptionally powerful. Fixtures are defined with the @[Link] decorator and automatically
injected into tests that request them by name.
Basic Fixture: Browser Setup & Teardown
# [Link] — shared across all tests in the directory
import pytest
from selenium import webdriver
@[Link]
def driver():
"""Launches a browser before the test, closes it after."""
browser = [Link]()
browser.maximize_window()
[Link]("[Link]
yield browser # <— test runs here
[Link]() # Cleanup ALWAYS runs, even if test fails
Using the Fixture in Tests
# test_profile.py
def test_user_can_edit_profile(driver): # Just ask for "driver"!
login_page = LoginPage(driver)
login_page.open().login("alice", "pass123")
# ... rest of test
def test_user_can_change_password(driver): # Same — fresh browser each time
login_page = LoginPage(driver)
login_page.open().login("alice", "pass123")
# ... rest of test
How It Works
pytest sees that the test function has a parameter called "driver". It looks for a fixture with that
name, runs its setup code, passes the result to the test, then runs the teardown (after yield) when
the test finishes.
Layered Fixtures: Composing Setup
Fixtures can use other fixtures, building up layers of setup. This is where things get really
powerful:
# [Link]
@[Link]
def driver():
browser = [Link]()
yield browser
[Link]()
@[Link]
def logged_in_user(driver): # Uses the driver fixture!
"""A logged-in session ready for testing."""
login_page = LoginPage(driver)
login_page.open().login("alice", "secret123")
yield driver # Test gets a driver that’s already logged in
@[Link]
def cart_with_item(logged_in_user): # Uses logged_in_user → uses driver
"""A logged-in user with one item in their cart."""
product_page = ProductPage(logged_in_user)
product_page.open_product("widget-1").add_to_cart()
yield logged_in_user
Tests Become Ultra-Clean
def test_checkout_shows_correct_total(cart_with_item):
"""No setup code at all — just the actual test!"""
checkout = CheckoutPage(cart_with_item)
[Link]()
assert checkout.get_total() == 29.99
3.3 Fixture Scopes: Controlling Lifetime
Not everything needs to be created fresh for every single test. pytest lets you control how long a
fixture lives:
Scope Created & Destroyed Use For
function (default) Once per test function Browser instances, fresh test
data
class Once per test class Shared state within a group of
related tests
module Once per test file Database connections,
expensive setups
session Once for the entire test run Starting a test server, creating a
test database
@[Link](scope="session")
def test_database():
"""Created ONCE for the entire test run."""
db = create_test_database()
run_migrations(db)
yield db
[Link]()
@[Link](scope="function")
def clean_db(test_database):
"""Resets data before EACH test, but reuses the db connection."""
test_database.clear_all_tables()
test_database.seed_default_data()
yield test_database
3.4 Test Data Factories
Hard-coded test data is brittle and hard to maintain. A data factory (or builder) generates test
data on demand with sensible defaults:
# Hard-coded data scattered across tests
def test_checkout():
user = {"name": "Alice", "email": "alice@[Link]",
BAD
"address": "123 Main St", "city": "Springfield",
"zip": "62701", "card": "4111111111111111"}
# 50 other tests have similar hard-coded blocks...
# A factory with smart defaults
class UserFactory:
@staticmethod
def create(**overrides):
defaults = {
"name": [Link](),
"email": [Link](),
GOOD "address": [Link](),
}
return {**defaults, **overrides}
# Tests only specify what matters for THAT test:
def test_name_displayed_on_profile():
user = [Link](name="Alice Smith")
# Only the name matters here, rest is auto-generated
3.5 Fixture Golden Rules
Rule Why It Matters
Always clean up (use yield or finally) Prevents leftover data from breaking other tests
Tests must be independent Never rely on another test running first
Use the narrowest scope possible function scope is safest; widen only when justified
Don’t over-fixture If setup is 2 lines, just put it in the test
Name fixtures descriptively logged_in_admin is better than setup2
Keep fixtures close to their tests [Link] in the same directory, not 5 levels up
Part 4: Putting It All Together
In practice, you combine all three patterns. Fixtures handle setup, page objects (or screenplay
tasks) handle interaction, and your tests stay clean and focused.
A Complete Example: POM + Fixtures
# [Link]
@[Link]
def driver():
browser = [Link]()
[Link](BASE_URL)
yield browser
[Link]()
@[Link]
def logged_in(driver):
LoginPage(driver).open().login("testuser", "pass123")
yield driver
# pages/search_page.py
class SearchPage(BasePage):
SEARCH_BOX = ([Link], "search")
RESULTS = (By.CSS_SELECTOR, ".result-item")
def search_for(self, query):
self.type_text(self.SEARCH_BOX, query)
return self
def get_result_count(self):
return len([Link].find_elements(*[Link]))
# tests/test_search.py
def test_search_returns_results(logged_in):
search = SearchPage(logged_in)
search.search_for("laptop")
assert search.get_result_count() > 0
def test_search_no_results_for_gibberish(logged_in):
search = SearchPage(logged_in)
search.search_for("xyzzy99999")
assert search.get_result_count() == 0
Notice the Layers
Fixture handles browser + login. Page object handles search interactions. Test focuses ONLY on
the behavior being verified. Each layer has one job.
The Maturity Ladder: Scripts → Framework
Here’s how your test automation evolves as you adopt these patterns:
Level What It Looks Like Maintainability
Level 1: Raw Scripts Selectors and logic mixed Very Low — any change breaks
directly in tests. Copy-paste many tests
everywhere.
Level 2: Helper Functions Common actions extracted into Low — better, but still messy
functions, but no structure.
Level 3: Page Object Model Each page has a class. Tests Good — UI changes affect one
use page methods. Locators in file
one place.
Level 4: POM + Fixtures Setup/teardown managed. Tests Very Good — professional
are clean. Data is generated. quality
Level 5: Screenplay + Fixtures Tests read like English. Actions Excellent — scales to large
are reusable across contexts. teams
Quick Reference Cheat Sheet
Pattern Core Idea Start Using When...
Page Object Model One class per page. Locators + You have more than 3 UI tests
actions in the class. Tests call
methods.
Base Page Shared methods (click, type, You write your second page
wait) inherited by all page object
objects.
Screenplay Actor → Tasks → Questions. POM feels limiting or tests span
Models user intent, not page many systems
structure.
Test Fixtures Reusable setup/teardown. You copy-paste setup code for
Injected into tests automatically. the 2nd time
Data Factories Generate test data with smart You have hard-coded test data
defaults. Override only what in 3+ places
matters.
Fixture Scopes Control fixture lifetime: per-test, Some setup is too slow to run for
per-file, or per-session. every test
"A test that can’t be understood is a test that can’t be trusted."
— A core principle of sustainable test automation