English | 繁體中文
A general-purpose web end-to-end (E2E) testing framework built with Playwright, Pytest, and Allure, following the Page Object Model (POM). The framework core — BasePage, browser / context fixtures, layered configuration, and YAML-driven selectors / test data — is site-agnostic and reusable across projects; YouTube (www.youtube.com) serves as the demo target.
Live Allure report: https://dopiz.github.io/web-e2e-automation-testing-playwright/
The demo scenarios drive YouTube and verify that a video actually starts playing — the video's readyState must reach HAVE_CURRENT_DATA.
| Test | Case | Expected |
|---|---|---|
test_search_channel_and_play_video |
Search mrbeast, open the channel, play a video |
✅ pass (smoke) |
test_search_channel_and_play_video |
Search aespa, open the channel, play a video |
⏭️ skipped — is_skip declared in test data |
test_search_shows_video_results |
Search term playwright tutorial |
✅ pass (regression) |
test_search_shows_video_results |
Search term pytest fixtures |
✅ pass (regression) |
| Tool | Version | Purpose |
|---|---|---|
| uv | latest | Python package / venv manager |
| Pytest | 9.0.2 | Test framework |
| Playwright | 1.58.0 | Browser automation (chromium / firefox / webkit) |
| pytest-rerunfailures | 16.1 | Auto-retry flaky tests |
| allure-pytest | 2.15.3 | Emits Allure results, @allure.step, attachments |
| Allure | latest | Report generator & viewer (allure serve) |
| PyYAML | 6.0.3 | Config, selector & test-data loading |
| Ruff | 0.15.7 | Linter & formatter |
The tree below shows the site-agnostic core (top) and the demo target (youtube/ subfolders). To automate a new site, add sibling folders under elements/, pages/, testdata/, and tests/ — the core is untouched.
.
├── .github/workflows/ # CI: run tests + publish Allure report to Pages, Claude PR review
├── pages/
│ ├── base_page.py # BasePage: locator resolution, @step decorator, click_if_visible ← CORE
│ └── youtube/ # Demo POM (one Page Object per screen state)
│ ├── components/ # Reusable cross-page components (search_bar)
│ ├── base.py # YouTubeBasePage: config setup / component composition
│ ├── home.py search.py channel.py video.py
├── elements/ # YAML selectors (XPath / CSS), mirrors pages/ layout
│ └── youtube/ # channel / home / search / search_bar / video .yaml
├── testdata/ # Data-driven cases — one yaml per test class, one key per test
│ └── youtube/ # search_and_play.yaml / search_results.yaml
├── tests/
│ ├── conftest.py # Fixture wiring: browser / context / page / data / auth_session ← CORE
│ └── youtube/ # test_search_and_play.py (smoke) / test_search_results.py
├── configuration/ # Layered config (deep-merged) ← CORE
│ ├── default.yaml # default_timeout, viewport, responsive profile, URLs
│ └── staging.yaml # Environment overrides
├── common/ # Business-semantic constants (e.g. VideoReadyState)
├── utils/ # Site-agnostic utilities ← CORE
│ ├── helper.py # Cached YAML loaders (Config / Element / Data), singleton
│ ├── auth.py # AuthSession: login-once storage_state reuse across fresh contexts
│ ├── allure_report.py # Allure integration (per-case parameter splitting)
│ ├── decorator.py # `parametrize` shorthand for data-driven tests
│ └── singleton.py # Singleton metaclass
├── conftest.py # Root pytest hooks: logging, Allure screenshots / HTML snapshots ← CORE
├── pytest.ini # Default pytest options & markers
├── ruff.toml # Ruff lint / format config
├── .pre-commit-config.yaml # Ruff pre-commit hooks
└── pyproject.toml # Dependencies (managed by uv, locked by uv.lock)
Everything under pages/base_page.py, configuration/, and utils/ (YAML loaders, AuthSession, Allure integration) carries no business semantics — it knows how to resolve selectors, build contexts, and collect artifacts, but nothing about YouTube. A new site plugs in by adding a folder per layer:
- BasePage resolves selectors from YAML into Playwright
Locators (which auto-wait on every action) and provides shared utilities (open,locator,click_if_visible,scroll). - Page Objects subclass a per-site base (e.g.
YouTubeBasePage) that composes shared components (search bar) and exposesopen()from config. One class per screen state; actions return the next Page Object for fluent chaining; assertions stay in tests. - Browser fixtures (
tests/conftest.py) map--browseronto Playwright's engines —chromium/firefox/webkit— and an unknown name fails loudly with a usage error instead of silently falling back.
Selectors live in YAML under elements/, decoupled from page logic. When the UI changes, update the YAML — no Python touched. Selectors support {placeholder} templating, filled at call time via kwargs.
Because YouTube is an SPA, previous pages stay in the DOM as hidden nodes — so selectors are scoped to the active page container and filtered by :visible, and unions cover A/B-served layout variants:
# elements/youtube/channel.yaml
VIDEOS: 'ytd-browse:not([hidden]) a.ytLockupMetadataViewModelTitle[href^="/watch"]:visible, ytd-browse:not([hidden]) ytd-grid-video-renderer a#video-title:visible'Configuration lives in configuration/ and resolves through three layers (low to high):
default.yaml— the shared base. Parameters common to every environment (default locator timeout, viewport, responsive profile, URLs) are declared once and reused everywhere.- Per-environment overrides.
--env=stagingdeep-mergesstaging.yamlonto the base, so each environment inherits the defaults and only re-declares what it changes. TEST_*environment variables. A runtime channel overriding any value — letting CI inject secrets (e.g. account tokens) without committing them. Double underscore maps to nesting, e.g.TEST_YOUTUBE__ENTRY_URL→youtube.entry_url.
Mobile emulation is driven separately by --device (a Playwright device preset name); responsive or any unknown name falls back to the configured browser.responsive viewport (width / height / pixel ratio / user agent).
Every interaction goes through Playwright Locators, which auto-wait for the element to be actionable (visible, stable, enabled) before acting — no hardcoded sleeps, no wait-condition boilerplate:
locator(element_key, **kwargs)resolves a YAML selector into aLocator; clicks / fills auto-wait for actionabilityexpect(...)assertions in tests auto-retry until the condition holds or times outclick_if_visiblehandles genuinely optional elements (ad skip button, collapsed search bar) without failing when they never appearpage.wait_for_functionasserts the true end state in the browser (videoreadyState)
youtube_home.open()
search_page = youtube_home.search_bar.search(keyword="mrbeast")
channel_page = search_page.go_to_channel()
video_page = channel_page.go_to_video(index=0)Test data lives in testdata/{site}/*.yaml — one file per test class, one key per test function. Each key holds a list of cases; every case carries its own description (used as the test id) plus optional is_skip / is_xfail flags (with skip_reason / xfail_reason), handled centrally by the data fixture.
# testdata/youtube/search_and_play.yaml
search_and_play:
- keyword: "mrbeast"
video_index: 3
description: "Search MrBeast channel and play video"A small parametrize decorator (utils/decorator.py) wraps the repetitive pytest.mark.parametrize boilerplate and feeds each case into the indirect data fixture:
test_data = DataHelper().read("youtube/search_and_play")
class TestYouTubeSearch:
@parametrize(test_data["search_and_play"])
def test_search_channel_and_play_video(self, data, youtube_home):
... # `data` is one case dictTests that need no data simply skip the decorator and the data parameter. Each case's fields are also flattened into individual Allure parameters (utils/allure_report.py), so the report shows keyword / description instead of one truncated dict blob.
Function-scoped contexts give every test a clean browser state, which also wipes login state. Instead of sharing one context across tests (state leakage), utils/auth.py caches the product of login — Playwright's storage_state (cookies + localStorage) — and injects it into each fresh context via the logged_in_page fixture: login runs once per session, every test still gets an isolated context.
Expiry is handled in two layers: cookies nearing their expires timestamp trigger a proactive re-login, and an optional validate callback catches server-side revocation after injection (one re-login retry, then fail loudly). Services opt in by overriding the auth_session fixture with their own login flow — see the docstring in tests/conftest.py.
The root conftest.py hooks into pytest's reporting to capture screenshots (page.screenshot()) after every test and HTML snapshots (page.content()) on failure, both attached to the Allure report. Each Page Object action is logged as an Allure step via the @step decorator.
A trace is recorded for every test (screenshots + DOM snapshots + sources). On failure it is saved to artifacts/{test_name}.zip; on pass it is discarded to keep artifacts lean.
uv run playwright show-trace artifacts/<test_name>.zipThe Trace Viewer replays the whole test: an action timeline with screenshots, DevTools-style DOM snapshots at each step, network requests, console logs, and the source line each action maps back to.
Ruff is the linter / formatter, wired into a pre-commit hook:
uv run pre-commit install # one-time setup
uv run ruff check . # lint
uv run ruff format . # formatDependencies are declared in pyproject.toml and locked in uv.lock:
git clone git@github.com:Dopiz/web-e2e-automation-testing-playwright.git
cd web-e2e-automation-testing-playwright
uv syncPlaywright ships its own browser builds — no local browser or driver setup needed:
uv run playwright installuv run pytest # all tests
uv run pytest -m smoke # only smoke testsCustom options defined via pytest_addoption:
| Option | Default | Description |
|---|---|---|
--env |
default |
Config environment merged from configuration/ (e.g. default, staging) |
--browser |
chromium |
Browser engine: chromium / firefox / webkit. An unsupported name fails with a usage error listing the supported browsers. |
--headless |
False |
Run in headless mode |
--device |
None |
Playwright device preset name for mobile emulation (e.g. iPhone 12 Pro, Pixel 7). responsive or any unknown name falls back to the browser.responsive profile in config. Firefox does not support mobile emulation — the option is ignored there with a warning. |
# Firefox in headless mode
uv run pytest --browser=firefox --headless
# Staging environment
uv run pytest --env=staging
# Emulate a specific mobile device
uv run pytest --device="Pixel 7"
# A single test case
uv run pytest tests/youtube/test_search_and_play.py::TestYouTubeSearch::test_search_channel_and_play_video
# By marker (strict-markers is enabled; smoke / regression / slow / flaky)
uv run pytest -m smoke
# Auto-retry flaky tests as an outer safety net
uv run pytest --reruns 2 --reruns-delay 1Any config value can also be overridden at runtime via TEST_* environment variables; double underscore maps to nesting, e.g. TEST_YOUTUBE__ENTRY_URL → youtube.entry_url.
# allure-pytest writes results to allure-results/ (enabled by default in pytest.ini)
uv run pytest --alluredir=allure-results
# Generate & open the HTML report
allure serve allure-resultsThe report includes test results, test steps (each Page Object action via @step), per-case parameters (flattened from test data), screenshots (after every test), and HTML snapshots (on failure). Playwright trace zips for failed tests land in artifacts/ for local replay via playwright show-trace.
A full E2E strategy spans several layers — the ones shown in this demo are marked; the rest build on the same framework.
A fast sanity check of the core user path — search, open a channel, play a video — marked smoke so CI can gate on it before the heavier suite. (example: test_search_channel_and_play_video)
Drive a real user flow across multiple pages and assert the true end state — not just that elements exist. (example: search → open channel → play video → assert the video reaches HAVE_CURRENT_DATA, i.e. it actually plays)
Design cases by input class (one representative per class) via testdata/*.yaml, cutting run time without losing coverage — e.g. search keywords spanning distinct content classes (mrbeast vs. playwright tutorial vs. pytest fixtures). Expected-failure and skip cases are declared in data, not code. (example: the aespa case marked is_skip with its reason in data)
The same suite runs on chromium / firefox / webkit and under mobile emulation via --device (Playwright device presets, with a configurable responsive fallback), so layout and behavior are verified across engines and viewports.
Auto-waiting locators everywhere, SPA-aware selectors scoped to the active page container and filtered by :visible (so hidden leftovers of previous pages never match), click_if_visible for genuinely optional UI (ads), and pytest-rerunfailures as an outer safety net. On failure, screenshots + HTML snapshots + a full Playwright trace are captured automatically for triage.
The same framework extends beyond the demo:
- Authenticated flows — reuse login state across tests via
AuthSession(utils/auth.py) instead of logging in per test. - Visual regression — the screenshot hook already captures per-test PNGs; diff them against baselines to catch visual regressions.
- Accessibility / performance — inject axe-core or read
performancetimings inside a Page Object step and assert on the result. - Parallel execution — add
pytest-xdistonce the suite grows enough to warrant it (function-scoped contexts are already isolation-safe).
Tests are data-driven: one YAML file per test class, one key per test function, each key holding a list of cases.
| Test | Case | Expected |
|---|---|---|
test_search_channel_and_play_video |
Search mrbeast, open channel, play a video |
✅ video reaches HAVE_CURRENT_DATA (smoke) |
test_search_channel_and_play_video |
Search aespa, open channel, play a video |
⏭️ skipped — is_skip declared in test data |
test_search_shows_video_results |
Search term playwright tutorial |
✅ URL carries the query, ≥ 5 results rendered |
test_search_shows_video_results |
Search term pytest fixtures |
✅ URL carries the query, ≥ 5 results rendered |
Triggered on push to main or manually via workflow_dispatch.
- Test — Sets up Python + uv, installs Playwright browsers, runs
uv run pytest tests/youtube --headless --env=staging --browser=chromium --alluredir=allure-results, uploadsallure-results - Report — Generates the Allure report and deploys it to GitHub Pages
Triggered on pull request (opened, synchronize, reopened).
- Uses Claude Code Action to review PR changes
- Posts inline comments on new issues, resolves fixed threads, minimizes outdated reviews
- Submits a review (approve / request changes / comment)
- Checks: Playwright best practices, POM patterns, Ruff code style, YAML config consistency
Required secret: ANTHROPIC_API_KEY (GITHUB_TOKEN is provided automatically by GitHub Actions)
