This is Part 1 of the TaskMaster build. We'll set up the project skeleton, write the dataclass models, build BasePage plus two real page objects (LoginPage and TaskListPage), wire up the conftest with auth and API fixtures, and finish with three passing tests — login, create-task, filter-tasks. By the end of this lesson you'll have a working framework you can layer the rest of the capstone on top of. Part 2 covers the API tests, network mocks, visual tests, a11y, and CI.
Step 1 — Project setup
Start with a fresh directory:
mkdir taskmaster-tests
cd taskmaster-tests
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activateBuild out the structure:
taskmaster-tests/
├── pages/
│ └── __init__.py
├── tests/
│ ├── conftest.py
│ ├── auth/
│ │ ├── conftest.py
│ │ └── test_login.py
│ ├── tasks/
│ │ └── test_crud.py
│ ├── filtering/
│ │ └── test_filters.py
│ └── api/
│ └── test_tasks_api.py
├── utils/
│ └── __init__.py
├── fixtures/
│ └── users/
├── .auth/ ← gitignored
├── reports/ ← gitignored
├── .gitignore
├── requirements.txt
├── pytest.ini
└── README.md
requirements.txt:
playwright==1.44.0
pytest==8.2.0
pytest-playwright==0.5.0
pytest-xdist==3.6.1
pytest-rerunfailures==14.0
allure-pytest==2.13.5
axe-playwright-python==1.1.0
pytest.ini:
[pytest]
addopts = --browser chromium
base_url = http://localhost:3000
testpaths = tests
markers =
smoke: critical-path tests run on every PR
regression: full regression suite
slow: tests that take > 30 seconds
api: API-only tests (no browser)
visual: visual regression tests
a11y: accessibility tests
filterwarnings =
error::pytest.PytestUnknownMarkWarning.gitignore:
.venv/
.auth/
reports/
test-results/
allure-results/
**/__pycache__/
**/.pytest_cache/
Install: pip install -r requirements.txt && playwright install --with-deps.
Step 2 — Data models
utils/data_factory.py:
import time
import uuid
from dataclasses import dataclass
def _unique_suffix() -> str:
return f"{int(time.time() * 1000)}-{uuid.uuid4().hex[:8]}"
@dataclass
class TestUser:
name: str = "Test User"
email: str = ""
password: str = "TestPass123"
role: str = "member"
def __post_init__(self):
if not self.email:
self.email = f"user-{_unique_suffix()}@taskmaster.test"
@dataclass
class TestTask:
title: str = ""
description: str = "A test task"
priority: str = "medium"
status: str = "todo"
due_date: str = "2025-12-31"
def __post_init__(self):
if not self.title:
self.title = f"Task {_unique_suffix()}"
def create_user(**kwargs) -> TestUser:
return TestUser(**kwargs)
def create_task(**kwargs) -> TestTask:
return TestTask(**kwargs)Each dataclass uses __post_init__ to fill in unique fields when not explicitly set. UUID-suffix guarantees parallel safety; the timestamp keeps the values sortable in logs.
Step 3 — Page objects
pages/base_page.py:
from playwright.sync_api import Page
class BasePage:
def __init__(self, page: Page):
self.page = page
def navigate(self, path: str):
self.page.goto(path)
def wait_for_page_load(self):
self.page.wait_for_load_state("networkidle")pages/login_page.py:
from playwright.sync_api import Page, Locator, expect
from pages.base_page import BasePage
class LoginPage(BasePage):
def __init__(self, page: Page):
super().__init__(page)
self.email_input: Locator = page.get_by_label("Email")
self.password_input: Locator = page.get_by_label("Password")
self.submit_button: Locator = page.get_by_role("button", name="Sign in")
self.error_message: Locator = page.get_by_test_id("error-message")
self.register_link: Locator = page.get_by_role("link", name="Create account")
def goto(self):
self.navigate("/login")
def login(self, email: str, password: str):
self.email_input.fill(email)
self.password_input.fill(password)
self.submit_button.click()
def expect_error(self, text: str):
expect(self.error_message).to_contain_text(text)pages/task_list_page.py:
from playwright.sync_api import Page, Locator, expect
from pages.base_page import BasePage
class TaskListPage(BasePage):
def __init__(self, page: Page):
super().__init__(page)
self.new_task_button: Locator = page.get_by_role("button", name="New task")
self.task_cards: Locator = page.get_by_test_id("task-card")
self.status_filter: Locator = page.get_by_label("Status")
self.priority_filter: Locator = page.get_by_label("Priority")
self.search_input: Locator = page.get_by_placeholder("Search tasks")
def goto(self):
self.navigate("/tasks")
def open_new_task_dialog(self):
self.new_task_button.click()
def create_task(self, title: str, description: str = "", priority: str = "medium"):
self.open_new_task_dialog()
self.page.get_by_label("Title").fill(title)
if description:
self.page.get_by_label("Description").fill(description)
self.page.get_by_label("Priority").select_option(priority)
self.page.get_by_role("button", name="Create").click()
def filter_by_status(self, status: str):
self.status_filter.select_option(status)
def search(self, query: str):
self.search_input.fill(query)
self.search_input.press("Enter")
def expect_task_count(self, count: int):
expect(self.task_cards).to_have_count(count)Both inherit BasePage, both hold typed Locator attributes, both expose actions at the right level of abstraction. Notice the methods do one thing each — create_task doesn't assert; expect_task_count doesn't act. Tests compose these primitives into the actual flow.
Step 4 — Auth fixtures
tests/conftest.py:
import pytest
from pathlib import Path
from playwright.sync_api import Browser
from pages.login_page import LoginPage
from pages.task_list_page import TaskListPage
AUTH_DIR = Path("tests/.auth")
AUTH_DIR.mkdir(parents=True, exist_ok=True)
def _login_and_save(browser: Browser, base_url: str, email: str, password: str, role: str) -> str:
context = browser.new_context(base_url=base_url)
page = context.new_page()
page.goto("/login")
page.get_by_label("Email").fill(email)
page.get_by_label("Password").fill(password)
page.get_by_role("button", name="Sign in").click()
page.wait_for_url("/tasks")
state_path = AUTH_DIR / f"{role}.json"
context.storage_state(path=str(state_path))
context.close()
return str(state_path)
@pytest.fixture(scope="session")
def admin_storage_state(browser, base_url):
return _login_and_save(browser, base_url, "admin@taskmaster.test", "AdminPass", "admin")
@pytest.fixture(scope="session")
def member_storage_state(browser, base_url):
return _login_and_save(browser, base_url, "member@taskmaster.test", "MemberPass", "member")
@pytest.fixture
def admin_page(browser, admin_storage_state, base_url):
context = browser.new_context(storage_state=admin_storage_state, base_url=base_url)
page = context.new_page()
yield page
context.close()
@pytest.fixture
def member_page(browser, member_storage_state, base_url):
context = browser.new_context(storage_state=member_storage_state, base_url=base_url)
page = context.new_page()
yield page
context.close()
# Page-object fixtures so tests don't have to construct them
@pytest.fixture
def login_page(page) -> LoginPage:
return LoginPage(page)
@pytest.fixture
def task_list_page(member_page) -> TaskListPage:
return TaskListPage(member_page)Two storage-state fixtures for two roles, two *_page fixtures that open contexts from the saved state, and page-object fixtures that wrap them. A test that takes task_list_page as a parameter starts logged in as the member role — no UI login overhead per test.
How the foundation comes together
Step 1 of 5
1. Project setup
venv created, requirements installed, playwright install --with-deps run, folder structure laid out, pytest.ini and .gitignore in place.
Step 5 — The first three tests
tests/auth/test_login.py:
import pytest
from playwright.sync_api import expect
from pages.login_page import LoginPage
@pytest.mark.smoke
class TestLogin:
def test_admin_can_log_in(self, login_page: LoginPage, page):
login_page.goto()
login_page.login("admin@taskmaster.test", "AdminPass")
expect(page).to_have_url("/tasks")
def test_invalid_credentials_show_error(self, login_page: LoginPage):
login_page.goto()
login_page.login("admin@taskmaster.test", "WrongPassword")
login_page.expect_error("Invalid email or password")tests/tasks/test_crud.py:
import pytest
from pages.task_list_page import TaskListPage
from utils.data_factory import create_task
@pytest.mark.smoke
class TestTasksCrud:
def test_member_can_create_task(self, task_list_page: TaskListPage):
task = create_task(title="Buy groceries", priority="high")
task_list_page.goto()
task_list_page.create_task(title=task.title, description=task.description, priority=task.priority)
# The new task appears in the list
from playwright.sync_api import expect
expect(task_list_page.page.get_by_text(task.title)).to_be_visible()tests/filtering/test_filters.py:
import pytest
from pages.task_list_page import TaskListPage
from utils.data_factory import create_task
@pytest.mark.regression
class TestFilters:
def test_filter_by_status_done(self, task_list_page: TaskListPage):
# Create one done task and one todo task via the page object
for status_value in ["todo", "done"]:
t = create_task(title=f"Task-{status_value}")
task_list_page.create_task(title=t.title, priority=t.priority)
# ... mark the second one done via UI ...
task_list_page.goto()
task_list_page.filter_by_status("done")
task_list_page.expect_task_count(1)Run them:
pytest tests/ -vThree green ticks. The framework is real.
What you have at the end of Part 1
By this point your repo contains:
- A clean folder structure that matches the chapter 8 convention.
- Dataclass-based factories with parallel-safe defaults.
- Two page objects under a base class.
- Session-scoped login that logs in once for admin and once for member, then reuses the stored state.
- Page-object fixtures that compose with the auth fixtures.
- Three passing tests across two feature folders, with smoke and regression markers.
The shape will hold for the rest of the capstone. Adding the remaining 22 tests is filling in the matrix — auth has two more (registration, logout, session-persistence), tasks have four more (edit, complete, delete, validation), filtering has four more (priority, assignee, due-date, search), and the API/visual/a11y buckets are entirely new.
Part 2 covers those buckets — the API tests using page.request, the network mocks for empty/error/slow states, visual baselines via to_have_screenshot, axe-core scans, and the GitHub Actions workflow that runs all of it in parallel with Allure reporting.
Tips before you proceed
A few things that save time when you're building this for real:
- Run the whole suite often. Don't write 25 tests then fix them all at once. Run after every test you write — five seconds of feedback beats a five-minute debugging archaeology session.
- Use
--headedwhile authoring. Watch the test in a real browser, see exactly where it gets stuck, fix and continue. Drop--headedonly when the test is stable and you commit. - When a test fails on CI but passes locally, the cause is almost always env-specific. Cookie domains, timing on slow runners, missing system fonts. Don't fight the symptom; pin the cause and fix it.
- Commit incrementally. One logical change per commit. The git log becomes a tutorial for your future self and your reviewers.
You have the foundation. Part 2 fills in the production-quality details.