Skip to content

E2E Tests — Common Mistakes

Mistake 1: Fragile DOM-Path Selectors

Selectors copied from Chrome DevTools describe the DOM structure, not the user intent. Any layout refactor breaks them — even if the feature still works perfectly.

import pytest
from playwright.sync_api import Page, expect


@pytest.mark.e2e
class TestCreateTaskBad:
    """Bad: selectors describe DOM structure, not user intent."""

    def test_create_task(self, page: Page) -> None:
        page.goto("http://localhost:3000/tasks")
        page.locator(
            "#root > div.layout > main > div.toolbar > button:nth-child(2)"
        ).click()
        page.locator(
            "div.modal-body > form > input.form-control-lg"
        ).fill("Fix payment bug")
        page.locator(
            "div.modal-footer > button.btn-primary"
        ).click()
        assert page.locator("td.task-title").first.is_visible()  # no retry


@pytest.mark.e2e
class TestCreateTaskGood:
    """Good: locators describe what the user sees and does."""

    def test_create_task(self, page: Page) -> None:
        page.goto("http://localhost:3000/tasks")
        page.get_by_role("button", name="New Task").click()
        page.get_by_label("Title").fill("Fix payment bug")
        page.get_by_role("button", name="Create").click()
        expect(page.get_by_text("Fix payment bug")).to_be_visible()

Rule for stable selectors: - get_by_role, get_by_label, get_by_text — preferred - data-testid attribute ([data-testid="create-btn"]) — reliable fallback - Unique IDs (#login-form) — fine - DOM-path (div > section:nth-child(2) > button) — avoid

A simple check: if the selector still works after wrapping the component in a new <div>, it's stable enough.


Mistake 2: No Wait for Async Operations

Clicking a button and immediately checking the result before the API call finishes. Raw assert checks once and moves on — no retry.

@pytest.mark.e2e
class TestAsyncBad:
    def test_save_profile(self, page: Page) -> None:
        page.get_by_role("button", name="Save").click()
        # API might still be in-flight
        assert page.locator(".toast-success").is_visible()  # flaky!


@pytest.mark.e2e
class TestAsyncGood:
    def test_save_profile(self, page: Page) -> None:
        page.get_by_role("button", name="Save").click()
        # Retries until visible or timeout — stable
        expect(page.get_by_text("Profile saved")).to_be_visible()

Rule: always use expect(locator).to_be_visible() from playwright.sync_api. Never use raw assert locator.is_visible() for state after async operations.


Mistake 3: Testing Field Validation Through E2E

Every validation rule gets an E2E test: empty email, short password, invalid phone. Each takes 5–30 seconds. That's the unit test job — runs in milliseconds.

@pytest.mark.e2e
class TestRegistrationValidationBad:
    """Bad: validation rules checked through browser — 30s+ per test."""

    def test_empty_email_shows_error(self, page: Page) -> None:
        page.goto("http://localhost:3000/register")
        page.get_by_role("button", name="Sign Up").click()
        expect(page.get_by_text("Email is required")).to_be_visible()

    def test_short_password_shows_error(self, page: Page) -> None:
        page.goto("http://localhost:3000/register")
        page.get_by_label("Password").fill("abc")
        page.get_by_role("button", name="Sign Up").click()
        expect(page.get_by_text("at least 8 characters")).to_be_visible()

    # ... 8 more validation rules, each a separate E2E test

The validator is already covered in milliseconds at the unit level. The only E2E test needed here is that the form submits successfully when valid.

@pytest.mark.e2e
class TestRegistrationGood:
    """Good: E2E only checks the successful happy path."""

    def test_valid_registration_redirects_to_onboarding(
        self, page: Page,
    ) -> None:
        page.goto("http://localhost:3000/register")
        page.get_by_label("Email").fill("newuser@company.com")
        page.get_by_label("Password").fill("Str0ng!Pass")
        page.get_by_role("button", name="Sign Up").click()
        expect(page).to_have_url("/onboarding")

Checklist

Check Bad Pattern Fix
Locator stability nth-child, deep CSS path get_by_role, get_by_label, data-testid
Async waits assert .is_visible() expect(...).to_be_visible()
Test scope Validation rules in E2E Move to unit parametrize
Test count 200 E2E tests Keep only critical user flows
Shared login state Login in every test logged_in_page fixture
Selector after refactor Breaks on layout change If it survives a <div> wrap, it's stable