UI Testing — Wait Strategies, Retry & Selector Abstraction
Wait Strategies
The #1 cause of flaky UI tests is improper waiting.
sleep() is never a wait strategy — it is a flakiness source with a timer.
Playwright Auto-Wait
Playwright auto-waits for elements to be actionable before interacting. "Actionable" means: visible, enabled, stable (not animating), attached to DOM.
# Playwright waits for element to be actionable automatically
page.click('[data-testid="submit"]') # waits until button is clickable
page.fill('[data-testid="email"]', "user@example.com") # waits until input is editable
No explicit wait needed for most interactions.
Explicit Waits — When Auto-Wait Is Not Enough
from playwright.sync_api import Page, expect
def wait_for_toast(page: Page, message: str, timeout: int = 5000) -> None:
toast = page.get_by_test_id("toast-notification")
expect(toast).to_contain_text(message, timeout=timeout)
def wait_for_table_loaded(page: Page, timeout: int = 10000) -> None:
loading_spinner = page.get_by_test_id("table-loading")
expect(loading_spinner).not_to_be_visible(timeout=timeout)
def wait_for_url(page: Page, pattern: str, timeout: int = 10000) -> None:
page.wait_for_url(pattern, timeout=timeout)
Network-Aware Waiting
Wait for a specific network response before asserting:
def test_search_results_loaded(page):
with page.expect_response("**/api/search**") as response_info:
page.fill('[data-testid="search-input"]', "widget")
page.click('[data-testid="search-submit"]')
response = response_info.value
assert response.status == 200
expect(page.get_by_test_id("search-results")).to_be_visible()
Retry Logic
Playwright Built-In Retry (for flakiness from async UI)
# pytest.ini or pyproject.toml
# [tool.pytest.ini_options]
# retries = 2 # pytest-retry
from playwright.sync_api import expect
# expect() retries assertions up to the configured timeout
expect(page.get_by_test_id("order-status")).to_have_text("CONFIRMED", timeout=10000)
Custom Retry Decorator (for API calls)
import time
import logging
from functools import wraps
from typing import Callable, TypeVar
log = logging.getLogger(__name__)
T = TypeVar("T")
def retry(max_attempts: int = 3, delay: float = 1.0, exceptions: tuple = (Exception,)):
def decorator(func: Callable[..., T]) -> Callable[..., T]:
@wraps(func)
def wrapper(*args, **kwargs) -> T:
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as exc:
last_exception = exc
log.warning(
"Attempt %d/%d failed for %s: %s",
attempt, max_attempts, func.__name__, exc,
)
if attempt < max_attempts:
time.sleep(delay)
raise last_exception # type: ignore[misc]
return wrapper
return decorator
Usage:
@retry(max_attempts=3, delay=0.5)
def wait_for_order_status(api_client, order_id: str, expected: str) -> None:
order = api_client.orders.get(order_id)
assert order["status"] == expected, f"Expected {expected}, got {order['status']}"
Selector Abstraction Layer
Centralise all selectors. Tests never contain raw selector strings.
from dataclasses import dataclass
@dataclass(frozen=True)
class LoginSelectors:
username_input: str = '[data-testid="username"]'
password_input: str = '[data-testid="password"]'
submit_button: str = '[data-testid="submit"]'
error_message: str = '[data-testid="error-msg"]'
forgot_password_link: str = '[data-testid="forgot-password"]'
@dataclass(frozen=True)
class DashboardSelectors:
welcome_heading: str = '[data-testid="welcome-heading"]'
user_menu: str = '[data-testid="user-menu"]'
logout_button: str = '[data-testid="logout"]'
Benefits: - Selector changes affect one file, not every test - IDE autocomplete catches typos before test run - Selectors are documented by their dataclass field name
Network Interception for Faster UI Tests
Stub slow external calls in UI tests to reduce execution time:
def test_payment_page_shows_error_on_gateway_failure(page):
page.route("**/api/payments/**", lambda route: route.fulfill(
status=502,
content_type="application/json",
body='{"error": "Gateway unavailable"}',
))
# Navigate to checkout
page.goto("/checkout")
page.get_by_test_id("pay-button").click()
expect(page.get_by_test_id("payment-error")).to_contain_text(
"Payment service unavailable"
)
Screenshot on Failure
import pytest
from pathlib import Path
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
page = item.funcargs.get("page")
if page:
screenshot_dir = Path("test-results/screenshots")
screenshot_dir.mkdir(parents=True, exist_ok=True)
screenshot_path = screenshot_dir / f"{item.nodeid.replace('/', '_')}.png"
page.screenshot(path=str(screenshot_path))