Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Copilot instructions — ecommerce-e2e-playwright

## What this repo is
End-to-end UI test suite for the demo shop **automationexercise.com**.
Python + Playwright (sync API) + pytest, Page Object Model. 12 tests across
auth, search, cart, checkout. ~365 LOC. Runs headless in CI on push and PR
across Chromium/Firefox/WebKit.

## Layout
- `pages/` — Page Object Model. One class per page; `base_page.py` holds shared
navigation (with 5xx retry) and the `parse_price` helper. `flows.py` holds
cross-page actions (e.g. `register_via_ui`) that several tests reuse.
- `tests/` — one file per area (`test_auth`, `test_search`, `test_cart`,
`test_checkout`, `test_smoke`). Tests read as business scenarios; no selectors.
- `utils/data_generator.py` — Faker-backed factories returning `TypedDict`s
(`User`, `PaymentCard`). Every test gets fresh, unique data.
- `conftest.py` — fixtures: API-backed user setup/teardown, ad blocking via
network route, and a `base_url` fixture (sourced from `pytest.ini`).
- `.github/workflows/tests.yml` — CI matrix.

## How to build / run
```bash
pip install -r requirements.txt # versions are pinned, do not loosen
playwright install chromium
pytest # full suite, chromium
pytest -n 4 # parallel (data isolation supports it)
pytest --browser firefox # other engines
```
Tests hit a live third-party site, so transient failures are expected; CI uses
`--reruns 2`. A green local run is the bar before committing.

## Conventions to follow in reviews and changes
- **No `time.sleep()`.** Use Playwright auto-waiting and web-first `expect`.
- **Selectors, in order of preference:** `data-qa` attributes, then stable CSS
classes, then `get_by_role`. Avoid matching on display text (breaks under
i18n) and never use XPath. Note: some buttons are `<a>` with no `href`, so
they have NO ARIA `link` role — `get_by_role("link", ...)` will not find them;
use the stable class (e.g. `a.check_out`).
- **Page objects hold selectors and actions; tests hold assertions and flow.**
Don't put raw locators in test files. Reusable multi-page journeys go in
`pages/flows.py`, not duplicated across tests.
- **Test data is typed.** Extend the `TypedDict`s in `utils/data_generator.py`
rather than passing loose dicts. The signup form and account API use different
field names (`first_name` vs `firstname`) — map explicitly, don't rename keys.
- **Every test owns its data** and must be order-agnostic and xdist-safe (unique
emails via uuid). Don't introduce shared mutable state between tests.
- **Pin dependencies.** `requirements.txt` uses `==`; bump deliberately, never
switch to `>=`.
- **Don't hardcode `--browser` in `pytest.ini`** — it must stay overridable from
the CLI and the CI matrix.

## What to flag in PR review
- Any `time.sleep`, XPath, or text-based selector for a button.
- New locators living in `tests/` instead of `pages/`.
- Loosened dependency pins.
- Tests that depend on execution order or another test's side effects.
- New cross-page flows duplicated instead of added to `pages/flows.py`.

## Trust these instructions
Only search the codebase if something here is incomplete or proves wrong.
24 changes: 18 additions & 6 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
name: e2e-tests

on: [push, workflow_dispatch]
on:
push:
pull_request:
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4

Expand All @@ -17,20 +24,25 @@ jobs:
run: pip install -r requirements.txt

- name: Install browser
run: playwright install --with-deps chromium
run: playwright install --with-deps ${{ matrix.browser }}

# --reruns guards against the demo site's intermittent 503 load-shedding;
# genuine regressions still fail consistently and turn the badge red
# -n 4 runs tests in parallel (data isolation in conftest.py supports it).
# Capped at 4 rather than `auto`: the demo site sheds load under heavy
# concurrency, so more workers means more flaky 503s, not more speed.
# --reruns guards against that intermittent load-shedding; genuine
# regressions still fail consistently and turn the badge red.
- name: Run e2e tests
run: >
pytest --html=report.html --self-contained-html
pytest --browser ${{ matrix.browser }}
-n 4
--html=report.html --self-contained-html
--tracing retain-on-failure --reruns 2 --reruns-delay 30

- name: Upload report and failure artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: test-report
name: test-report-${{ matrix.browser }}
path: |
report.html
test-results/
14 changes: 7 additions & 7 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# virtualenv
.venv/

# python
# Python
__pycache__/
*.py[cod]

# pytest / playwright artifacts
.venv/
venv/
.pytest_cache/
test-results/

# Test artifacts
report.html
test-results/
playwright-report/
trace.zip
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,21 @@ money path a real client pays to keep green.

## Stack

Playwright (Chromium) · pytest · Page Object Model · Faker · pytest-html · GitHub Actions
Playwright (Chromium · Firefox · WebKit) · pytest · Page Object Model · Faker ·
pytest-html · pytest-xdist · GitHub Actions

## Run it locally

```bash
pip install -r requirements.txt
playwright install chromium
playwright install
pytest --html=report.html --self-contained-html
Comment thread
Copilot marked this conversation as resolved.

# parallel (data isolation supports it)
pytest -n 4

# a different engine
pytest --browser firefox
```

## Design decisions
Expand All @@ -42,11 +49,17 @@ pytest --html=report.html --self-contained-html
through the site's [REST API](https://automationexercise.com/api_list) in fixtures
([conftest.py](conftest.py)), so tests are independent and order-agnostic.
- **Resilient selectors** — `get_by_role` / `data-qa` attributes, no brittle XPath.
Where the markup fights ARIA (e.g. an `<a class="check_out">` with no `href`, which
therefore has no `link` role), a stable CSS class beats matching on display text.
- **Typed test data** — user/payment factories return `TypedDict`s ([utils/data_generator.py](utils/data_generator.py)),
so editors and `pyright` catch a missing or misspelled field before it reaches the form.
- **No `time.sleep()`** — Playwright auto-waiting and web-first `expect` assertions only.
- **Ad traffic is blocked at network level** — the target site serves ad iframes that can
cover buttons and steal clicks; a route filter in `conftest.py` keeps runs deterministic.
- **Cross-browser in CI** — the GitHub Actions matrix runs the full suite on Chromium,
Firefox and WebKit on every push ([.github/workflows/tests.yml](.github/workflows/tests.yml)).
- **Failure diagnostics** — screenshots on failure locally; in CI also Playwright traces,
uploaded with the HTML report as a workflow artifact.
uploaded with the HTML report as a per-browser workflow artifact.

## Field notes from automating this target

Expand All @@ -70,8 +83,6 @@ Real-world issues found and handled while building the suite:

## What I'd add next

- Cross-browser matrix in CI (Firefox, WebKit) — one-line change with `--browser`.
- Parallel execution with `pytest-xdist` (data isolation already supports it).
- Visual regression checks for key pages.
- An API test layer reusing the same data factories.
- Adopt an open-source target (e.g. RealWorld/Conduit) to file real bug reports upstream.
13 changes: 10 additions & 3 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@
import pytest
from playwright.sync_api import Playwright

from utils.data_generator import generate_user
from utils.data_generator import User, generate_user

# The target site is ad-supported; ad iframes sometimes cover buttons and
# intercept clicks, so all ad/analytics requests are dropped at network level.
AD_HOSTS = re.compile(
r"(googlesyndication|doubleclick|adservice|google-analytics|googletagmanager)"
r"(googlesyndication|doubleclick|adservice|google-analytics|googletagmanager|fundingchoices)"
)

# pytest-playwright reads `base_url` from pytest.ini, but that value is NOT
# propagated to xdist workers (-n auto), so navigations there hit "/" with no
# host. Re-expose it as a fixture sourced from the ini so parallel runs work.
@pytest.fixture(scope="session")
def base_url(request) -> str:
return request.config.getini("base_url")


@pytest.fixture(autouse=True)
def block_ads(context):
Expand All @@ -26,7 +33,7 @@ def api(playwright: Playwright, base_url: str):
client.dispose()


def _delete_account(api, user: dict) -> None:
def _delete_account(api, user: User) -> None:
# idempotent: a 404 response code for an already-deleted account is fine
api.delete(
"/api/deleteAccount",
Expand Down
4 changes: 3 additions & 1 deletion pages/cart_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ def remove_item(self, index: int = 0) -> None:
self.rows.nth(index).locator(".cart_quantity_delete").click()

def proceed_to_checkout(self) -> None:
self.page.get_by_text("Proceed To Checkout").click()
# The button is an <a class="check_out"> without href, so it has no
# ARIA "link" role; target the stable class instead of brittle text.
self.page.locator("a.check_out").click()
3 changes: 2 additions & 1 deletion pages/checkout_page.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from playwright.sync_api import Page

from pages.base_page import BasePage
from utils.data_generator import PaymentCard


class CheckoutPage(BasePage):
Expand Down Expand Up @@ -33,7 +34,7 @@ def __init__(self, page: Page):
"Congratulations! Your order has been confirmed!"
)

def pay(self, card: dict) -> None:
def pay(self, card: PaymentCard) -> None:
page = self.page
page.locator('[data-qa="name-on-card"]').fill(card["name_on_card"])
page.locator('[data-qa="card-number"]').fill(card["card_number"])
Expand Down
26 changes: 26 additions & 0 deletions pages/flows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Cross-page user flows that several tests share.

Lives outside the page objects because it spans pages (login -> signup),
and outside conftest because it's a reusable action, not a fixture.
"""
from playwright.sync_api import Page, expect

from pages.login_page import LoginPage
from pages.signup_page import SignupPage
from utils.data_generator import User


def register_via_ui(page: Page, user: User) -> SignupPage:
"""Full UI signup journey ending logged in. Returns the SignupPage
so callers can chain logout/delete-account checks."""
login = LoginPage(page)
login.open()
login.start_signup(user["name"], user["email"])

signup = SignupPage(page)
expect(signup.form_heading).to_be_visible()
signup.fill_account_details(user)
expect(signup.account_created_message).to_be_visible()
signup.continue_after_signup()
expect(signup.logged_in_as(user["name"])).to_be_visible()
return signup
3 changes: 2 additions & 1 deletion pages/signup_page.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from playwright.sync_api import Page

from pages.base_page import BasePage
from utils.data_generator import User


class SignupPage(BasePage):
Expand All @@ -14,7 +15,7 @@ def __init__(self, page: Page):
self.account_created_message = page.locator('[data-qa="account-created"]')
self.account_deleted_message = page.locator('[data-qa="account-deleted"]')

def fill_account_details(self, user: dict) -> None:
def fill_account_details(self, user: User) -> None:
page = self.page
page.get_by_role("radio", name="Mr.").check()
page.locator('[data-qa="password"]').fill(user["password"])
Expand Down
4 changes: 3 additions & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
[pytest]
testpaths = tests
base_url = https://automationexercise.com
addopts = --browser chromium --screenshot only-on-failure --output test-results
# Browser is NOT hardcoded here so `--browser firefox|webkit` works from the CLI
# and the CI matrix. Default (chromium) comes from pytest-playwright.
addopts = --screenshot only-on-failure --output test-results
12 changes: 7 additions & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
pytest>=8.0
pytest-playwright>=0.5.2
pytest-html>=4.1
pytest-rerunfailures>=14.0
faker>=33.0
# Pinned for reproducible CI runs. Bump deliberately, not by surprise.
pytest==9.1.0
pytest-playwright==0.8.0
pytest-html==4.2.0
pytest-rerunfailures==16.3
pytest-xdist==3.8.0
faker==40.23.0
24 changes: 7 additions & 17 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,20 @@
"""Authentication: signup, login, and the negative paths real clients always ask about."""
from playwright.sync_api import Page, expect

from pages.flows import register_via_ui
from pages.login_page import LoginPage
from pages.signup_page import SignupPage
from utils.data_generator import User


def test_register_new_user(page: Page, new_user: dict):
login = LoginPage(page)
login.open()
expect(login.signup_heading).to_be_visible()
login.start_signup(new_user["name"], new_user["email"])

signup = SignupPage(page)
expect(signup.form_heading).to_be_visible()
signup.fill_account_details(new_user)
expect(signup.account_created_message).to_be_visible()

signup.continue_after_signup()
expect(signup.logged_in_as(new_user["name"])).to_be_visible()
def test_register_new_user(page: Page, new_user: User):
signup = register_via_ui(page, new_user)

# removing the account through the UI doubles as a check of that flow
signup.delete_account()
expect(signup.account_deleted_message).to_be_visible()


def test_login_with_valid_credentials(page: Page, registered_user: dict):
def test_login_with_valid_credentials(page: Page, registered_user: User):
login = LoginPage(page)
login.open()
expect(login.login_heading).to_be_visible()
Expand All @@ -33,7 +23,7 @@ def test_login_with_valid_credentials(page: Page, registered_user: dict):
expect(login.logged_in_as(registered_user["name"])).to_be_visible()


def test_login_with_wrong_password_shows_error(page: Page, registered_user: dict):
def test_login_with_wrong_password_shows_error(page: Page, registered_user: User):
login = LoginPage(page)
login.open()
login.login(registered_user["email"], "wrong-" + registered_user["password"])
Expand All @@ -42,7 +32,7 @@ def test_login_with_wrong_password_shows_error(page: Page, registered_user: dict
expect(login.logged_in_as(registered_user["name"])).not_to_be_visible()


def test_signup_with_taken_email_shows_error(page: Page, registered_user: dict):
def test_signup_with_taken_email_shows_error(page: Page, registered_user: User):
login = LoginPage(page)
login.open()
login.start_signup("Another Person", registered_user["email"])
Expand Down
16 changes: 4 additions & 12 deletions tests/test_checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@

from pages.cart_page import CartPage
from pages.checkout_page import CheckoutPage, PaymentPage
from pages.login_page import LoginPage
from pages.flows import register_via_ui
from pages.product_page import ProductPage
from pages.signup_page import SignupPage
from utils.data_generator import generate_payment_card
from utils.data_generator import User, generate_payment_card


def test_checkout_requires_login(page: Page):
Expand All @@ -21,16 +20,9 @@ def test_checkout_requires_login(page: Page):
expect(cart.checkout_login_prompt).to_be_visible()


def test_full_purchase_flow(page: Page, new_user: dict):
def test_full_purchase_flow(page: Page, new_user: User):
"""Registration -> product -> cart -> checkout -> payment -> confirmation."""
login = LoginPage(page)
login.open()
login.start_signup(new_user["name"], new_user["email"])
signup = SignupPage(page)
signup.fill_account_details(new_user)
expect(signup.account_created_message).to_be_visible()
signup.continue_after_signup()
expect(signup.logged_in_as(new_user["name"])).to_be_visible()
signup = register_via_ui(page, new_user)

products = ProductPage(page)
products.open()
Expand Down
Loading
Loading