A reusable Python QA archetype for UI and API testing. Built on Playwright and Pytest with Allure reporting. Clone it, point it at your target system, write tests.
- What you get
- Prerequisites
- Setup from scratch
- Running tests
- Project layout
- Configuration
- Authentication
- Adding tests
- Markers
- Reporting
- Code quality
- Continuous integration
- Troubleshooting
- Adopting this archetype
- License
- Page Object Model with a
BasePagefoundation,LoginPageplaceholder, and component composition - API client layer using Playwright's
APIRequestContext(shared runtime with the browser) - OAuth2 / OIDC auth via a cached
AuthClientwith TOTP retry across windows - Type-safe configuration via
pydantic-settingsand.envfiles (SecretStrfor credentials) - Allure + pytest-html reports with screenshot, video, and trace attachments on failure
- Parallel execution (
pytest-xdist) and automatic retry of flaky tests (pytest-rerunfailures) - Test data factories via Faker, with Pydantic models for shape validation
- Structured logging via
structlog - GitHub Actions matrix across Chromium / Firefox / WebKit plus a lint + type check job
- Ruff (lint + format), mypy strict, pre-commit hooks
- Markers:
smoke,regression,ui,api,e2e,slow
- Python 3.11+ (
python3.11 --versionshould print3.11.xor higher) - Git
- Allure CLI (optional, only if you want to view the Allure HTML report locally —
brew install allureon macOS)
# 1. Clone (or use this repo as a template)
git clone <your-fork-url>.git
cd playwright-pytest-atlas
# 2. Create and activate a Python 3.11 virtual environment
python3.11 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 3. Upgrade pip and install the project in editable mode
pip install --upgrade pip
pip install -e ".[dev]" # runtime + ruff/mypy/pre-commit
# or, if you need OTP / JWT helpers:
pip install -e ".[dev,auth]" # also pulls pyotp and pyjwt
# 4. Install the Playwright browsers
playwright install chromium # lightest option (~100 MB)
# or, for all three browsers + OS deps:
playwright install --with-deps
# 5. Configure the environment
cp .env.example .env
# Edit .env — defaults already point to public demo targets so the smoke
# suite runs out of the box. Replace QA_BASE_URL / QA_API_BASE_URL for
# your real system under test.
# 6. Run the smoke suite to validate everything works
pytest -m smoke -v
# Expected: 2 passed (1 UI + 1 API) against playwright.dev and jsonplaceholder.typicode.comNo
requirements.txt. Dependencies live in pyproject.toml under[project].dependencies(runtime) and[project.optional-dependencies](dev,auth).pip install -e .reads them directly.
After the initial setup, every future session is just:
source .venv/bin/activate
pytest -m smoke| Goal | Command |
|---|---|
| Smoke suite | pytest -m smoke or make smoke |
| Full suite | pytest or make test |
| UI tests only | pytest -m ui or make ui |
| API tests only | pytest -m api or make api |
| End-to-end only | pytest -m e2e or make e2e |
| Compound selection | pytest -m "smoke and ui" pytest -m "regression and not slow" |
| Single file | pytest tests/ui/test_example_ui.py -v |
| Single test | pytest tests/ui/test_example_ui.py::test_home_page_displays_get_started -v |
| Substring match on test names | pytest -k home |
| Parallel (auto-detect cores) | pytest -n auto |
| Switch browser one-off | pytest --browser=firefox -m ui |
| Multiple browsers in one run | pytest --browser=chromium --browser=firefox |
By default tests run headless. To see the actual browser window:
# Show the browser, slow down each action by ~1s so you can follow along
pytest tests/ui/test_example_ui.py --headed --slowmo 1000 -v
# Step through interactively with the Playwright Inspector (also disables timeouts)
PWDEBUG=1 pytest tests/ui/test_example_ui.py::test_home_page_displays_get_started --headedTo run headed permanently for your local sessions, set QA_HEADLESS=false in your .env.
On macOS the browser window often opens behind your terminal. Cmd+Tab or check the dock if you do not see it pop forward.
.
├── framework/ Reusable building blocks (the archetype core)
│ ├── pages/ Page Object Model
│ │ ├── base_page.py BasePage foundation
│ │ ├── example_page.py Sample page object (delete when adapting)
│ │ └── login_page.py Keycloak-style login placeholder
│ ├── components/ UI fragments scoped to a Locator
│ │ └── base_component.py
│ ├── api/ HTTP clients on top of APIRequestContext
│ │ ├── base_client.py BaseAPIClient with verb shortcuts + ok-helper
│ │ ├── example_client.py Sample client (delete when adapting)
│ │ └── auth_client.py OAuth2 / OIDC token client with OTP retry
│ ├── auth/ Auth helpers
│ │ ├── otp.py TOTP generation (requires the [auth] extras)
│ │ └── token_cache.py In-process Token cache with TTL
│ ├── data/ Test data
│ │ ├── models.py Pydantic schemas
│ │ └── factories.py Faker-driven builders
│ └── utils/ Cross-cutting helpers
│ ├── logger.py structlog setup + get_logger()
│ └── assertions.py SoftAssert context manager
├── config/
│ └── settings.py pydantic-settings (env-driven, QA_ prefix)
├── tests/
│ ├── conftest.py Root: settings, page/api/auth fixtures, Allure hooks
│ ├── ui/ UI tests (subfolder conftest is a placeholder)
│ ├── api/ API tests (subfolder conftest is a placeholder)
│ └── e2e/ Flows combining UI + API
├── reports/ Allure results, pytest-html, videos, traces (gitignored)
├── .github/workflows/
│ └── tests.yml Chromium/Firefox/WebKit matrix + lint job
├── .env.example Configuration template (copy to .env)
├── .gitignore
├── .pre-commit-config.yaml Ruff + standard hooks
├── LICENSE MIT
├── Makefile Common commands
├── pyproject.toml Deps, pytest, ruff, mypy config
└── README.md
Fixture rule. Cross-cutting fixtures (page objects, API clients used by e2e) live in the root tests/conftest.py so every test type can see them. Subfolder conftests at tests/ui/ and tests/api/ are placeholders for genuinely UI-only or API-only fixtures.
All runtime settings are env-driven with the QA_ prefix. See .env.example and config/settings.py for the full schema. Set real env vars in CI; never commit secrets.
Common knobs:
| Variable | Default | Purpose |
|---|---|---|
QA_ENVIRONMENT |
local |
local / dev / staging / prod |
QA_BASE_URL |
https://playwright.dev |
Base URL for UI tests |
QA_API_BASE_URL |
https://jsonplaceholder.typicode.com |
Base URL for API tests |
QA_BROWSER |
chromium |
chromium, firefox, or webkit |
QA_HEADLESS |
true |
false to show the browser |
QA_SLOW_MO_MS |
0 |
Add latency between Playwright actions |
QA_DEFAULT_TIMEOUT_MS |
30000 |
Per-action timeout |
QA_NAVIGATION_TIMEOUT_MS |
30000 |
Page navigation timeout |
QA_RECORD_VIDEO |
false |
Record videos for every test |
QA_RECORD_TRACE |
retain-on-failure |
off / on / retain-on-failure |
QA_CAPTURE_SCREENSHOT |
only-on-failure |
off / on / only-on-failure |
Auth-related variables (all optional) are documented in .env.example and the Authentication section below.
Auth is opt-in. Leave QA_USERNAME / QA_PASSWORD blank and tests run anonymously. Set them and the auth fixtures activate automatically.
Install the auth extras if you need TOTP or JWT decoding:
pip install -e ".[dev,auth]"The storage_state_path session fixture logs in once through LoginPage, persists cookies + localStorage to disk, and every subsequent browser context loads that state. No re-login per test.
Defaults match a Keycloak-style two-step form. Override class attributes (username_label, password_label, submit_button_name) or methods on a project-specific subclass.
Depend on the ui_login fixture when you need to exercise the login flow itself or want a clean slate:
def test_dashboard_after_fresh_login(ui_login, page):
page.goto("/dashboard")
...Set QA_AUTH_TOKEN_URL and depend on api_token (a Token dataclass) or auth_headers (a {"Authorization": "Bearer ..."} dict):
def test_admin_endpoint(jsonplaceholder, auth_headers):
response = jsonplaceholder.get("/admin/users", headers=auth_headers)
assert response.okThe token is cached for 15 minutes inside TokenCache. AuthClient handles OTP retries across TOTP windows when QA_OTP_SECRET is set.
For SAML, PAT exchange, header-based auth, or anything non-standard: subclass AuthClient, override _build_payload / _fetch_token, then replace the auth_client fixture in your project conftest.
# framework/pages/dashboard_page.py
from playwright.sync_api import Locator, expect
from framework.pages.base_page import BasePage
class DashboardPage(BasePage):
url_path = "/dashboard"
@property
def greeting(self) -> Locator:
return self.page.get_by_role("heading", name="Welcome")
def wait_for_loaded(self) -> None:
expect(self.greeting).to_be_visible()Expose it via a fixture. For cross-cutting use (UI + e2e), add it in the root tests/conftest.py; for UI-only, add it in tests/ui/conftest.py:
@pytest.fixture
def dashboard_page(page, base_url) -> DashboardPage:
return DashboardPage(page, base_url)# framework/api/users_client.py
from framework.api.base_client import BaseAPIClient
from framework.data.models import User
class UsersClient(BaseAPIClient):
def create(self, user: User) -> dict:
return self.expect_ok(
self.post("/users", data=user.model_dump())
).json()# tests/ui/test_dashboard.py
import pytest
@pytest.mark.ui
@pytest.mark.smoke
def test_dashboard_greets_user(dashboard_page):
dashboard_page.open()
assert dashboard_page.greeting.is_visible()Tag tests with markers declared in pyproject.toml:
@pytest.mark.ui
@pytest.mark.smoke
def test_something(...):
...Run subsets:
pytest -m smoke
pytest -m "smoke and ui"
pytest -m "regression and not slow"
pytest -m "e2e or smoke"Adding a new marker requires registering it in [tool.pytest.ini_options].markers in pyproject.toml (we run with --strict-markers).
Three reports are generated for every run, written to reports/:
| Report | Path | View |
|---|---|---|
| Allure (raw) | reports/allure-results/ |
make allure (requires the allure CLI) |
| Pytest HTML | reports/html/report.html |
Open the file in a browser |
| Playwright artifacts (screenshot / video / trace) | reports/playwright/ |
View traces with playwright show-trace <file> |
On failure, screenshots are automatically attached to the Allure report and a Playwright trace is retained at reports/playwright/<test-name>/trace.zip. Open it with:
playwright show-trace reports/playwright/<test-name>/trace.zipRuff (lint + format), mypy strict, and pre-commit hooks are pre-wired.
make lint # ruff check
make format # ruff format
make type # mypy strict on framework + config
make check # lint + type
pre-commit install # install git hooks (also runs on every commit)
pre-commit run --all-files # run all hooks on demand.github/workflows/tests.yml runs on every push and pull request:
testjob: matrix across Chromium / Firefox / WebKit on Python 3.11. Runs the smoke suite by default, in parallel viapytest-xdist, with one automatic retry on flake.lintjob: ruff check + ruff format check + mypy strict.
Reports are uploaded as build artifacts (reports-chromium-py3.11, etc.) with 14-day retention.
To trigger an ad-hoc run with a custom marker expression: open the Actions tab → tests → Run workflow, and provide a marker expression (default smoke).
You're not in the venv. Re-activate: source .venv/bin/activate. Or you skipped the editable install — re-run pip install -e ".[dev]".
Install Python 3.11+. On macOS: brew install python@3.11. The default python3 on macOS is often 3.9 which is below the minimum.
Two common causes:
- The smoke test is too fast (~2s) — the window opens and closes before you notice. Add
--slowmo 1000so each action is delayed. - On macOS, the browser opens behind your terminal. Cmd+Tab or check the dock.
If you genuinely see no window at all, playwright install chromium may have installed only the headless shell. Re-run:
playwright install chromium --force
playwright install --list # should show both chromium-XXXX and chromium_headless_shell-XXXXThe Allure CLI is a separate runtime. On macOS:
brew install allureThe raw results in reports/allure-results/ are still produced without it, and reports/html/report.html works standalone.
That's the auth fixtures behaving correctly — they refuse to run when QA_USERNAME / QA_PASSWORD are blank. Either configure auth in your .env, or do not depend on credentials / api_token / ui_login in tests that should run anonymously.
Run the hooks on demand to see exactly what tripped:
pre-commit run --all-filesMost commonly ruff auto-fixes the issue; just git add -u && git commit again.
- Click Use this template on GitHub, or clone and
rm -rf .git && git init. - Update
name,description, andauthorsin pyproject.toml. - Point .env.example at your real target URLs.
- Delete the samples (
framework/pages/example_page.py,framework/api/example_client.py,tests/ui/test_example_ui.py,tests/api/test_example_api.py,tests/e2e/test_example_e2e.py) and the fixtures referencing them in tests/conftest.py. - Customise framework/pages/login_page.py for your real login flow (override class attributes or
login()). - Keep
framework/generic. Project-specific page objects and clients live alongside it (they reuseBasePage/BaseAPIClient).
MIT. See LICENSE.