From Zero to Playwright: How to Bootstrap an Automation Repo That Scales

You’re starting a brand‑new automation repo that you expect to grow into an enterprise asset. The goal isn’t “get a few tests running.” It’s lay down habits and guardrails so new SDETs can onboard quickly, write clean tests, and avoid the typical graveyard of flaky E2E. This post gives you a practical, copy‑pasteable blueprint using Playwright (Python + pytest), but the structure applies to any stack.
The Philosophy (Before You Write a Line of Code)
- Small PRs, short branches. Trunk‑based with <300 LOC PRs keeps velocity and code review quality high.
- Conventional commits.
feat:
,fix:
,test:
,docs:
,chore:
,refactor:
make history searchable. - Code review rules. Two approvals for framework changes; one for test files. Ban sleeps; justify brittle selectors.
- PR checklist. Passing CI, tests added/updated, docs updated, traces/screens attached on UI changes.
Day 1: The Minimal but “Right” Repo Skeleton
automation/
README.md
CONTRIBUTING.md
CODEOWNERS
pyproject.toml
pytest.ini
.pre-commit-config.yaml
envs/
.env.dev.example
.env.qa.example
tests/
dev/
smoke/
regression/
qa/
prod/
shared/
fixtures/
conftest.py
pages/
auth/
scheduling/
visits/
controls/
datepicker.py
table.py
utils/
api.py
time.py
data/
factories/
reports/
scripts/
run_local.sh
seed_dev.py
Why this works:
- Environment split from the start (
tests/dev
,tests/qa
,tests/prod
). - Single reuse hub under
shared/
for fixtures, page/controls, utils, and test data. - Obvious places to put things = faster onboarding and fewer “where does this go?” debates.
Tooling That Enforces Hygiene (Not Opinions)
- Formatter: Black (or Ruff formatter) — kills style arguments.
- Linter: Ruff — supersedes flake8 + common plugins.
- Types: mypy/pyright — gradual typing; start with framework code.
- Pre‑commit: run ruff, format, mypy, and a tiny smoke subset on push.
pyproject.toml
(starter):
[tool.pytest.ini_options]
addopts = "-q -ra --maxfail=1"
testpaths = ["tests"]
[tool.ruff]
select = ["E","F","W","I","UP","B"]
line-length = 100
[tool.black]
line-length = 100
[tool.mypy]
python_version = "3.11"
ignore_missing_imports = true
.pre-commit-config.yaml
(starter):
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.6
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
- repo: https://github.com/pytest-dev/pytest
rev: 8.2.0
hooks:
- id: pytest
stages: [push]
args: ["-m", "smoke", "-q"]
Config & Secrets (Fast, Safe, Consistent)
- Load
.env
per env inconftest.py
(e.g.,BASE_URL
, timeouts, flags). - Never check secrets; use CI secrets +
.env.*.example
for local. - Centralize timeouts/retries/tracing defaults in one module.
Selector & Stability Strategy (Non‑Negotiable)
- Publish
data-testid
in the product. Use Playwright’s role selectors by default. - Ban brittle CSS/XPath unless justified in code review.
- Remove
sleep
. Preferexpect
auto‑waits or explicit helpers. - Favor network‑level waits when you can predict backend activity.
Test Design Principles to Teach Every New Hire
- Small & modular. One intent per test; deterministic; idempotent.
- No cross‑test dependencies. Each test creates/owns its data.
- E2E are precious (<10%). They verify stitching, not minutiae.
- Fixture‑driven reuse. Auth/session, seeded data, page wiring via fixtures.
- Parametrize > duplicate. Use factories for variants.
- Document intent, not click‑by‑click. Use short docstrings + TMS markers.
Test docstring pattern: Purpose / Preconditions / Validates.
Page Objects & Control Objects (The Secret to Scale)
- Page Objects expose intentions (e.g.,
filter_upcoming()
,submit_hra()
), not clicks. - Control Objects model reusable widgets (tables, datepickers) to kill copy‑paste across pages.
Minimal page object:
class VisitsPage:
"""Visits page: list, filter, open visit details."""
def __init__(self, page, base_url: str):
self.page = page
self.base_url = base_url
def open(self):
self.page.goto(f"{self.base_url}/visits")
def filter_upcoming(self):
self.page.get_by_role("tab", name="Upcoming").click()
def first_row(self):
return self.page.get_by_role("row").nth(1) # skip header
A reusable control:
class TableControl:
"""Virtualized table helper with stable row/column access."""
def __init__(self, page, locator):
self.page = page
self.locator = locator
def row_count(self) -> int:
return self.page.locator(self.locator).get_by_role("row").count()
def cell(self, row: int, col: int):
return self.page.locator(self.locator).get_by_role("row").nth(row).get_by_role("cell").nth(col)
Fixtures That Do the Heavy Lifting
Key fixtures to add early:
page
/context
per test for isolation.auth_context
to reuse storage state or login via API.seed_*
fixtures that create stable, minimal entities via API and tear them down.feature_flags
fixture to flip experiments cleanly and skip unsupported tests.
Example test (docstrings + markers):
import pytest
from playwright.sync_api import expect
from shared.pages.visits.visits_page import VisitsPage
@pytest.mark.smoke
@pytest.mark.tms("SNAP-1234")
def test_upcoming_visits_visible(auth_context, base_url):
"""
Purpose: Ensure the Upcoming tab lists at least one visit for a seeded patient.
Preconditions: Patient with a scheduled visit exists (seeded via fixture).
Validates: JIRA SNAP-1234 acceptance criteria A1, A3 (list visible, row fields).
"""
page = auth_context
v = VisitsPage(page, base_url)
v.open()
v.filter_upcoming()
expect(v.first_row()).to_be_visible()
Data Strategy & Environments
- Prefer factories over hand‑rolled fixtures for entities (e.g.,
PatientFactory.create()
-> calls API). - Use synthetic static data only when truly static; otherwise generate with UUID suffixes.
- Add nightly cleanup for orphaned entities to retain idempotency and speed.
Reporting, Traceability & Flake Management
- Wire Allure (or equivalent): screenshots, videos, trace attachments on failure.
- Push TMS IDs via
@pytest.mark.tms("ID")
. - Quarantine flow: move known‑flaky tests behind a non‑gating job; still fail that job to keep pressure on fixes.
- Keep JSON/HTML reports per shard, aggregate for dashboards.
CI That Mirrors Risk
Separate jobs that match how you ship:
- PR: fast
dev-smoke
(minutes). - Merge to main / Nightly:
dev-regression
(sharded). - Manual/cron:
prod-smoke
(safe subset against prod).
Guidelines:
- Shard by file or test count; cache Python venv + browser binaries.
- Always upload artifacts (trace.zip, video, console logs) on failure.
- Use markers to select tests:
pytest -m "smoke and not prod_only"
orpytest -m "regression"
.
Documentation People Actually Read
- README: quickstart (3 commands), repo tour, selector policy, “how to add a test.”
- CONTRIBUTING.md: branching, commits, PR checklist, naming, code style.
- /docs (MkDocs):
- Page object catalog
- Controls catalog
- Data seeding guide
- CI runbook
- Flake triage flow
- ADRs: short “Architecture Decision Records” for key choices (selectors, page model, data strategy). They prevent circular debates six months later.
Guardrails for Growth
- CODEOWNERS: core SDET group owns framework files; domain owners own tests.
- Budgets: alert on test files >N seconds; keep slow tests behind
@pytest.mark.slow
. - Deprecation policy: annotate with
@deprecated
+ removal version/date and log in an ADR.
CODEOWNERS
example:
/shared/** @qa-core-sdets
/tests/** @domain-owners @qa-core-sdets
PR template (short):
## What & Why
<!-- brief intent, risk -->
## Screens/Traces
<!-- attach failing trace or success proof if UI-visible -->
## Checklist
- [ ] Tests added/updated
- [ ] Docs updated (README/ADR if needed)
- [ ] No sleeps; selectors follow policy
From Basic → Complex: A Phased Roadmap
Phase 1: Starter (week 1–2)
- Single env (dev), smoke only.
- 1–2 page objects + 1 control; login fixture;
.env.dev
. - Pre‑commit, ruff/black/mypy, pytest markers.
Phase 2: Standard (month 1)
- Env split (dev/qa/prod), full regression; sharded CI.
- Data factories + API seeding.
- Allure wired; TMS IDs; trace/video artifacts.
- Controls library for table/modal/dropdown.
Phase 3: Advanced (quarter 1)
- Feature flag fixture + skip/xfail matrix.
- Quarantine pipeline, flake dashboard, stability SLOs.
- Contract/API tests to pre‑qualify UI runs.
- Synthetic data pipeline seeded nightly.
Phase 4: Enterprise (6–12 months)
- Test taxonomy: unit‑like (controls), component (POs), slice (2–3 pages), a few critical E2E.
- Coverage dashboards by feature & requirement (Allure + TMS).
- Compliance: secret scanning, SBOM, audit logs on prod runs.
- Scaling CI: self‑hosted runners, autoscaling, cost caps, smart test selection.
Naming & Layout Conventions (Make the Repo Readable)
- Files:
test_<feature>_<intent>.py
(e.g.,test_visits_list_smoke.py
). - Tests:
test_<verb>_<object>[_condition]
(e.g.,test_create_roster_with_required_fields
). - Page objects:
<Area>Page
,<Area>Dialog
,<Widget>Control
. - Helpers: verbs (
select_*
,fill_*
,assert_*
). - Markers:
smoke|regression|slow|prod_only
; optionaltms("ID")
,flaky
.
Docstrings & Comments (How Much Is Enough?)
- Test docstring: 3–5 lines > Purpose, Preconditions, Validates. Link Jira via marker, not prose.
- Page object docstring: one sentence on scope + gotchas (e.g., virtualized table note).
- Inline comments only for non‑obvious waits, workarounds, and TODOs with owner + deadline.
- Type hints on public helpers — they double as living docs.
Week‑1 Onboarding: Your SDET Starter Path
- Run smoke locally in one command.
- Add one parametric regression test using an existing page object.
- Add/extend a control‑level assert to remove duplication.
- Wire a TMS ID + short docstring (Purpose/Preconditions/Validates).
- Read a trace and fix a flaky wait the “repo way.”
Anti‑Patterns to Ban Early
sleep
without justification.- CSS/XPath for dynamic nodes when
data-testid
or roles exist. - Cross‑test dependencies or shared state.
- Giant E2E flows that verify 20 things and fail for 1.
- Click‑by‑click narration in comments that goes stale.
- Hard‑coded data or credentials in tests.
Copy‑Paste Starters
Smoke command (local):
pytest -m smoke -q
Regression command (local, sharded by file glob):
pytest -m regression tests/dev/regression -q
Playwright install (CI bootstrap):
playwright install --with-deps
Example conftest.py
snippet (env + auth):
import os
import pytest
from dotenv import load_dotenv
from playwright.sync_api import sync_playwright
@pytest.fixture(scope="session")
def base_url():
env = os.getenv("TEST_ENV", "dev")
load_dotenv(dotenv_path=f"envs/.env.{env}", override=True)
return os.environ["BASE_URL"]
@pytest.fixture
def auth_context(base_url):
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
context = browser.new_context(storage_state="storage/auth.json")
page = context.new_page()
if not os.path.exists("storage/auth.json"):
# Fallback login routine (API preferred in real-world)
page.goto(f"{base_url}/login")
page.get_by_label("Email").fill(os.environ["USER_EMAIL"])
page.get_by_label("Password").fill(os.environ["USER_PASSWORD"])
page.get_by_role("button", name="Sign in").click()
context.storage_state(path="storage/auth.json")
yield page
context.close()
browser.close()
The Payoff
Start small, but start correct: a clear skeleton, strict hygiene, reusable page/controls, fixture‑driven data, and CI jobs that mirror risk. These habits compound. Six months from now you’ll still be moving fast, without a flake‑ridden, nobody‑understands‑it test suite dragging you down.
👉 Want more posts like this? Subscribe and get the next one straight to your inbox. Subscribe to the Blog or Follow me on LinkedIn
Comments ()