10 · Project — POM Framework with API Tests¶
Everything in Level 2 becomes one deliverable here: a test framework you could hand to a new team member on their first day. Page objects (01), robust waits (02), API tests (03), mocks (04), factories (05), configuration (06), reports (07), parallel execution (08), and cross-browser support (09) — assembled, not demonstrated.
The target: a repo where pytest -m smoke runs in under a minute, the full suite
runs in parallel, and a failure produces a screenshot, a URL, and a report you can
attach to a ticket.
1. Repository layout¶
qa-framework/
├── pytest.ini
├── requirements.txt
├── README.md
├── config/
│ └── environments.yaml # base URLs and API roots per environment
├── framework/
│ ├── __init__.py
│ ├── api_client.py # requests.Session wrapper
│ └── factories.py # factory_boy definitions
├── pages/
│ ├── __init__.py
│ ├── base_page.py # click / type_into / text_of — mechanics only
│ ├── login_page.py
│ ├── product_list_page.py
│ ├── cart_page.py
│ └── components/
│ ├── header_nav.py
│ └── search_widget.py
├── tests/
│ ├── conftest.py # driver, api, base_url, screenshot hook
│ ├── api/
│ │ ├── conftest.py
│ │ ├── test_auth.py
│ │ └── test_orders.py
│ ├── ui/
│ │ ├── conftest.py
│ │ ├── test_login.py
│ │ └── test_checkout.py
│ └── unit/
│ └── test_price_rules.py # mocked collaborators, no I/O
└── reports/ # gitignored; junit.xml, report.html, screenshots/
Three rules this layout enforces: locators live only in pages/, HTTP lives only
in framework/api_client.py, and tests/ contains assertions and nothing else.
2. Configuration¶
# pytest.ini
[pytest]
testpaths = tests
addopts =
-ra
--strict-markers
--tb=short
--junitxml=reports/junit.xml
--html=reports/report.html --self-contained-html
markers =
smoke: critical path, runs on every merge
api: hits a real HTTP service
ui: drives a browser
slow: over five seconds
# config/environments.yaml
local:
base_url: http://localhost:8000
api_root: http://localhost:8000/api/v1
staging:
base_url: https://staging.example.test
api_root: https://staging.example.test/api/v1
3. Root conftest.py¶
# tests/conftest.py
import os
from pathlib import Path
import pytest
import requests
import yaml
from selenium import webdriver
REPORTS = Path("reports")
def pytest_addoption(parser):
parser.addoption("--env", default="staging", choices=["local", "staging"])
parser.addoption("--browsers", default="chrome")
parser.addoption("--grid-url", default=None)
def pytest_generate_tests(metafunc):
if "browser_name" in metafunc.fixturenames:
metafunc.parametrize(
"browser_name",
metafunc.config.getoption("--browsers").split(","),
scope="session",
)
def pytest_configure(config):
(REPORTS / "screenshots").mkdir(parents=True, exist_ok=True)
@pytest.fixture(scope="session")
def settings(request):
env = request.config.getoption("--env")
return yaml.safe_load(Path("config/environments.yaml").read_text())[env]
@pytest.fixture(scope="session")
def base_url(settings):
return settings["base_url"]
@pytest.fixture(scope="session")
def api(settings):
with requests.Session() as session:
session.headers.update({"Accept": "application/json"})
session.base_url = settings["api_root"]
yield session
@pytest.fixture
def driver(browser_name, request):
grid_url = request.config.getoption("--grid-url")
options = {
"chrome": webdriver.ChromeOptions,
"firefox": webdriver.FirefoxOptions,
}[browser_name]()
options.add_argument("--headless=new" if browser_name == "chrome" else "--headless")
options.add_argument("--window-size=1440,900")
if grid_url:
driver = webdriver.Remote(command_executor=grid_url, options=options)
elif browser_name == "chrome":
driver = webdriver.Chrome(options=options)
else:
driver = webdriver.Firefox(options=options)
yield driver
report = getattr(request.node, "report_call", None)
if report is not None and report.failed:
driver.save_screenshot(str(REPORTS / "screenshots" / f"{request.node.name}.png"))
driver.quit()
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
setattr(item, f"report_{report.when}", report)
Note what is and isn't session-scoped. api and settings are cheap, shared, and
read-only — session scope is right. driver is function-scoped because a browser
carries cookies, local storage, and scroll position from test to test, and that's
exactly the state leak module 05 warned about.
4. Fast login — the biggest single speed win¶
Driving the login form in every UI test costs 5–10 seconds each. Authenticate once over the API, then inject the cookie.
# tests/ui/conftest.py
import pytest
@pytest.fixture
def logged_in(driver, api, base_url, make_user):
user = make_user(password="Valid#123")
response = api.post(f"{api.base_url}/auth/login",
json={"email": user.email, "password": "Valid#123"},
timeout=10)
assert response.status_code == 200, response.text[:500]
driver.get(f"{base_url}/favicon.ico") # must be on the domain to set a cookie
driver.add_cookie({"name": "session", "value": response.json()["token"]})
return user
Keep one test that logs in through the real form — that path needs coverage.
Every other test uses logged_in.
5. Tests, by layer¶
# tests/api/test_orders.py
import pytest
@pytest.mark.api
@pytest.mark.smoke
def test_create_order(api, make_user):
user = make_user()
response = api.post(f"{api.base_url}/orders",
json={"user_id": user.id, "sku": "SKU-1000", "qty": 2},
timeout=10)
assert response.status_code == 201, (
f"POST /orders returned {response.status_code}: {response.text[:500]}"
)
order = response.json()
assert order["total_cents"] == 200_000
@pytest.mark.api
@pytest.mark.parametrize("qty", [0, -1, 1_000_000])
def test_invalid_quantities_rejected(api, make_user, qty):
user = make_user()
response = api.post(f"{api.base_url}/orders",
json={"user_id": user.id, "sku": "SKU-1000", "qty": qty},
timeout=10)
assert response.status_code == 422
# tests/ui/test_checkout.py
import pytest
from pages.cart_page import CartPage
@pytest.mark.ui
@pytest.mark.smoke
def test_checkout_shows_correct_total(driver, base_url, logged_in):
cart = CartPage(driver, base_url).load()
cart.add_item("SKU-1000", qty=2)
assert cart.total_text() == "₹2,000.00"
# tests/unit/test_price_rules.py
from unittest.mock import patch
from framework import pricing
def test_discount_ignored_when_service_times_out():
with patch("framework.pricing.requests.get", side_effect=TimeoutError):
assert pricing.final_price(1000, code="SAVE10") == 1000
Three layers, three costs. The unit test runs in milliseconds and covers a failure mode the other two can't reach; the API test covers the calculation; the UI test covers only that the number reaches the screen.
6. How it runs¶
# local loop — chrome only, smoke only
pytest -m smoke
# pre-merge
pytest -n auto --env staging
# nightly, all browsers
pytest -n 8 --browsers chrome,firefox --env staging --alluredir=reports/allure-results
# a single failing test, serially, with full traceback
pytest tests/ui/test_checkout.py::test_checkout_shows_correct_total --tb=long
7. Acceptance checklist¶
Your framework is done when every row is true.
| # | Requirement | How to verify |
|---|---|---|
| 1 | No locator appears outside pages/ |
grep -rn "By\." tests/ returns nothing |
| 2 | No assert inside a page object |
grep -rn "assert" pages/ returns nothing |
| 3 | No time.sleep anywhere |
grep -rn "time.sleep" . returns nothing |
| 4 | Tests are order-independent | pytest -p randomly passes three times running |
| 5 | Suite is parallel-safe | pytest -n 4 passes; --lf then passes serially |
| 6 | Every test makes its own data | No shared fixed emails or ids |
| 7 | Data is cleaned up | DB/API record count is unchanged after a full run |
| 8 | Failures produce evidence | Force a failure; a screenshot exists in reports/ |
| 9 | Environment is a flag | --env local and --env staging both work unedited |
| 10 | Markers are enforced | A typo'd marker fails collection (--strict-markers) |
| 11 | Smoke suite is fast | pytest -m smoke finishes in under 60 seconds |
| 12 | A stranger can run it | README: install, run, and interpret the report in 3 commands |
8. Common ways this project goes wrong¶
A BasePage that grows into a god object
If BasePage gains login(), add_to_cart(), and get_order_total(), you
have one enormous page object wearing a base class's name. BasePage holds
mechanics — click, type, wait, read text. Page vocabulary goes in the page.
UI tests asserting business logic
Verifying tax rules, discount tiers, and rounding through the checkout screen means fifteen slow browser tests for something four API tests cover in a second. Test the calculation at the API layer; test once through the UI that the number is displayed.
Reports written but never published
reports/ in .gitignore and not declared as a CI artifact means the
evidence is deleted with the build workspace. Wire the artifact upload the
same day you add the flag.
A framework with no README
The measure of this project is not that it works on your machine — it's that someone else can run it without asking you. Three commands: install, run smoke, open the report.
How It Actually Works¶
Assembling Page Objects, fixtures, and config into one framework surfaces an interaction worth naming explicitly: fixture scope and Page Object statefulness can silently fight each other.
A scope="function" WebDriver fixture gives every test a fresh browser session (a
new WebDriver session ID, a new browser process or tab) — safe, but paying full
browser-launch cost per test. A scope="session" WebDriver shared across Page
Objects is fast, but now every Page Object instance you construct wraps the same
underlying session, so navigation state (cookies, current URL, open tabs) truly
persists between tests unless you explicitly reset it (e.g., driver.delete_all_
cookies(), navigating to a known start URL in a function-scoped fixture layered on
top). This is not a framework bug to work around cleverly — it's the same fixture-
caching mechanism from Level 1 applied to an object (the browser) that has far more
implicit, hard-to-reset state than a plain Python object, which is exactly why real
frameworks usually pair a session-scoped driver (expensive to create) with a
function-scoped "clean state" fixture that resets cookies/localStorage/URL before
each test — cheap to run, and it restores test independence without re-paying the
browser launch cost.
Stretch goals¶
- Visual regression. Add a fixture that captures a full-page screenshot and compares it against a committed baseline with a pixel-difference threshold. Make one CSS change and confirm it's caught.
- Contract testing. Validate every API response against a JSON Schema
(
jsonschemalibrary) instead of hand-written key sets, so a dropped field fails one shared check rather than slipping past six tests. - CI pipeline. Write a GitHub Actions workflow: smoke on every push, full
parallel suite nightly, JUnit annotations on the PR, and
reports/uploaded as an artifact. - Flakiness dashboard. Persist Allure history across runs and use it to list the five most-frequently-flaky tests over the last 30 builds. Fix the top one.
- Grid in Docker Compose. Stand up a hub with 2 Chrome and 2 Firefox nodes,
point the suite at it with
--grid-url, and compare wall time against local-n 4. - Accessibility pass. Inject
axe-coreviaexecute_scripton your three most important pages and fail the test on any critical violation. - Self-healing locators. Log every
NoSuchElementExceptionwith the page, locator, and a screenshot, and produce a weekly report of the most brittle locators in the suite. Then rewrite the worst three.