10 · Project — POM Test Suite¶
Goal¶
Build a small but real multi-file test suite against
https://www.saucedemo.com/ (a public demo e-commerce app built
specifically for automation practice) that brings together everything from
this level: Page Object Model, fixtures, network mocking, file handling
concepts, and clean debugging output. No new concepts — just assembling
Level 2's pieces into one working project.
Project layout¶
saucedemo-suite/
├── conftest.py
├── pytest.ini
├── pages/
│ ├── base_page.py
│ ├── login_page.py
│ ├── inventory_page.py
│ └── cart_page.py
└── tests/
├── test_login.py
├── test_inventory.py
└── test_checkout.py
Configuration¶
# pytest.ini
[pytest]
addopts =
--screenshot=only-on-failure
--tracing=retain-on-failure
-n auto
testpaths = tests
# conftest.py
import pytest
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {**browser_context_args, "base_url": "https://www.saucedemo.com"}
Page objects¶
# pages/base_page.py
from playwright.sync_api import Page
class BasePage:
def __init__(self, page: Page):
self.page = page
self.cart_badge = page.locator(".shopping_cart_badge")
def cart_count(self) -> int:
if self.cart_badge.count() == 0:
return 0
return int(self.cart_badge.text_content())
# pages/login_page.py
from playwright.sync_api import Page
from pages.base_page import BasePage
class LoginPage(BasePage):
def __init__(self, page: Page):
super().__init__(page)
self.username = page.get_by_placeholder("Username")
self.password = page.get_by_placeholder("Password")
self.submit = page.get_by_role("button", name="Login")
self.error = page.locator("[data-test='error']")
def goto(self):
self.page.goto("/")
def login(self, username: str, password: str):
self.username.fill(username)
self.password.fill(password)
self.submit.click()
# pages/inventory_page.py
from playwright.sync_api import Page
from pages.base_page import BasePage
class InventoryPage(BasePage):
def __init__(self, page: Page):
super().__init__(page)
self.items = page.locator(".inventory_item")
self.sort_dropdown = page.locator(".product_sort_container")
def add_to_cart(self, item_name: str):
item = self.items.filter(has_text=item_name)
item.get_by_role("button", name="Add to cart").click()
def item_names(self) -> list[str]:
return self.items.locator(".inventory_item_name").all_text_contents()
def sort_by(self, label: str):
self.sort_dropdown.select_option(label=label)
# pages/cart_page.py
from playwright.sync_api import Page
from pages.base_page import BasePage
class CartPage(BasePage):
def __init__(self, page: Page):
super().__init__(page)
self.checkout_button = page.get_by_role("button", name="Checkout")
self.cart_items = page.locator(".cart_item")
def goto(self):
self.page.goto("/cart.html")
def item_count(self) -> int:
return self.cart_items.count()
Tests¶
# tests/test_login.py
from playwright.sync_api import expect
from pages.login_page import LoginPage
def test_standard_user_can_log_in(page):
login = LoginPage(page)
login.goto()
login.login("standard_user", "secret_sauce")
expect(page).to_have_url("https://www.saucedemo.com/inventory.html")
def test_locked_out_user_sees_error(page):
login = LoginPage(page)
login.goto()
login.login("locked_out_user", "secret_sauce")
expect(login.error).to_contain_text("locked out")
def test_wrong_password_shows_error(page):
login = LoginPage(page)
login.goto()
login.login("standard_user", "wrong-password")
expect(login.error).to_contain_text("do not match")
# conftest.py (addition)
import pytest
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage
@pytest.fixture
def inventory_page(page) -> InventoryPage:
login = LoginPage(page)
login.goto()
login.login("standard_user", "secret_sauce")
return InventoryPage(page)
# tests/test_inventory.py
from playwright.sync_api import expect
def test_can_add_item_to_cart(inventory_page):
inventory_page.add_to_cart("Sauce Labs Backpack")
assert inventory_page.cart_count() == 1
def test_sort_by_price_low_to_high_orders_items(inventory_page):
inventory_page.sort_by("Price (low to high)")
prices = inventory_page.page.locator(".inventory_item_price").all_text_contents()
numeric = [float(p.replace("$", "")) for p in prices]
assert numeric == sorted(numeric)
def test_all_six_products_are_listed(inventory_page):
names = inventory_page.item_names()
assert len(names) == 6
# tests/test_checkout.py
from playwright.sync_api import expect
from pages.cart_page import CartPage
def test_checkout_with_two_items(inventory_page):
inventory_page.add_to_cart("Sauce Labs Backpack")
inventory_page.add_to_cart("Sauce Labs Bike Light")
assert inventory_page.cart_count() == 2
cart = CartPage(inventory_page.page)
cart.goto()
assert cart.item_count() == 2
cart.checkout_button.click()
page = cart.page
page.get_by_placeholder("First Name").fill("Ada")
page.get_by_placeholder("Last Name").fill("Lovelace")
page.get_by_placeholder("Zip/Postal Code").fill("12345")
page.get_by_role("button", name="Continue").click()
page.get_by_role("button", name="Finish").click()
expect(page.get_by_text("Thank you for your order!")).to_be_visible()
Running it¶
What this project exercises¶
- POM (Module 1):
LoginPage,InventoryPage,CartPage, each owning its own locators, plus a sharedBasePagefor the cart badge. - Fixtures (Module 2):
browser_context_argsforbase_url, and aninventory_pagefixture chaining a login flow so checkout/inventory tests never repeat it. - Configuration (Module 7):
pytest.inibaking in screenshots, tracing, and parallelism. - Debugging practices (Module 9): tracing/screenshots on failure are already wired in before you ever hit a real failure.
How It Actually Works¶
This project's inventory_page fixture chaining a full login flow before
every test that needs it (Module 2's pattern) has a real protocol-level cost
worth being aware of: each of the three test_inventory.py/test_checkout.py
tests that depend on it re-runs the entire goto + two fill()s + click()
sequence — a fresh Page.navigate, two direct DOM value sets with
input/change events, and a full actionability-checked
Input.dispatchMouseEvent click — from scratch, because the fixture is
function-scoped by default and each test gets a brand-new
BrowserContext (Module 2's isolation model) with no session cookie yet.
Level 3 Module 2 (auth state reuse) shows the alternative: persisting the
authenticated context's storage state to disk once and injecting it into
new contexts via storage_state=..., which sets the session cookie through
Network.setCookie at context-creation time instead of re-running the login
UI flow — the same end state reached without repeating the CDP traffic
every single test.
sort_by() calling select_option(label=...) on .product_sort_container
sets the underlying <select>'s value directly and fires synthetic
input/change events (as covered in Level 1 Module 8), which is why
asserting on the resulting re-rendered price order (rather than the
dropdown's own value) is the meaningful check here — it confirms the app's
own JS actually reacted to the change event and re-sorted the list, not
merely that the dropdown's displayed selection changed.
Exercise — extend the suite¶
- Add a
test_remove_item_from_carttest using a newCartPage.removemethod you write, assertingcart_count()drops back to 0. - Add a
problem_userlogin test asserting the known SauceDemo bug where product images are broken/swapped — assert on thesrcattribute of.inventory_item_img imgdiffering from whatstandard_usersees. - Add a
page.routemock (Module 4) that intercepts SauceDemo's checkout completion request and forces a failure response, asserting your app-under-test shows some error state (or, if it doesn't handle the mocked failure gracefully, document that as a found bug — a legitimate and common outcome of E2E testing). - Run the full suite with
-n autoseveral times in a row to confirm it's stable under parallel execution with no shared-state flakiness.