Page Object, Page Component, Screenplay, and Test Abstraction Patterns: Guided Hands-On Workflow
Refactor a deliberately duplicated loopback catalog suite into a Page Object, a ProductCard component, and a small task layer. The workflow keeps assertions visible, reacquires DOM state after rerender, and finishes with a framework-free Screenplay-style comparison.
Learning objectives
- Create a disposable local catalog whose filtering rerenders product nodes so abstraction lifetime matters.
- Identify duplication and responsibility mixing in a direct Selenium suite before refactoring it.
- Implement one Page Object and one Page Component using locators plus explicit conditions rather than cached elements.
- Add a small task-oriented business action without hiding the navigation/outcome that tests assert.
- Compare the refactored suite with a minimal actor/task/question Screenplay-style sketch.
- Record session, URL, DOM, application, and evidence state so the refactor does not reduce observability.
1. Scenario and safety boundary
The AUT is a synthetic catalog served only from
127.0.0.1. Filtering recreates product-card nodes,
adding a product changes an in-page cart counter, and no
account/network/backend state exists. This deliberately gives us
enough behavior to discuss stale references, state queries, and task
boundaries without introducing credentials or production systems.
2. Create the local fixture
The following example makes the Create the local fixture behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
mkdir selenium-ch13-workflow
cd selenium-ch13-workflow
python -m venv .venv
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
# Linux/macOS: source .venv/bin/activate
python -m pip install "selenium==4.47.0"
The following example makes the Create the local fixture behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
from pathlib import Path
root = Path("site")
root.mkdir(exist_ok=True)
(root / "index.html").write_text("""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Academy Catalog</title>
<style>
body{font-family:system-ui,sans-serif;max-width:900px;margin:2rem auto;padding:0 1rem}
header,.toolbar,.product{border:1px solid #8886;border-radius:.65rem;padding:1rem;margin:.75rem 0}
#catalog{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:.75rem}
button,input{padding:.45rem .65rem}.price{font-weight:700}
</style></head><body>
<header data-testid="site-header"><h1>Academy Catalog</h1><p>Cart: <strong id="cart-count">0</strong></p><p id="last-action">ready</p></header>
<div class="toolbar"><label>Filter <input id="filter" data-testid="filter" autocomplete="off"></label></div>
<section id="catalog" aria-live="polite"></section>
<script>
const products = [
{sku:'adapter', name:'USB-C Adapter', price:'19.00'},
{sku:'cable', name:'Fiber Cable', price:'29.00'},
{sku:'sensor', name:'Lab Sensor', price:'49.00'}
];
let cart = 0;
const catalog = document.querySelector('#catalog');
const filter = document.querySelector('#filter');
const cartCount = document.querySelector('#cart-count');
const lastAction = document.querySelector('#last-action');
function render(){
const term = filter.value.trim().toLowerCase();
const visible = products.filter(p => p.name.toLowerCase().includes(term));
catalog.replaceChildren(...visible.map(p => {
const card = document.createElement('article');
card.className = 'product';
card.dataset.testid = 'product-card';
card.dataset.sku = p.sku;
card.innerHTML = `<h2 data-testid="product-name">${p.name}</h2><p class="price" data-testid="product-price">$${p.price}</p><button data-testid="add" type="button">Add</button>`;
card.querySelector('[data-testid="add"]').addEventListener('click', () => {
cart += 1;
cartCount.textContent = String(cart);
lastAction.textContent = `added:${p.sku}`;
});
return card;
}));
}
filter.addEventListener('input', render);
render();
</script></body></html>""", encoding="utf-8")
print(root.resolve())
Save the Python block as make_fixture.py, run it, then
start a loopback server:
python make_fixture.py
python -m http.server 8782 --bind 127.0.0.1 --directory site
3. Preflight and predictions
-
A fresh session should see three product cards and cart count
0. -
Typing
Cablererenders the catalog, so any product WebElement captured before the filter change should be treated as short-lived. -
Adding
USB-C Adaptershould update both cart count to1and#last-actiontoadded:adapter. - The refactor must not change those browser/AUT outcomes; it only changes where locators, waits, and intent live in the test code.
4. Start with duplicated direct Selenium code
The following example makes the Start with duplicated direct Selenium code behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
URL = "http://127.0.0.1:8782/"
def scenario_filter():
driver = webdriver.Chrome()
try:
driver.get(URL)
field = driver.find_element(By.CSS_SELECTOR, "[data-testid='filter']")
field.send_keys("Cable")
WebDriverWait(driver, 3).until(
lambda d: len(d.find_elements(By.CSS_SELECTOR, "[data-testid='product-card']")) == 1
)
names = [e.text for e in driver.find_elements(By.CSS_SELECTOR, "[data-testid='product-name']")]
assert names == ["Fiber Cable"]
finally:
driver.quit()
def scenario_add():
driver = webdriver.Chrome()
try:
driver.get(URL)
cards = driver.find_elements(By.CSS_SELECTOR, "[data-testid='product-card']")
adapter = next(c for c in cards if "USB-C Adapter" in c.text)
adapter.find_element(By.CSS_SELECTOR, "[data-testid='add']").click()
WebDriverWait(driver, 3).until(
lambda d: d.find_element(By.ID, "cart-count").text == "1"
)
assert driver.find_element(By.ID, "last-action").text == "added:adapter"
finally:
driver.quit()
The code is valid, but selectors, wait semantics, and page vocabulary are duplicated in scenario functions. As the suite grows, the maintenance blast radius grows with them.
5. Extract a Page Component for one product card
The following example makes the Extract a Page Component for one product card behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
from selenium.webdriver.common.by import By
class ProductCard:
NAME = (By.CSS_SELECTOR, "[data-testid='product-name']")
PRICE = (By.CSS_SELECTOR, "[data-testid='product-price']")
ADD = (By.CSS_SELECTOR, "[data-testid='add']")
def __init__(self, root):
self.root = root
def name(self) -> str:
return self.root.find_element(*self.NAME).text
def price_text(self) -> str:
return self.root.find_element(*self.PRICE).text
def add(self) -> None:
self.root.find_element(*self.ADD).click()
The component stores only its current root reference and child
locators. Because catalog filtering rerenders card nodes, callers
must not retain a ProductCard across that rerender. The
Page Object will construct fresh components when asked.
6. Extract the Page Object and centralize synchronization
The following example makes the Extract the Page Object and centralize synchronization behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
class CatalogPage:
ROOT = (By.ID, "catalog")
CARD = (By.CSS_SELECTOR, "[data-testid='product-card']")
FILTER = (By.CSS_SELECTOR, "[data-testid='filter']")
CART_COUNT = (By.ID, "cart-count")
LAST_ACTION = (By.ID, "last-action")
def __init__(self, driver, base_url: str):
self._driver = driver
self.base_url = base_url
WebDriverWait(driver, 3).until(lambda d: d.title == "Academy Catalog")
WebDriverWait(driver, 3).until(lambda d: d.find_element(*self.ROOT).is_displayed())
@classmethod
def open(cls, driver, base_url: str) -> "CatalogPage":
driver.get(base_url)
return cls(driver, base_url)
def products(self) -> list[ProductCard]:
return [ProductCard(root) for root in self._driver.find_elements(*self.CARD)]
def product_named(self, name: str) -> ProductCard:
return next(card for card in self.products() if card.name() == name)
def filter_products(self, text: str) -> None:
field = self.driver.find_element(*self.FILTER)
field.clear()
field.send_keys(text)
WebDriverWait(self._driver, 3).until(
lambda d: bool(self.products()) and all(
text.lower() in card.name().lower() for card in self.products()
)
)
def cart_count(self) -> int:
return int(self._driver.find_element(*self.CART_COUNT).text)
def wait_for_cart_count(self, expected: int) -> None:
WebDriverWait(self._driver, 3).until(lambda d: self.cart_count() == expected)
def last_action(self) -> str:
return self._driver.find_element(*self.LAST_ACTION).text
The constructor checks the page contract but does not assert
business outcomes. products() reacquires card roots
each time. The wait is attached to the application condition
introduced by filtering, not hidden as a fixed delay.
7. Add a task-oriented abstraction only where it adds domain meaning
The following example makes the Add a task-oriented abstraction only where it adds domain meaning behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
from selenium.webdriver.support.ui import WebDriverWait
class AddProductToCart:
def __init__(self, product_name: str):
self.product_name = product_name
def perform(self, page: CatalogPage) -> None:
before = page.cart_count()
page.product_named(self.product_name).add()
page.wait_for_cart_count(before + 1)
The task coordinates a reusable business action. It does not assert that the product “should” be in the cart; the test will make that expectation explicit. For a one-line action used once, a task class would be ceremony rather than value.
8. The refactored tests speak in page and task vocabulary
The following example makes the The refactored tests speak in page and task vocabulary behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
import unittest
from selenium import webdriver
from catalog import AddProductToCart, CatalogPage
URL = "http://127.0.0.1:8782/"
class CatalogTests(unittest.TestCase):
def setUp(self):
self.driver = webdriver.Chrome()
self.page = CatalogPage.open(self.driver, URL)
def tearDown(self):
self.driver.quit()
def test_filter(self):
self.page.filter_products("Cable")
self.assertEqual([p.name() for p in self.page.products()], ["Fiber Cable"])
def test_add_product(self):
AddProductToCart("USB-C Adapter").perform(self.page)
self.assertEqual(self.page.cart_count(), 1)
self.assertEqual(self.page.last_action(), "added:adapter")
def test_price_is_observable(self):
cable = self.page.product_named("Fiber Cable")
self.assertEqual(cable.price_text(), "$29.00")
Save ProductCard, CatalogPage, and
AddProductToCart together in catalog.py;
save the test block as test_catalog.py. The tests
retain the behavior assertions. A UI locator change belongs in
page/component code; a changed business expectation belongs in the
test. Driver lifecycle is still outside the page object.
9. Screenplay-style sketch without an external framework
The following example makes the Screenplay-style sketch without an external framework behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
from dataclasses import dataclass
@dataclass
class Actor:
name: str
catalog: CatalogPage
def attempts_to(self, *tasks) -> None:
for task in tasks:
task.perform_as(self)
def asks(self, question):
return question.answered_by(self)
@dataclass
class AddProduct:
name: str
def perform_as(self, actor: Actor) -> None:
AddProductToCart(self.name).perform(actor.catalog)
class CurrentCartCount:
def answered_by(self, actor: Actor) -> int:
return actor.catalog.cart_count()
# Example test vocabulary:
# learner = Actor("Learner", page)
# learner.attempts_to(AddProduct("USB-C Adapter"))
# assert learner.asks(CurrentCartCount()) == 1
This vocabulary is useful when many scenarios share domain tasks/questions across multiple views. It is not automatically better for a small suite, and it still delegates browser mechanics to page/component services.
10. State and evidence map
The following table organizes the key choices and evidence for State and evidence map. Use it together with the surrounding prose so the rows serve as a comparison aid rather than standalone rules.
| Action | State read/changed | Verification |
|---|---|---|
| construct Page Object | current session/title/catalog root | page contract waits succeed |
| filter products | filter DOM value + rerendered card node set | fresh component list contains matching names |
| add product task | button event + cart/application state | cart count and last-action observable state |
| test assertion | query result only | failure message remains scenario-specific |
| teardown | browser session/profile | driver quits even after test failure |
11. Challenge: choose the right boundary
Add a scenario that proves all visible products have a non-empty
price. Decide first: should “get visible product prices” be a Page
Object method, a ProductCard query, a task, or direct Selenium in
the test? A maintainable answer keeps the price query on
ProductCard, obtains current components from
CatalogPage, and keeps the expectation in the test.
Explain why before writing code.
12. Cleanup and next step
Stop the loopback server, quit all browser sessions, and remove the disposable project directory when finished. Lesson 3 turns the refactor into explicit design choices: when abstraction helps, when it hides too much, and how to select a pattern for suite scale and diagnostic needs.
Knowledge check
Why does CatalogPage.products() create fresh
ProductCard objects?
Filtering rerenders the card nodes; reacquiring current roots prevents long-lived cached references from becoming stale.
Why does AddProductToCart wait but not assert the
final business expectation?
The task needs the action to complete deterministically, while the test should own the scenario expectation and failure meaning.
What responsibility remains outside the Page Object in the example?
Creating/quitting WebDriver, choosing browser configuration, and evidence/test-runner lifecycle.
When would the Screenplay-style layer be excessive?
When a small suite has few workflows and page/component methods already express intent clearly; extra actor/task/question types would add ceremony without reducing coupling.
What changes after the refactor in browser/AUT behavior?
Nothing should. The refactor changes code organization, not the intended session, DOM, or application outcomes.
Official references and version notes
- Selenium 4.47 release notes — stable binding/Grid baseline pinned for this chapter.
- Selenium downloads — current stable client and Server/Grid releases.
- Page Object Models — page services, assertion placement, page component composition, and encapsulation guidance.
- Design patterns and development strategies — page/object and command-oriented alternatives for maintainable suites.
- Domain-specific language — express test intent in user/domain terms rather than UI mechanics.
- Avoid sharing state — isolate test data and create a new WebDriver instance per test where practical.
- Fresh browser per test — clean browser/session state guidance.
- Generating application state — repetitive setup should normally use lower-layer APIs rather than browser UI.
Version-sensitive behavior was rechecked against current Selenium primary documentation on 2026-08-28. Mandatory examples pin Selenium Python 4.47.0 and Python 3.10+, use a supported locally installed Chromium-family browser with Selenium Manager, and target only loopback fixtures. Page Object, Page Component, task/DSL, and Screenplay-style structures are test-architecture patterns rather than capabilities negotiated with the browser. The mandatory path uses Python standard-library unittest for the small suite so no external test-architecture framework is required. The Screenplay-style example is intentionally framework-free and remains an organizational layer over ordinary Selenium WebDriver.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.