10 · Project — Automate a Login Flow¶
This project ties together every Module 1–9 concept into one small but real
test file: project setup, first script structure, locators, actions,
expect assertions, waiting, forms, and headed/headless execution. It uses
https://www.saucedemo.com/, a public demo store built specifically for
automation practice, with a known set of test accounts.
Goal¶
Write a pytest test file that:
- Logs in with valid credentials and asserts success.
- Logs in with invalid credentials and asserts the specific error shown.
- Logs in as a known "locked out" user and asserts that specific error.
- Adds an item to the cart after logging in and asserts the cart badge.
- Logs out and confirms you're returned to the login page.
Project setup¶
pytest.ini:
The test file¶
# tests/test_login_flow.py
from playwright.sync_api import Page, expect
BASE_URL = "https://www.saucedemo.com/"
def login(page: Page, username: str, password: str) -> None:
page.goto(BASE_URL)
page.get_by_placeholder("Username").fill(username)
page.get_by_placeholder("Password").fill(password)
page.get_by_role("button", name="Login").click()
def test_valid_login_reaches_inventory_page(page: Page):
login(page, "standard_user", "secret_sauce")
expect(page).to_have_url(BASE_URL + "inventory.html")
expect(page.get_by_text("Products")).to_be_visible()
def test_invalid_password_shows_error(page: Page):
login(page, "standard_user", "wrong_password")
error = page.locator("[data-test='error']")
expect(error).to_be_visible()
expect(error).to_contain_text("Username and password do not match")
expect(page).to_have_url(BASE_URL) # never navigated away
def test_locked_out_user_shows_locked_error(page: Page):
login(page, "locked_out_user", "secret_sauce")
error = page.locator("[data-test='error']")
expect(error).to_contain_text("Sorry, this user has been locked out")
def test_login_then_add_item_updates_cart_badge(page: Page):
login(page, "standard_user", "secret_sauce")
first_product = page.locator(".inventory_item").first
product_name = first_product.locator(".inventory_item_name").text_content()
first_product.get_by_role("button", name="Add to cart").click()
badge = page.locator(".shopping_cart_badge")
expect(badge).to_have_text("1")
page.locator(".shopping_cart_link").click()
expect(page.locator(".inventory_item_name")).to_have_text(product_name)
def test_logout_returns_to_login_page(page: Page):
login(page, "standard_user", "secret_sauce")
expect(page).to_have_url(BASE_URL + "inventory.html")
page.locator("#react-burger-menu-btn").click()
page.get_by_role("link", name="Logout").click()
expect(page).to_have_url(BASE_URL)
expect(page.get_by_role("button", name="Login")).to_be_visible()
Running it¶
tests/test_login_flow.py::test_valid_login_reaches_inventory_page PASSED [ 20%]
tests/test_login_flow.py::test_invalid_password_shows_error PASSED [ 40%]
tests/test_login_flow.py::test_locked_out_user_shows_locked_error PASSED [ 60%]
tests/test_login_flow.py::test_login_then_add_item_updates_cart_badge PASSED [ 80%]
tests/test_login_flow.py::test_logout_returns_to_login_page PASSED [100%]
============================== 5 passed in 4.87s ===============================
tests/test_login_flow.py::test_valid_login_reaches_inventory_page PASSED [ 20%]
tests/test_login_flow.py::test_invalid_password_shows_error PASSED [ 40%]
tests/test_login_flow.py::test_locked_out_user_shows_locked_error PASSED [ 60%]
tests/test_login_flow.py::test_login_then_add_item_updates_cart_badge PASSED [ 80%]
tests/test_login_flow.py::test_logout_returns_to_login_page PASSED [100%]
============================== 5 passed in 12.41s ===============================
What this project deliberately practices¶
- A helper function (
login) instead of repeating the same three lines in every test — a small preview of the Page Object Model formalized in Level 2 Module 1. - Testing the negative path (wrong password, locked-out account) with the same rigor as the happy path — a login flow that's only ever tested with correct credentials hasn't actually verified the error handling a real user will eventually hit.
- Asserting on state that changed, not just "no crash happened" — the cart badge count and the product name carried across pages are concrete, checkable facts, not vague "did anything look wrong" checks.
expect(...)everywhere, never a plainasserton a live-fetched DOM value, so the suite tolerates normal small rendering delays without being flaky.
Extending it further (optional)¶
- Parametrize the invalid-login test over multiple bad usernames/passwords
using
@pytest.mark.parametrize, asserting the same error path each time. - Add a test for the
problem_useraccount (another built-in saucedemo test account) and see if you can spot the intentionally broken behavior it exhibits — a good exercise in noticing an app defect through E2E testing rather than being told about it in advance. - Run the whole file with
--browser firefoxand--browser webkitand confirm all five tests still pass identically across engines.
How It Actually Works¶
Nothing in this file introduces new API surface — its value for
understanding internals is in seeing the CDP round trips stack up across
one realistic flow. login() alone triggers: a Page.navigate +
lifecycle-event wait for goto, two fill() calls that each resolve a
locator via an accessibility-tree query and dispatch input/change
events directly, and a click() that runs the full actionability retry
loop before dispatching real Input.dispatchMouseEvent mouse-down/up
events on the Login button — after which the driver watches for the
resulting Page.frameNavigated to know the SPA/page transition occurred,
which is exactly what the subsequent expect(page).to_have_url(...) is
polling for.
The pytest-playwright page fixture used throughout this file (formalized
in Level 2 Module 2) is what actually owns the Browser/BrowserContext
lifecycle: it launches one browser process per test session (reused across
tests for speed) but creates a fresh BrowserContext per test function,
which is why five independent tests in this file never leak cookies,
localStorage, or the saucedemo session between each other — each gets a
brand-new isolated CDP browser context with an empty cookie jar, torn down
with Target.disposeBrowserContext after the test completes.
Exercise¶
- Build the project exactly as shown and get all 5 tests passing headless.
- Re-run headed with
--slowmo 200and watch each test execute — confirm your mental model of each step matches what you see on screen. - Add a parametrized version of the invalid-login test covering at least three different bad credential combinations from a single test function.
- Investigate the
problem_useraccount: log in with it, add an item to the cart, and see if the product image or some other element looks wrong compared tostandard_user. Write one sentence describing the bug you found — this is the exact kind of defect E2E testing exists to catch. - Run the full file against all three engines (
--browser chromium,--browser firefox,--browser webkit) and confirm identical results, completing Level 1.