Scope and assumptions: Playwright Python (sync API), pytest, pytest-xdist, containerised CI, a suite in the 500–5,000 test range owned by multiple teams. Every AI technique below is treated as an out-of-band assistant with deterministic verification — never as an uncontrolled decision-maker inside a passing or failing assertion.
Written by Himanshu Agarwal — Test Architect | AI-Driven QA Automation.
I publish practical engineering playbooks on AI testing, SDET, Playwright, LLM, RAG, MCP, GenAI and enterprise automation.
- AI Playbook Store (currently 50% off all bundles and ebooks): https://himanshuai.gumroad.com/
- Daily free articles: https://himanshuai.substack.com
- Connect on LinkedIn: https://www.linkedin.com/in/himanshuai/
- 1:1 consulting and architecture reviews: https://topmate.io/himanshuai
1. Dynamic Locators & Unstable DOM
Why it happens in real projects. Modern front ends generate their own DOM identity. CSS Modules, Tailwind JIT and styled-components emit hashed class names that change on every build. Virtualised lists (react-window, AG Grid) recycle nodes, so nth-child(3) points at a different record after a scroll. Micro-frontends ship independently, so the DOM around your element changes without a single commit in the repo you monitor. A/B experiments swap entire component trees at runtime. Codegen-recorded selectors capture incidental structure (div > div > ul > li:nth-child(3) > button) instead of element identity.
What makes it hard at enterprise scale. A design-system upgrade breaks 300 tests in one PR, spread across 12 repositories with 12 different owners. There is no contract between frontend teams and QA about what makes an element addressable, so each team invents its own convention. Locator knowledge gets duplicated across page objects, so a single DOM change requires dozens of edits. Nobody owns "locator strategy" as an architectural concern, so it degrades continuously.
Typical failure symptoms.
-
TimeoutError: Locator.click: Timeout 30000ms exceededwithwaiting for locator("div.css-1x9f2b > ul > li:nth-child(3) > button"). - Strict mode violations:
strict mode violation: locator resolved to 4 elements. - Tests that pass locally against seeded data and fail in CI because row ordering differs.
- Breakage clusters: one component change, hundreds of red tests, one real root cause.
Why traditional approaches fail. Hardening XPath makes the selector more coupled to structure, not less. Raising timeouts does nothing — the element identity is wrong, not slow. Centralising brittle selectors in a page object centralises the breakage but does not remove it. Recording tools optimise for "works right now", which is exactly the wrong optimisation target.
Solution
Treat addressability as an application contract, then enforce it in code.
Locator priority ladder (enforced by review and lint):
-
get_by_role()with an accessible name — semantic, and it doubles as an accessibility check. -
get_by_test_id()— for elements with no meaningful role. -
get_by_label()/get_by_placeholder()— forms. -
locator()with CSS scoped inside a stable container — last resort. - XPath, positional CSS, generated class hashes — banned.
Configure the test-ID attribute once, at session scope:
# conftest.py
import pytest
from playwright.sync_api import Playwright
@pytest.fixture(scope="session", autouse=True)
def configure_test_id(playwright: Playwright) -> None:
# Contract with the frontend team: every interactive component in the
# design system emits data-qa. get_by_test_id() then resolves against it.
playwright.selectors.set_test_id_attribute("data-qa")
Scope locators to a component root so DOM position stops mattering. The identity of a row comes from its data, not from its index:
# components/orders.py
from playwright.sync_api import Locator, Page, expect
class OrderRow:
def __init__(self, root: Locator) -> None:
self._root = root
@property
def status(self) -> Locator:
return self._root.get_by_test_id("order-status")
def cancel(self) -> None:
self._root.get_by_role("button", name="Cancel order").click()
expect(self.status).to_have_text("Cancelled")
class OrdersPage:
def __init__(self, page: Page) -> None:
self._page = page
self._table = page.get_by_role("table", name="Orders")
def row(self, order_id: str) -> OrderRow:
# Row identity is derived from business data, so virtualisation,
# re-sorting and pagination do not invalidate the locator.
return OrderRow(
self._table.get_by_role("row").filter(
has=self._page.get_by_test_id("order-id").filter(has_text=order_id)
)
)
filter(has=...) resolves the inner locator relative to the outer one, which is what makes this stable under re-ordering. Locators are lazy: self._table is re-resolved on every action, so a re-rendered table does not produce a stale reference.
Enforce the policy with a test that guards the framework itself. This runs in the same suite and fails the PR:
# tests/meta/test_locator_policy.py
import pathlib
import re
FORBIDDEN = (
(re.compile(r"\.locator\(\s*['\"]\s*//"), "XPath is not allowed"),
(re.compile(r"nth-child\(|nth-of-type\("), "positional CSS is not allowed"),
(re.compile(r"\.css-[a-z0-9]{5,}"), "generated style hashes are not allowed"),
)
def test_no_brittle_locators() -> None:
offenders: list[str] = []
for path in pathlib.Path("tests").rglob("*.py"):
for lineno, line in enumerate(path.read_text().splitlines(), start=1):
for pattern, reason in FORBIDDEN:
if pattern.search(line):
offenders.append(f"{path}:{lineno}: {reason} -> {line.strip()}")
assert not offenders, "Locator policy violations:\n" + "\n".join(offenders)
Division of responsibility.
- Playwright handles: resolution, auto-waiting, strict-mode uniqueness, relative queries.
- Python/pytest handles: the component model, the registry of contracts, policy enforcement.
-
AI helps at authoring time: feed it the ARIA snapshot of a container (
page.locator("main").aria_snapshot(), Playwright 1.49+) and ask for a role-based locator proposal. The snapshot is a compact accessibility tree, so the model reasons about semantics rather than 400 KB of markup. Also useful: asking an LLM to open a PR addingdata-qato design-system components that lack it. - Do not trust AI to: pick locators at runtime. A wrong pick produces a green test on the wrong element — a silent false negative, which is worse than the red test you started with.
Interview Perspective
Say that locator instability is an application design problem surfacing in the test layer, and that the architectural fix is a test-ID contract owned jointly with frontend, enforced in the design system and in CI lint — not a smarter selector engine.
Then show the trade-off thinking: role-based locators give accessibility coverage for free but break on copy and i18n changes, so localisation-heavy products should prefer test IDs with role assertions layered on top. Mention strict mode as a feature: an ambiguous locator should fail loudly rather than silently act on the first match.
Common mistakes worth calling out: treating page objects as a bag of selectors instead of a behavioural API; committing recorded selectors; reaching for AI self-healing before establishing a locator contract; and storing locators as strings rather than as component-scoped Locator factories.
2. Flaky Tests & Timing Issues
Why it happens in real projects. Flakiness is a race between the test and the application. Common sources: hydration in SSR frameworks (markup exists before handlers attach), debounced search inputs, optimistic UI that renders then reverts, animated modals, polling widgets, toasts that auto-dismiss, retried network calls, and back-end latency that varies under parallel load. The test is fast and the application is nondeterministic; under 16 xdist workers, the environment is slower and more variable than on a developer laptop.
What makes it hard at enterprise scale. A 1% per-test flake rate across 2,000 tests means almost every pipeline run is red. Once that happens, "re-run the job" becomes the team's default action and real failures stop being investigated. Flakiness then becomes a merge-queue tax measured in engineer-hours per week, and the automation suite loses its authority to block a release.
Typical failure symptoms.
- Green at
-n 1, red at-n 16. -
Element is not stable,element is outside of the viewport,element intercepts pointer events. - Assertions on text that was correct 200 ms earlier.
- The same test failing 1 in 30 runs, always at a different step.
Why traditional approaches fail. time.sleep() turns a race into a slow race — it neither guarantees correctness nor stays correct as the app changes, and it inflates suite duration linearly. Raising the global timeout hides the signal and makes real failures take 30 s each to report. Blanket reruns via pytest-rerunfailures mask genuine product race conditions, which are exactly the defects worth catching.
Solution
Synchronise on state, not on time. Playwright's auto-waiting already covers actionability (attached, visible, stable, enabled, receives events). Your job is to wait for the specific state your assertion depends on.
from playwright.sync_api import Page, expect
def test_orders_refresh_updates_table(page: Page) -> None:
page.goto("/orders")
# Tie the UI action to the network call it triggers, instead of guessing.
with page.expect_response(
lambda r: r.url.endswith("/api/orders") and r.request.method == "GET"
) as response_info:
page.get_by_role("button", name="Refresh").click()
assert response_info.value.ok, response_info.value.status
# Web-first assertions retry until the timeout, so no explicit wait is needed.
expect(page.get_by_test_id("orders-table").get_by_role("row")).to_have_count(25)
expect(page.get_by_test_id("last-updated")).not_to_have_text("—")
Control time instead of waiting for it. For session timeouts, countdowns, auto-refresh and scheduled banners, use the clock API (Playwright 1.45+) rather than a real 25-minute wait:
def test_session_expiry_warning(page: Page) -> None:
page.clock.install() # must be installed before navigation
page.goto("/dashboard")
page.clock.fast_forward("24:00")
expect(page.get_by_role("dialog", name="Session expiring")).to_be_visible()
Remove animation as a variable by pinning reduced_motion in the context args (see problem 7), and avoid wait_for_load_state("networkidle") — on an app with polling or analytics beacons it either never settles or settles for the wrong reason.
Make flakiness a governed state, not a habit. Quarantine must cost something and must expire:
# conftest.py
import datetime as dt
import pytest
# nodeid -> (ticket, expiry)
QUARANTINE = {
"tests/checkout/test_promo.py::test_stacked_coupons": ("BUG-4821", dt.date(2026, 10, 1)),
}
def pytest_collection_modifyitems(config, items):
today = dt.date.today()
for item in items:
entry = QUARANTINE.get(item.nodeid)
if not entry:
continue
ticket, expiry = entry
if today > expiry:
# Expired quarantine fails the build: the debt is visible, not silent.
item.add_marker(pytest.mark.fail_quarantine_expired)
continue
# Still executed and still reported — just not blocking.
item.add_marker(pytest.mark.xfail(reason=f"Quarantined: {ticket}", strict=False))
Division of responsibility.
- Playwright handles: actionability waiting, assertion retries, response/URL/event waiting, virtual clock.
-
Python/pytest handles: isolation (unique data per test), deterministic seeding (
random.Random(seed)with the seed logged), quarantine policy, and flake-rate reporting per test and per file. - AI helps by: ranking flaky tests from historical CI data after deterministic statistics are computed (failure rate, failure-step distribution, correlation with worker count), and drafting a root-cause hypothesis from the trace plus the diff that preceded the first failure.
- Do not trust AI to: decide that a test is "just flaky" and mute it. Auto-muting is how a real race condition ships to production.
Interview Perspective
Frame flakiness as an observability and isolation problem, not a waiting problem. State the rule plainly: never synchronise on time, always synchronise on an observable state transition — a response, a URL, an element state, a count.
Show that you track it: flake rate per test, per suite and per worker count is a first-class metric, and reruns are instrumented as a signal (a test that only passes on retry is logged as flaky even when the job is green) rather than used as a fix.
Trade-off to mention: pytest-rerunfailures keeps the pipeline usable while you pay down debt, but a rerun must never turn a failure into an invisible pass. Common mistakes: global timeout inflation, networkidle as a default wait, asserting on ephemeral toasts instead of persisted state, and tests that depend on execution order.
3. Handling iFrames, Popups & Multiple Tabs
Why it happens in real projects. Payment providers render card fields in cross-origin iframes; 3-D Secure opens a challenge popup; SSO redirects into a provider domain; legacy modules are embedded as nested frames; consent banners, chat widgets and survey modals appear at unpredictable moments and intercept pointer events. Any target="_blank" link produces a new Page object that your existing page fixture knows nothing about.
What makes it hard at enterprise scale. You cannot add test IDs to a third party's DOM, and that DOM changes without notice. Frame load order is nondeterministic. Consent and experiment overlays appear on a percentage of sessions, so they break a percentage of runs. Multiple tabs mean multiple pages sharing one context — leak one and the next test starts on the wrong page.
Typical failure symptoms.
- Element visibly on screen, yet
locator resolved to 0 elements. -
element intercepts pointer eventspointing at a cookie banner. - The test hangs after clicking a link that opened a new tab.
-
Frame was detachedmid-interaction.
Why traditional approaches fail. The Selenium switch_to mental model does not map onto Playwright; people emulate it with global state and lose auto-waiting. Sleeping until a frame "should" be there restores the race. Indexing frames by position breaks the moment a chat widget is added.
Solution
Frames: use frame_locator(), which is lazy and re-resolves on every action. Chain it for nested frames:
from playwright.sync_api import Page, expect
def test_card_payment_with_3ds(page: Page, card) -> None:
page.goto("/checkout")
card_frame = page.frame_locator("iframe[title='Secure card input']")
card_frame.get_by_label("Card number").fill(card.number)
card_frame.get_by_label("Expiry").fill(card.expiry)
card_frame.get_by_label("CVC").fill(card.cvc)
# The popup is captured by the context manager that triggers it —
# no polling, no sleep, no race.
with page.expect_popup() as popup_info:
page.get_by_role("button", name="Pay now").click()
challenge = popup_info.value
challenge.wait_for_load_state()
challenge.frame_locator("#challenge-frame").get_by_role("button", name="Approve").click()
# Assert on the original page; auto-waiting covers the popup closing.
expect(page.get_by_role("heading", name="Payment confirmed")).to_be_visible()
For a tab opened by a plain link, use the context-level event so you capture pages you did not directly trigger:
with page.context.expect_page() as new_page_info:
page.get_by_role("link", name="Open invoice").click()
invoice = new_page_info.value
expect(invoice).to_have_title("Invoice")
invoice.close() # explicit close keeps context state predictable
Interstitials: handle them declaratively, not with try/except everywhere. add_locator_handler (Playwright 1.42+, times added in 1.44) registers a handler that Playwright invokes whenever the overlay blocks an action:
import pytest
from playwright.sync_api import Page
@pytest.fixture(autouse=True)
def dismiss_interstitials(page: Page) -> None:
page.add_locator_handler(
page.get_by_role("dialog", name="We value your privacy"),
lambda dialog: dialog.get_by_role("button", name="Accept all").click(),
times=1,
)
Third-party flows you do not own: stub them for functional coverage, verify them separately. Ninety percent of your checkout tests care about your order logic, not the provider's iframe:
def stub_payment_provider(page: Page) -> None:
page.route(
"**/provider.example.com/tokenize",
lambda route: route.fulfill(
status=200,
json={"token": "tok_test_approved", "status": "approved"},
),
)
Keep a small number of genuine end-to-end contract tests against the provider's sandbox on a nightly schedule. That gives you speed on every commit and real integration signal once a day.
Division of responsibility.
- Playwright handles: frame resolution, popup/tab capture, overlay handlers, request interception.
-
Python/pytest handles: which flows are stubbed vs. real (markers such as
@pytest.mark.contract), fixture-level registration of handlers, page lifecycle hygiene. - AI helps by: summarising an unfamiliar third-party frame's ARIA snapshot during test authoring so you can write locators against a DOM you do not control.
- Do not use AI here at runtime. Payment and authentication frames may contain cardholder data and personal information. Sending that DOM to an external model is a compliance incident, not an engineering trade-off. Redact or stub instead.
Interview Perspective
Lead with the model difference: Playwright treats frames and popups as first-class objects captured through event context managers, so the correct pattern is to wrap the action that produces the popup, not to poll for it afterwards.
Show architectural judgement on third-party surfaces: define a boundary, stub across it for the bulk of the suite, and keep a thin contract suite running on a schedule with alerting owned by a specific team. Mention the security constraint explicitly — it is the kind of answer that separates a senior engineer from a tool user.
Common mistakes: indexing frames positionally, try/except around every possible overlay, forgetting to close popups (which leaks state into the next test in the same context), and running full third-party integration on every PR, which imports someone else's uptime into your merge queue.
4. API + UI Test Data Management
Why it happens in real projects. Setting up state through the UI is slow and couples every test to unrelated screens. So teams seed data instead — and then the seed drifts from the API contract, or two parallel tests grab the same "test user", or a nightly purge job deletes the fixtures the suite depends on. Entity creation frequently spans several services, so there is no single place to create a valid customer.
What makes it hard at enterprise scale. Shared lower environments are used by multiple teams simultaneously. Data has TTLs and compliance constraints, so you cannot simply copy production. Full database resets are impossible in a shared environment and are fatal to parallelism. The result is hidden coupling: tests that pass individually and fail as a suite.
Typical failure symptoms.
- Tests pass with
-k test_name, fail in the full run. - "The account was in the wrong state" — because another test changed it.
- Monday-morning failures after weekend data cleanup.
- Cascading failures where one setup test breaks and forty downstream tests fail.
Why traditional approaches fail. Static JSON/CSV fixtures encode assumptions that silently rot. SQL seeding bypasses domain validation, so you create states the application itself can never produce. "Reset between tests" cannot scale beyond a single-tenant environment and forces serial execution.
Solution
Arrange over the API, act over the UI, assert in both. Every test creates exactly the data it needs and destroys it afterwards.
# conftest.py
import os
import uuid
import pytest
from playwright.sync_api import APIRequestContext, Playwright
RUN_ID = os.getenv("CI_BUILD_ID", uuid.uuid4().hex[:8])
@pytest.fixture(scope="session")
def api(playwright: Playwright) -> APIRequestContext:
request_context = playwright.request.new_context(
base_url=os.environ["API_BASE_URL"],
extra_http_headers={"Authorization": f"Bearer {os.environ['SERVICE_TOKEN']}"},
)
yield request_context
request_context.dispose()
@pytest.fixture
def customer(api: APIRequestContext, worker_id: str) -> dict:
# Namespacing by run and worker makes collisions structurally impossible.
email = f"qa+{RUN_ID}-{worker_id}-{uuid.uuid4().hex[:6]}@example.test"
created = api.post("/v1/customers", data={"email": email, "tier": "gold"})
assert created.ok, f"setup failed: {created.status} {created.text()}"
body = created.json()
yield body
# Idempotent teardown; never fail a passing test because cleanup 404s.
api.delete(f"/v1/customers/{body['id']}")
Assert server-side truth for anything that matters. A confirmation banner is not proof that the order was persisted:
def test_order_is_persisted_after_checkout(page, api, customer) -> None:
checkout(page, customer)
expect(page.get_by_role("heading", name="Order confirmed")).to_be_visible()
orders = api.get(f"/v1/customers/{customer['id']}/orders").json()
assert len(orders) == 1
assert orders[0]["status"] == "PLACED"
Where AI genuinely helps — and where it must be gated. Generating boundary and equivalence-class data sets from an OpenAPI schema is a good use of a model. Trusting the output is not. Validate it against a schema-derived model before anything reaches a test:
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class CustomerPayload(BaseModel):
# extra="forbid" is the anti-hallucination gate: invented fields are rejected.
model_config = ConfigDict(extra="forbid")
email: EmailStr
tier: Literal["free", "gold", "platinum"]
credit_limit: int = Field(ge=0, le=1_000_000)
def accept_generated_dataset(rows: list[dict]) -> list[CustomerPayload]:
valid, rejected = [], []
for row in rows:
try:
valid.append(CustomerPayload.model_validate(row))
except Exception as exc: # log, count, and surface the rejection rate
rejected.append((row, str(exc)))
record_metric("ai.dataset.rejection_rate", len(rejected) / max(len(rows), 1))
return valid
Division of responsibility.
-
Playwright handles:
APIRequestContextfor setup/teardown and for API assertions that share the browser's auth state (context.request). - Python/pytest handles: builders/factories, fixture scoping, namespacing, cleanup ordering, environment configuration.
- AI helps by: proposing edge-case data (unicode names, boundary amounts, expired states) and by suggesting missing negative cases against a schema.
- Do not trust AI to: invent domain invariants. Whether a "gold" customer can hold a negative balance is a business rule, and a model will confidently guess.
Interview Perspective
State the principle first: test data is a dependency, and dependencies must be owned, versioned and disposable. The default pattern is API-arranged, UI-acted, dual-asserted.
Then discuss the trade-off between shared seeded data (fast, cheap, collision-prone) and per-test creation (isolated, slower, dependent on API stability). The mature answer is usually tiered: ephemeral per-test data for functional tests, a small immutable reference set for lookups, and a separate contract suite that verifies the setup APIs themselves — because if your factories are broken, every test lies.
Common mistakes: building state through the UI, hardcoding IDs from an environment, cleanup that runs only on the happy path, and shared session-scoped mutable fixtures — the single most common cause of parallel-execution failures.
5. Parallel Execution & Resource Conflicts
Why it happens in real projects. Parallelism exposes every hidden assumption of shared state. pytest-xdist workers compete for the same accounts, the same download directory, the same feature-flag toggle, the same rate-limited endpoint and the same database rows. Chromium instances compete for memory and /dev/shm inside a container.
What makes it hard at enterprise scale. A 30-minute pipeline budget for 2,000 UI tests forces 16–32 concurrent workers across sharded runners. At that concurrency the back end behaves differently: connection pools saturate, rate limiters trigger, and latency distributions widen. Failures become load-dependent, which makes them look like flakiness and get treated as such.
Typical failure symptoms.
- Green at
-n 1, red at-n 8, differently red at-n 16. - Sporadic HTTP 429 or 503 in setup calls.
- "Account is locked" after too many concurrent logins.
- Browser crashes in CI with no application error (
/dev/shmexhaustion).
Why traditional approaches fail. Reducing worker count hides contention and burns the time budget. Retries convert contention into nondeterministic pass/fail. --dist loadfile helps with intra-file coupling but does nothing about shared external resources.
Solution
Design for isolation, then parallelise. Each test gets a fresh browser context (the pytest-playwright default), unique data (problem 4) and a worker-scoped account lease.
# conftest.py
import json
import os
import pytest
def _worker_index(worker_id: str) -> int:
# "master" when running without xdist, otherwise "gw0", "gw1", ...
return 0 if worker_id == "master" else int(worker_id.removeprefix("gw"))
@pytest.fixture(scope="session")
def leased_account(worker_id: str) -> dict:
pool = json.loads(os.environ["QA_ACCOUNT_POOL"]) # one entry per worker slot
assert len(pool) >= 1, "empty account pool"
return pool[_worker_index(worker_id) % len(pool)]
Do expensive session setup exactly once across all workers with a file lock in the shared temp root:
from pathlib import Path
from filelock import FileLock
@pytest.fixture(scope="session")
def storage_state(tmp_path_factory, playwright, leased_account) -> str:
root = tmp_path_factory.getbasetemp().parent # shared by every xdist worker
state_file = root / f"auth-{leased_account['role']}.json"
with FileLock(str(state_file) + ".lock"):
if not state_file.exists():
mint_storage_state(playwright, leased_account, state_file)
return str(state_file)
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args, storage_state):
return {**browser_context_args, "storage_state": storage_state}
Pin the few tests that genuinely cannot run concurrently — for example, tests that flip a global feature flag — instead of serialising the whole suite:
@pytest.mark.xdist_group("global-feature-flags")
def test_maintenance_banner_toggle(page):
...
Run with pytest -n 16 --dist loadgroup, which keeps grouped tests on a single worker while everything else spreads freely.
Sharding and container sizing. pytest-xdist parallelises within a runner; use pytest-split or a CI matrix to shard across runners. Size workers from CPU and memory, not optimism — roughly one worker per available core, with at least 1 GB per Chromium instance, and mount a larger /dev/shm (or run with --ipc=host) to avoid renderer crashes that look exactly like application timeouts.
Division of responsibility.
- Playwright handles: context isolation, per-context storage state, per-test artifacts.
- Python/pytest handles: worker-aware fixtures, locks, resource pools, grouping, sharding, output directories.
- AI helps by: predictive test selection — ranking which tests to run first on a PR based on the code diff and historical failure correlation, shortening feedback time.
- Do not trust AI to: decide which tests to skip on a release branch. Selection reorders and prioritises; the full suite still runs before merge and on the release candidate.
Interview Perspective
Say that parallelism is a design property, not a flag. -n auto is the last step, not the first. The prerequisites are: no shared mutable state, no ordering dependencies, per-worker identities, and data namespaced by run and worker.
Show that you think about the system under test, not just the runner: 16 workers means 16× the login rate, 16× the write rate, and rate limiters that were never tuned for it. Concurrency limits often need negotiating with the platform team, and sometimes the correct answer is a dedicated test tenant.
Common mistakes: session-scoped mutable fixtures, a single shared admin account, writing artifacts to a fixed path, assuming database isolation you do not have, and diagnosing contention failures as flakiness and retrying them.
Halfway point. Problems 1–5 are framework and infrastructure design. Problems 6–10 are where AI enters the system, and where most teams get the guardrails wrong.
If this level of depth is useful, I package the full patterns — framework architecture, parallel execution design, AI guardrails, RAG and MCP for QA — as engineering playbooks:
- AI System Design Bundle (7 books): https://himanshuai.gumroad.com/l/TheAISystemDesignBundle7Books
- Full store, 50% off all bundles and ebooks: https://himanshuai.gumroad.com/
- Daily free articles on Substack: https://himanshuai.substack.com
6. Authentication, Sessions & Token Handling
Why it happens in real projects. Enterprise apps rarely have a simple login form. There is an OIDC redirect chain across two or three domains, MFA, short-lived access tokens with refresh, CSRF tokens bound to a session, cookies scoped to a parent domain, and role-based access that requires several distinct identities in the same suite. Bot protection on the identity provider frequently blocks headless traffic.
What makes it hard at enterprise scale. Logging in through the UI in every test can consume 30–50% of total suite runtime. Tokens expire mid-run on long suites. Storage state captured once at the start of the pipeline is stale by shard three. Secrets must come from a vault, not a repo, and any artifact you keep — including Playwright traces — may contain them.
Typical failure symptoms.
- Random 401/403 appearing partway through a run, never at the start.
-
storage_stateworks locally and fails in CI because the cookie domain differs per environment. - MFA challenge appears in CI only.
- Tests interfering because one role's session leaked into another's context.
Why traditional approaches fail. UI login per test is slow and makes every test depend on the identity provider's availability. Committing a storage_state.json puts a valid session in version control. Disabling authentication in test environments means the authenticated paths are never exercised — you validate a system you do not ship.
Solution
Log in once per role, programmatically, and reuse the state. Keep exactly one UI login test per role — that test is your login coverage — and let everything else start authenticated.
import json
import os
import time
from pathlib import Path
import pyotp
from playwright.sync_api import Playwright, expect
def mint_storage_state(playwright: Playwright, account: dict, path: Path) -> None:
api = playwright.request.new_context(base_url=os.environ["API_BASE_URL"])
response = api.post(
"/auth/login",
data={
"username": account["username"],
"password": account["password"], # sourced from the vault, never from code
"otp": pyotp.TOTP(account["totp_seed"]).now(),
},
)
assert response.ok, f"login failed: {response.status}"
token = response.json()["access_token"]
browser = playwright.chromium.launch()
context = browser.new_context(base_url=os.environ["APP_BASE_URL"])
# Seed the token before any application script runs.
context.add_init_script(
f"window.localStorage.setItem('access_token', {json.dumps(token)});"
)
page = context.new_page()
page.goto("/")
# Verify the session is genuinely usable before persisting it.
expect(page.get_by_test_id("user-menu")).to_be_visible()
context.storage_state(path=str(path))
context.close()
browser.close()
api.dispose()
def is_fresh(path: Path, ttl_seconds: int = 15 * 60) -> bool:
return path.exists() and (time.time() - path.stat().st_mtime) < ttl_seconds
Combine is_fresh() with the FileLock fixture from problem 5 so state is re-minted when it ages out, and derive per-role fixtures from separate state files:
@pytest.fixture
def admin_page(browser, storage_state_for):
context = browser.new_context(storage_state=storage_state_for("admin"))
page = context.new_page()
yield page
context.close()
Treat traces and logs as secret material. Playwright traces capture request headers, so a trace from an authenticated run contains bearer tokens. Restrict artifact buckets, set short retention, and redact before anything leaves your boundary — especially before it reaches a model:
import re
SECRET_PATTERNS = re.compile(
r"(Bearer\s+[\w\-\.]+" # bearer tokens
r"|eyJ[\w\-\.]{20,}" # JWTs
r"|\b\d{13,19}\b" # PANs
r"|[\w\.\+\-]+@[\w\-]+\.[\w\.]+)" # emails
)
def redact(text: str) -> str:
return SECRET_PATTERNS.sub("[REDACTED]", text)
Division of responsibility.
-
Playwright handles: cookie/localStorage persistence via
storage_state,add_init_script, and API calls that inherit browser auth viacontext.request. - Python/pytest handles: secret retrieval, TTL and re-minting, role fixtures, cross-worker locking, redaction.
- AI helps by: almost nothing here, and that is the correct answer. At most, summarising an unfamiliar OIDC flow from a HAR during onboarding.
- Never send: credentials, tokens, TOTP seeds or raw traces to an external model. Redaction happens before the boundary, not inside the prompt.
Interview Perspective
Position authentication as test infrastructure with a security boundary, not a test step. One UI login per role for coverage; programmatic minting for everything else; state with a TTL because long suites outlive tokens.
Demonstrate the security thinking unprompted: secrets from a vault at runtime, dedicated non-production identities, TOTP seeds treated as credentials, traces treated as sensitive artifacts with restricted access and short retention.
Common mistakes: UI login in a setup method for every test; a single shared admin account across all workers; committed storage state; ignoring token expiry on suites that run longer than the token lifetime; and turning off auth in the test environment, which quietly removes the highest-risk code path from coverage.
7. Cross-Browser & Cross-Platform Compatibility
Why it happens in real projects. WebKit, Firefox and Chromium differ in date and file input behaviour, clipboard and permission models, font metrics and text wrapping, scroll anchoring, and download semantics. Add Linux CI versus macOS development machines, plus mobile emulation, and you have an environment matrix where "failure" often means "different", not "broken".
What makes it hard at enterprise scale. Three browsers × two viewports × four locales is 24 executions of every test. The compute cost and, more importantly, the triage cost is what kills the practice. Teams respond by disabling WebKit, which works until a Safari-only defect reaches customers.
Typical failure symptoms.
- A test that times out only on WebKit, with no application error.
- Visual snapshots failing on font rendering differences between the dev machine and CI.
- Date-picker interactions that work in Chromium and silently no-op elsewhere.
- Assertions on formatted currency or dates failing because the runner's locale changed.
Why traditional approaches fail. Running everything everywhere is unaffordable and produces noise that trains people to ignore results. Branching on browser name with extra waits pretends an environment difference is a timing problem. Permanently skipping a browser with no ticket turns a temporary decision into permanent blindness.
Solution
Make the environment deterministic before blaming the browser. Most "cross-browser" failures are unpinned locale, timezone, viewport or animation:
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"locale": "en-GB",
"timezone_id": "Europe/London",
"viewport": {"width": 1440, "height": 900},
"color_scheme": "light",
"reduced_motion": "reduce", # removes animation as a source of races
}
Branch only where behaviour genuinely differs, never for synchronisation:
# Playwright 1.44+ resolves ControlOrMeta per platform.
page.get_by_test_id("cell-A1").click(modifiers=["ControlOrMeta"])
Run a risk-based matrix, not a completeness matrix.
- Every PR: full suite on Chromium, plus the critical-path suite on the most-used mobile viewport.
- Nightly: critical path on Firefox and WebKit, plus locale variants for formatting-sensitive flows.
- Weekly or pre-release: real-device cloud for a small, explicitly chosen set of journeys.
Pin the browser binaries by pinning the Playwright version and running the official mcr.microsoft.com/playwright/python image at the tag matching the installed library. Browser drift is otherwise a silent, unversioned dependency change.
Make every exclusion accountable:
@pytest.mark.skip_browser("webkit") # BUG-5133: WebKit date input, owned by web-platform
def test_expiry_date_picker(page):
...
Then add a policy test — the same pattern as the locator lint — asserting that every skip_browser / only_browser marker in the repository carries a ticket reference. Undocumented skips are how coverage silently shrinks.
Division of responsibility.
- Playwright handles: browser launch, emulation, device descriptors, per-browser markers.
- Python/pytest handles: the matrix policy, marker governance, environment pinning, CI scheduling.
- AI helps by: triaging visual differences after a deterministic pixel diff has run — clustering diffs and classifying them as font rendering, layout shift or content change, so a human reviews 5 clusters instead of 200 images.
- Do not trust AI to: approve visual baselines. Baseline updates are an explicit human decision recorded in version control; auto-approval is how a broken layout becomes the new expected result.
Interview Perspective
Frame cross-browser testing as a risk allocation decision. Explain the tiering (per-PR, nightly, pre-release), and state the pre-condition: pin locale, timezone, viewport, color scheme and motion, otherwise you spend your budget debugging your own environment.
Mention emulation honestly — device emulation validates responsive layout and touch behaviour, not GPU quirks, real network conditions or OS-level input; that is what a small real-device set is for.
Common mistakes: treating a WebKit timeout as flakiness, adding browser-specific sleeps, permanently skipping browsers without a ticket or owner, and letting browser versions float so a failure cannot be attributed to a change you made.
8. AI-Generated Test Case Reliability & Hallucinations
Why it happens in real projects. A language model produces text that is plausible, and plausible Playwright code is not the same as correct Playwright code. Models invent APIs that sound right (page.wait_for_selector_visible(), page.click_if_exists()), invent endpoints, and — most dangerously — invent business rules. When a model does not know whether a gold-tier customer gets free shipping above £50 or £100, it picks one and writes a confident assertion.
What makes it hard at enterprise scale. Generation is cheap; verification is not. A model can add 400 tests in an afternoon, and review capacity becomes the bottleneck. Worse, a generated test that passes is more dangerous than one that fails: it inflates coverage metrics, adds maintenance cost, and asserts nothing. Snapshot-style tests generated against current behaviour bake existing defects in as the expected result.
Typical failure symptoms.
- Tests whose only assertion is
expect(page).to_have_url(...). -
try/except Exception: passwrapped around the meaningful step. - Calls to Playwright methods that do not exist, discovered only at runtime.
- Assertions that mirror the implementation instead of the requirement, so they can never detect a regression.
Why traditional approaches fail. "Better prompting" reduces the error rate but cannot bound it. Human review does not scale to generated volume. Pass rate is a useless quality signal for generated tests, because the easiest way to pass is to assert nothing.
Solution
Treat model output as untrusted input passing through a verification pipeline. The architectural principle: AI produces intent; the framework produces code.
Step 1 — Constrain the output to a schema, not free-form Python. The model emits validated intent; a template renders executable code against your existing page objects.
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class Step(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["goto", "click", "fill", "select", "expect_text", "expect_visible", "expect_count"]
target: str | None = None # must resolve to a registered page-object accessor
value: str | None = None
class GeneratedTest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(pattern=r"^test_[a-z0-9_]+$")
requirement_id: str = Field(pattern=r"^(JIRA|REQ)-\d+$") # forces traceability
steps: list[Step] = Field(min_length=2)
assertions: list[Step] = Field(min_length=1)
Step 2 — Reject anything referencing something that does not exist. Build the allowed vocabulary by introspecting the framework, so hallucinated targets fail before code is written:
def validate_targets(test: GeneratedTest, registry: set[str]) -> list[str]:
unknown = {
step.target
for step in (*test.steps, *test.assertions)
if step.target and step.target not in registry
}
return sorted(unknown) # non-empty => reject and log, never "fix silently"
Step 3 — Static gates in CI, applied to generated tests specifically.
- The module imports and compiles.
- No
time.sleep, no bareexcept, nopage.wait_for_timeout. - At least one web-first assertion (
expect() per test. - Locator policy lint (problem 1) applies unchanged.
- A
requirement_idthat resolves to a real ticket.
Step 4 — Prove the test can fail. This is the gate that catches assertions which assert nothing. Run generated tests against a fault-injected build; any test that still passes is rejected:
@pytest.fixture(autouse=True)
def fault_injection(page, request):
"""Enabled only in the nightly 'assertion strength' job via --inject-faults."""
if not request.config.getoption("--inject-faults"):
yield
return
page.route("**/api/**", lambda route: route.fulfill(
status=500, json={"error": "injected-fault"}))
yield
The job's success criterion is inverted: every generated test must fail. Any test that passes with the back end returning 500 is not testing anything and is removed.
Step 5 — Humans merge, models propose. Generated tests arrive as a pull request containing the source requirement, the validated intent, the rendered code and the fault-injection result. Track the survival rate — the proportion of generated tests still present and unmodified after 60 days. If it is low, the generator is producing debt, not coverage.
Division of responsibility.
- Playwright handles: execution and fault injection via routing.
- Python/pytest handles: schema validation, registry introspection, static gates, the inverted-assertion job, traceability.
- AI helps by: converting acceptance criteria into candidate scenarios, spotting missing negative and boundary cases, and drafting the first version of a test intent grounded in an ARIA snapshot and an OpenAPI spec.
- Do not trust AI to: define expected business behaviour, generate baselines, or merge its own output.
Interview Perspective
The line to deliver: AI increases the supply of tests, so the bottleneck moves to verification — invest there. Describe the pipeline: constrained schema output, registry validation, static gates, fault-injection proof, human merge.
The fault-injection gate is the detail that lands well, because it addresses the failure mode most people miss: a hallucinated test that passes is worse than one that fails, since it silently reduces the real coverage behind a rising number.
Common mistakes: pasting generated code straight into the repo; measuring generated tests by pass rate; letting the model invent selectors instead of using the framework's registry; and generating snapshots from current behaviour, which turns today's bugs into tomorrow's expected results.
9. Self-Healing Locators & AI Decision Accuracy
Why it happens in real projects. Locators drift (problem 1), so self-healing is an attractive pitch: the model finds a similar element and the test continues. The risk is precise — healing changes what is being tested. If "Delete" moved and the model selects the visually similar "Archive", the test passes for the wrong reason. Worse, healing masks real defects: if a button lost its accessible name, that is an accessibility regression your locator just detected, and healing throws that signal away.
What makes it hard at enterprise scale. Healing rates creep upward and nobody updates the source code, so the suite depends on runtime inference to work at all. Execution becomes nondeterministic — the same test takes different paths on different runs, which makes debugging and audit impossible. In regulated domains, you must be able to state exactly what was executed; "the model chose an element it considered similar" is not an acceptable answer.
Typical failure symptoms.
- Green suite, broken feature in production.
- Healing telemetry showing the same page healing every night for a month.
- Two runs of the same commit executing different elements.
- Nobody able to explain why a test passed.
Why traditional approaches fail. Vendor auto-healing that silently substitutes elements optimises for a green dashboard rather than for information. Similarity scoring without semantic constraints will happily match any button with comparable text.
Solution
Healing is a suggestion pipeline with deterministic verification, scoped by environment. It never makes a silent decision in CI.
Store a semantic contract alongside every critical element, so a proposal can be checked against a specification rather than against a screenshot:
from dataclasses import dataclass
@dataclass(frozen=True)
class ElementContract:
test_id: str
role: str # expected ARIA role
name: str # expected accessible name
landmark: str # "main", "navigation", "complementary"
healable: bool = True # destructive and regulated actions are never healable
Resolve with an explicit, environment-scoped policy:
import os
from playwright.sync_api import Locator, Page
HEALING_MODE = os.getenv("HEALING_MODE", "off") # off | propose | assist
def resolve(page: Page, contract: ElementContract) -> Locator:
primary = page.get_by_test_id(contract.test_id)
if primary.count() == 1:
return primary
# CI default: fail loudly. A missing locator is information, not an inconvenience.
if HEALING_MODE == "off" or not contract.healable:
raise LocatorResolutionError(contract)
return propose_and_verify(page, contract)
Verify every candidate deterministically before it is used:
CONFIDENCE_THRESHOLD = 0.85
def propose_and_verify(page: Page, contract: ElementContract) -> Locator:
# Semantic input, not raw DOM: smaller, stabler and free of styling noise.
snapshot = page.get_by_role(contract.landmark).aria_snapshot() # Playwright 1.49+
candidates = llm_propose_locators(snapshot, contract) # [{role, name, confidence}]
for candidate in sorted(candidates, key=lambda c: -c["confidence"]):
if candidate["confidence"] < CONFIDENCE_THRESHOLD:
break
if candidate["role"] != contract.role: # gate 1: role must match the contract
continue
locator = page.get_by_role(candidate["role"], name=candidate["name"], exact=True)
if locator.count() != 1: # gate 2: must be unambiguous
continue
# gate 3: record the proposal for human review, always.
record_healing_proposal(contract, candidate, page.url)
if HEALING_MODE == "assist":
return locator # local/debug only
break
raise LocatorResolutionError(contract)
Policy by environment, stated explicitly:
-
CI (
off) — never heal. The test fails, a proposal artifact is emitted, and the failure is real. -
Nightly healing lab (
propose) — run the failing tests with proposals enabled, collect accepted candidates, and open a pull request containing the locator diff, the confidence score and the ARIA evidence. A human merges. -
Local (
assist) — allowed, with a loud warning, so an engineer can keep debugging past a stale locator.
Deny-list by design. Destructive actions (delete, cancel, pay, transfer), authentication flows and anything in a regulated journey have healable=False. There is no confidence score high enough to justify guessing which button transfers money.
Prefer detection over healing where you can. An ARIA snapshot assertion catches structural drift explicitly and deterministically, which is usually more valuable than recovering from it:
expect(page.get_by_role("navigation")).to_match_aria_snapshot("""
- navigation:
- link "Dashboard"
- link "Orders"
- link "Settings"
""")
Instrument healing as a health metric, not a feature. Track heal attempts, acceptance rate, rejected candidates and time-to-merge for healing PRs. If a page needs healing repeatedly, the fix is a data-qa attribute in the component — not a better model.
Division of responsibility.
-
Playwright handles: resolution, uniqueness (
count()), ARIA snapshots, deterministic drift assertions. - Python/pytest handles: contracts, policy, gates, thresholds, deny-lists, proposal artifacts, metrics.
- AI helps by: proposing semantically similar candidates from an accessibility tree — a genuinely hard ranking problem where models are useful.
- Do not trust AI to: substitute elements at runtime in CI, act on destructive controls, or commit a locator change without human review.
Interview Perspective
The strongest answer: self-healing is a diagnostic signal, not a cure. A heal event tells you the locator contract is broken; the fix belongs in the application or the page object, not in a runtime inference layer.
Then show you can build it safely if the organisation wants it: semantic contracts, candidate proposals from an ARIA snapshot, deterministic gates (role match, unique resolution, container match), a confidence threshold, environment-scoped policy, a deny-list for destructive actions, and PR-based adoption with full audit logging of model, version and prompt hash.
Trade-off to name: healing buys short-term green pipelines and costs long-term trust and determinism. In regulated environments, non-deterministic execution is often simply disqualifying.
Common mistakes: enabling vendor healing globally; healing on any failure instead of specifically on zero-element resolution; no threshold; no audit trail; and never measuring whether healed locators were subsequently fixed in code.
10. Debugging, Reporting & Root-Cause Analysis with AI
Why it happens in real projects. A failure message says Timeout 30000ms exceeded waiting for get_by_role("heading", name="Order confirmed"). The actual cause was a 502 on a background call 40 seconds earlier, in a container that no longer exists. The information needed for diagnosis was available at runtime and was not captured.
What makes it hard at enterprise scale. A run produces 60 failures with 3 distinct root causes, spread across teams. Time-to-classify dominates mean time to repair. One back-end deploy fails 40 tests in 8 repositories, and every team independently investigates the same thing. Nobody is measuring triage cost, so nobody optimises it.
Typical failure symptoms.
- "Re-run and see if it passes" as the default first response.
- Screenshots that show the symptom but not the cause.
- No link between a failure and the deployment that caused it.
- The same root cause investigated three times in one week.
Why traditional approaches fail. Per-test HTML reports do not cluster. Screenshots capture the end state, not the sequence. Logs sit in CI output that expires. Without a failure signature, there is no way to say "these 40 failures are one incident".
Solution
Capture a structured evidence bundle deterministically, then let AI explain the clusters.
Enable tracing at the runner level — pytest --tracing retain-on-failure --video retain-on-failure --screenshot only-on-failure — and capture console, page errors and failed requests per test:
# conftest.py
import hashlib
import json
import os
import re
import pytest
from playwright.sync_api import Page
@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)
@pytest.fixture(autouse=True)
def evidence(page: Page, request):
console: list[dict] = []
network: list[dict] = []
page.on("console", lambda m: console.append({"type": m.type, "text": m.text}))
page.on("pageerror", lambda e: console.append({"type": "pageerror", "text": str(e)}))
page.on("requestfailed", lambda r: network.append(
{"url": r.url, "method": r.method, "error": r.failure}))
page.on("response", lambda r: network.append(
{"url": r.url, "status": r.status}) if r.status >= 500 else None)
yield
report = getattr(request.node, "report_call", None)
if report is None or not report.failed:
return
error_text = str(report.longrepr)
bundle = {
"nodeid": request.node.nodeid,
"signature": failure_signature(error_text),
"error": redact(error_text[-4000:]),
"console": [redact(json.dumps(c)) for c in console[-25:]],
"network": network[-25:],
"url": page.url,
"commit": os.getenv("GIT_COMMIT", "unknown"),
"browser": page.context.browser.browser_type.name,
"worker": os.getenv("PYTEST_XDIST_WORKER", "master"),
}
emit_to_warehouse(bundle) # one JSON event per failure
def failure_signature(error_text: str) -> str:
# Normalise volatile values so identical causes collapse into one cluster.
first_line = error_text.strip().splitlines()[0][:300]
normalised = re.sub(r"\b\d+\b", "N", first_line)
normalised = re.sub(r"https?://\S+", "URL", normalised)
return hashlib.sha1(normalised.encode()).hexdigest()[:12]
Cluster deterministically first. Grouping 60 failures into 3 signatures is a hash operation, not an AI problem. Only then does a model add value: explaining a cluster and proposing a root cause from the evidence.
Force the model to cite evidence, then verify the citations. This is a cheap, effective anti-hallucination gate:
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class Triage(BaseModel):
model_config = ConfigDict(extra="forbid")
category: Literal["app_bug", "test_bug", "environment", "test_data", "unknown"]
hypothesis: str = Field(max_length=600)
evidence: list[str] = Field(min_length=1) # verbatim strings from the bundle
confidence: float = Field(ge=0.0, le=1.0)
def accept_triage(triage: Triage, bundle_text: str) -> Triage:
# Any "evidence" the model cannot point to in the bundle is invention.
if any(fragment not in bundle_text for fragment in triage.evidence):
return triage.model_copy(update={"category": "unknown", "confidence": 0.0})
if triage.confidence < 0.7:
return triage.model_copy(update={"category": "unknown"})
return triage
The triage output is posted as a comment on the run with links to the trace, the video and the commit range. It is advisory. It never mutates state.
Division of responsibility.
-
Playwright handles: trace, video, screenshots, console and network events. The Trace Viewer remains the primary human debugging tool;
page.pause()andPWDEBUG=1remain the primary local tools. - Python/pytest handles: evidence capture, redaction, failure signatures, clustering, warehouse emission, dashboards.
- AI helps by: explaining clusters, correlating a failure spike with a deployment window, drafting a bug report with reproduction steps, and detecting when 40 failures are one incident.
- Do not trust AI to: close tickets, mute tests, mark failures as known flakiness, trigger retries, or alter test code. Every AI action in this layer is a comment or a proposal.
Interview Perspective
Make the strategic point: execution time is usually not the bottleneck — triage time is. Optimising a 22-minute suite to 18 minutes is worth far less than reducing 60 failures to 3 clusters with an evidence bundle attached to each.
Describe the pipeline: capture structured evidence at runtime, normalise into a failure signature, cluster deterministically, then use a model for explanation with mandatory evidence citation and a confidence threshold. Mention redaction before the model boundary — traces contain tokens and personal data.
Common mistakes: relying on screenshots alone; no trace retention policy; no correlation between failures and deploys; letting AI take actions rather than make suggestions; and treating a rising rerun rate as normal rather than as a measurable, budgeted defect.
Practical Playwright + Python + AI Architecture
A mature framework separates concerns into layers with a strict dependency direction: tests depend on the domain layer, the domain layer depends on the Playwright layer, and nothing depends upward. AI sits beside the stack, never inside the assertion path.
1. Playwright layer (browser control).
Browser and context lifecycle, storage state, tracing, routing and interception, device profiles, frame and popup handling. Rule: this layer speaks browser vocabulary only — no business terms, no test assertions. Everything is a Locator; nothing is a selector string passed around as data.
2. Python domain layer (framework core).
Component objects and page objects that expose behaviour (orders.row(id).cancel()), element contracts, the locator registry, domain models, and configuration per environment. Tests read like the product, not like the DOM. This layer is where maintainability is won or lost: one DOM change should require one edit.
3. pytest layer (execution and isolation).
Fixtures, scopes, markers, parametrisation, worker-aware resources, quarantine policy, collection hooks and reporting hooks. This layer owns isolation and parallel policy: fresh context per test, unique data per test, per-worker identities, grouped tests for shared global resources.
4. API and data layer.
APIRequestContext for setup, teardown and server-side assertions; builders and factories with run/worker namespacing; pydantic models derived from the API schema so drift fails fast. The default flow is arrange via API, act via UI, assert in both.
5. AI assistance layer (out-of-band services).
Four bounded services, each with schema-constrained input and output: an authoring assistant (intent from acceptance criteria, grounded in ARIA snapshots and OpenAPI), a locator proposal service (problem 9), a triage service (problem 10), and a test selection/prioritisation service (problem 5). Non-negotiables: no model call in the hot path of an assertion; every output validated deterministically before use; redaction before the boundary; and full audit logging of model, version, prompt hash and decision so any run can be explained after the fact.
6. Reporting and observability layer.
One structured JSON event per test execution shipped to a warehouse, plus traces, videos and screenshots in access-controlled storage with a retention policy. Dashboards that matter: flake rate by test and by suite, top failure signatures, duration percentiles by shard, heal proposal rate and acceptance, generated-test survival rate, and triage acceptance rate. If AI recommendations are not measured, they cannot be trusted.
7. CI/CD layer.
A pinned container image matching the Playwright version, so browser binaries are a versioned dependency. Tiered execution: PR runs Chromium critical path plus impacted tests (AI-prioritised, never AI-skipped); merge runs the full suite sharded across runners with xdist inside each; nightly runs cross-browser, contract tests against real third parties, the generated-test fault-injection job, and the healing lab. Quality gates block the pipeline on policy violations — brittle locators, undocumented skips, expired quarantine entries, exceeded flake budget.
Key Takeaways
- Locator stability is an application property. Fix it with a test-ID contract and semantic locators, not with runtime inference.
- Never synchronise on time. Synchronise on observable state — responses, URLs, element states, counts — and use the clock API for time-dependent UI.
- Isolation is the parallelism strategy. Unique data per test, per-worker identities and fresh contexts come before
-n auto. - Arrange over the API, act over the UI, assert in both. A UI banner is not proof of persistence.
- Authentication is infrastructure with a security boundary: mint storage state per role with a TTL, and treat traces and tokens as secrets.
- Cross-browser coverage is a risk decision. Pin locale, timezone, viewport and motion first — most "browser bugs" are environment drift.
- Model output is untrusted input. Constrain it with schemas, validate it against a registry, gate it with static checks, and let humans merge.
- A test that cannot fail is worse than a flaky one. Prove assertion strength with fault injection before accepting generated tests.
- Self-healing is telemetry about your locator strategy. Proposals with deterministic gates, confidence thresholds and deny-lists — never silent runtime substitution in CI.
- Optimise triage, not just execution. Capture structured evidence, cluster failures deterministically, then let AI explain with cited evidence and a confidence threshold.
If you are going deeper into Playwright, Python, AI-powered test automation and framework architecture, I write practical engineering playbooks covering these patterns end to end — locator strategy, parallel execution design, AI guardrails, self-healing governance and enterprise framework structure. For 1:1 architecture reviews, mentorship or career discussions, you can reach me on Topmate.
Himanshu Agarwal
Test Architect | AI-Driven QA Automation
HimanshuAI Digital Playbook Store — 50% off all bundles and ebooks
Practical playbooks on AI Testing, SDET, Playwright, LLM, RAG, MCP, GenAI and Enterprise Automation.
- Playbook Store: https://himanshuai.gumroad.com/
- AI System Design Bundle (7 books): https://himanshuai.gumroad.com/l/TheAISystemDesignBundle7Books
- Mega Vault — 1,150+ PDF bundle, $499: https://himanshuai.gumroad.com/l/TheHimanshuAIMegaVault
- Substack newsletter (free daily articles): https://himanshuai.substack.com
- 1:1 consulting: https://topmate.io/himanshuai
- LinkedIn: https://www.linkedin.com/in/himanshuai/
- Email: me@himanshuai.com
If you have solved any of these ten problems differently — particularly self-healing governance or AI-assisted triage — I would be interested to hear how it held up at scale.
Suggested tags for publishing: playwright, python, testing, automation, ai, qa, pytest, sdet
Top comments (0)