Test structure means how we organize test cases, test data, locators, reusable functions, setup, teardown and reporting.
A good structure becomes increasingly important when an automation project grows from a few tests to hundreds or thousands of tests.
A beginner can start with a single Python test file.
from playwright.sync_api import sync_playwright, expect
def test_login():
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://example.com/login")
page.get_by_label("Username").fill("testuser")
page.get_by_label("Password").fill("Password123")
page.get_by_role(
"button",
name="Login"
).click()
expect(page).to_have_url(
"https://example.com/dashboard"
)
expect(
page.get_by_role(
"heading",
name="Dashboard"
)
).to_be_visible()
browser.close()
Maintainability
A locator or workflow should ideally be updated in one reusable place instead of many test files.
Reusability
Login, navigation and common business operations can be reused.
Isolation
Tests should not unexpectedly depend on another test's execution order or state.
Debugging
When a test fails, the structure should make it easier to identify where and why it failed.
A scalable Playwright Python project can separate tests, page objects, fixtures, configuration and test data.
playwright-project/ │ ├── tests/ │ ├── test_login.py │ ├── test_checkout.py │ ├── test_customer.py │ └── test_orders.py │ ├── pages/ │ ├── login_page.py │ ├── dashboard_page.py │ └── checkout_page.py │ ├── fixtures/ │ └── test_fixtures.py │ ├── data/ │ ├── users.json │ └── products.json │ ├── utils/ │ └── helpers.py │ ├── conftest.py ├── pytest.ini └── requirements.txt
Instead of putting every locator directly into every test, a Page Object can contain page-specific interactions.
login_page.py
from playwright.sync_api import Page
class LoginPage:
def __init__(self, page: Page):
self.page = page
self.username = page.get_by_label("Username")
self.password = page.get_by_label("Password")
self.login_button = page.get_by_role(
"button",
name="Login"
)
def open(self):
self.page.goto(
"https://example.com/login"
)
def login(self, username, password):
self.username.fill(username)
self.password.fill(password)
self.login_button.click()
test_login.py
from playwright.sync_api import expect
from pages.login_page import LoginPage
def test_valid_login(page):
login = LoginPage(page)
login.open()
login.login(
"testuser",
"Password123"
)
expect(page).to_have_url(
"https://example.com/dashboard"
)
expect(
page.get_by_role(
"heading",
name="Dashboard"
)
).to_be_visible()
Page Object → knows how to interact with the page.
Test → knows what business behavior should be validated.
Playwright works well with pytest fixtures. A fixture can provide a browser page to a test while keeping setup outside the test itself.
import pytest
from playwright.sync_api import Page
@pytest.fixture
def logged_in_page(page: Page):
page.goto(
"https://example.com/login"
)
page.get_by_label(
"Username"
).fill("testuser")
page.get_by_label(
"Password"
).fill("Password123")
page.get_by_role(
"button",
name="Login"
).click()
return page
Using the Fixture
from playwright.sync_api import expect
def test_dashboard(logged_in_page):
expect(
logged_in_page.get_by_role(
"heading",
name="Dashboard"
)
).to_be_visible()
A robust automation framework normally follows a predictable lifecycle.
Playwright's pytest integration provides browser/page fixtures, while teams can define additional fixtures for application-specific setup.
Test isolation means that one test should not unexpectedly depend on another test's data, execution order or browser state.
test_edit_customer() only works because
test_create_customer() happened before it,
the tests are coupled.
❌ Fragile - Tests Depend On Each Other
This approach assumes the previous test has already created the customer.
from playwright.sync_api import expect
def test_create_customer(page):
page.goto(
"https://example.com/customers"
)
page.get_by_role(
"button",
name="Add Customer"
).click()
page.get_by_label(
"Name"
).fill("John Smith")
page.get_by_label(
"Email"
).fill("john@example.com")
page.get_by_role(
"button",
name="Save"
).click()
expect(
page.get_by_text("John Smith")
).to_be_visible()
def test_edit_customer(page):
# Depends on test_create_customer()
# having already created John Smith.
page.goto(
"https://example.com/customers"
)
page.get_by_text(
"John Smith"
).click()
page.get_by_label(
"Name"
).fill("John Updated")
page.get_by_role(
"button",
name="Save"
).click()
expect(
page.get_by_text("John Updated")
).to_be_visible()
def test_delete_customer(page):
# Depends on the customer still existing.
page.goto(
"https://example.com/customers"
)
page.get_by_text(
"John Updated"
).click()
page.get_by_role(
"button",
name="Delete"
).click()
expect(
page.get_by_text(
"Customer deleted"
)
).to_be_visible()
✓ Robust - Each Test Controls Its State
Each test creates the data required for its own scenario.
from playwright.sync_api import expect
def create_customer(page, name, email):
page.goto(
"https://example.com/customers"
)
page.get_by_role(
"button",
name="Add Customer"
).click()
page.get_by_label(
"Name"
).fill(name)
page.get_by_label(
"Email"
).fill(email)
page.get_by_role(
"button",
name="Save"
).click()
def test_create_customer(page):
create_customer(
page,
"Create User",
"create@example.com"
)
expect(
page.get_by_text("Create User")
).to_be_visible()
def test_edit_customer(page):
create_customer(
page,
"Edit User",
"edit@example.com"
)
page.get_by_text(
"Edit User"
).click()
page.get_by_label(
"Name"
).fill("Updated User")
page.get_by_role(
"button",
name="Save"
).click()
expect(
page.get_by_text("Updated User")
).to_be_visible()
def test_delete_customer(page):
create_customer(
page,
"Delete User",
"delete@example.com"
)
page.get_by_text(
"Delete User"
).click()
page.get_by_role(
"button",
name="Delete"
).click()
expect(
page.get_by_text(
"Customer deleted"
)
).to_be_visible()
Isolation With Unique Test Data
When tests can execute in parallel, using the same customer name or email can create conflicts. Generate unique test data where the application requires uniqueness.
from uuid import uuid4
def generate_customer():
unique_id = uuid4().hex[:8]
return {
"name": f"Test User {unique_id}",
"email": f"user_{unique_id}@example.com"
}
def test_create_customer(page):
customer = generate_customer()
# Use customer["name"]
# Use customer["email"]
page.goto(
"https://example.com/customers"
)
page.get_by_role(
"button",
name="Add Customer"
).click()
page.get_by_label(
"Name"
).fill(customer["name"])
page.get_by_label(
"Email"
).fill(customer["email"])
page.get_by_role(
"button",
name="Save"
).click()
expect(
page.get_by_text(customer["name"])
).to_be_visible()
Another Isolation Problem - Shared Browser State
# Avoid designing tests around a global
# page object shared by unrelated tests.
current_page = None
def test_admin(page):
global current_page
current_page = page
page.goto(
"https://example.com/admin"
)
def test_customer(page):
# Fragile:
# assumes state from another test.
page.goto(
"https://example.com/customer"
)
Hard-coding large amounts of test data directly inside tests can make maintenance difficult.
users.json
{
"valid_user": {
"username": "testuser",
"password": "Password123"
},
"admin_user": {
"username": "adminuser",
"password": "AdminPassword123"
}
}
Reading the Data
import json
with open("data/users.json") as file:
users = json.load(file)
user = users["valid_user"]
username = user["username"]
password = user["password"]
Test names should explain what behavior is being validated.
| Weak Name | Better Name | Why |
|---|---|---|
| test_login | test_valid_user_can_login | Describes the expected behavior. |
| test_checkout | test_customer_can_complete_checkout | Describes the business scenario. |
| test_error | test_invalid_password_shows_error | Identifies the specific condition. |
| test_customer | test_admin_can_approve_customer | Defines actor and action. |
A clean test can often be organized into three logical stages.
Prepare
Perform
Validate
def test_customer_can_place_order(page):
# ARRANGE
page.goto(
"https://example.com/products"
)
product = page.get_by_text("Laptop")
# ACT
product.click()
page.get_by_role(
"button",
name="Add to Cart"
).click()
page.get_by_role(
"button",
name="Checkout"
).click()
# ASSERT
expect(
page.get_by_role(
"heading",
name="Order Confirmation"
)
).to_be_visible()
A common automation mistake is creating one very large test that validates many unrelated business workflows.
❌ Avoid - One Huge Test
This test has too many responsibilities. If step 7 fails, it can become difficult to determine the actual failure boundary.
def test_everything(page):
# Login
page.goto(
"https://example.com/login"
)
page.get_by_label(
"Username"
).fill("testuser")
page.get_by_label(
"Password"
).fill("Password123")
page.get_by_role(
"button",
name="Login"
).click()
# Create customer
page.get_by_role(
"link",
name="Customers"
).click()
page.get_by_role(
"button",
name="Add Customer"
).click()
page.get_by_label(
"Name"
).fill("John Smith")
page.get_by_label(
"Email"
).fill("john@example.com")
page.get_by_role(
"button",
name="Save"
).click()
# Create product
page.get_by_role(
"link",
name="Products"
).click()
page.get_by_role(
"button",
name="Add Product"
).click()
page.get_by_label(
"Product Name"
).fill("Laptop")
page.get_by_role(
"button",
name="Save"
).click()
# Checkout
page.get_by_role(
"link",
name="Shop"
).click()
page.get_by_text(
"Laptop"
).click()
page.get_by_role(
"button",
name="Add to Cart"
).click()
page.get_by_role(
"button",
name="Checkout"
).click()
# Cancel order
page.get_by_role(
"button",
name="Cancel Order"
).click()
# Logout
page.get_by_role(
"button",
name="Logout"
).click()
• Too many responsibilities
• Harder failure diagnosis
• One failure can prevent later scenarios from running
• Difficult to reuse individual workflows
• More difficult to maintain
✓ Prefer - Focused Tests
Split unrelated business behaviors into separate tests.
def test_admin_can_create_customer(page):
login_as_admin(page)
create_customer(
page,
"John Smith",
"john@example.com"
)
expect(
page.get_by_text("John Smith")
).to_be_visible()
def test_admin_can_create_product(page):
login_as_admin(page)
create_product(
page,
"Laptop"
)
expect(
page.get_by_text("Laptop")
).to_be_visible()
def test_customer_can_checkout(page):
login_as_customer(page)
add_product_to_cart(
page,
"Laptop"
)
complete_checkout(page)
expect(
page.get_by_text(
"Order Confirmation"
)
).to_be_visible()
def test_customer_can_cancel_order(page):
login_as_customer(page)
create_test_order(page)
cancel_order(page)
expect(
page.get_by_text(
"Order Cancelled"
)
).to_be_visible()
Reusable Workflow Functions
Focused tests do not mean duplicating every line of code. Common workflows can be extracted into reusable functions, fixtures or Page Objects.
def login_as_admin(page):
page.goto(
"https://example.com/login"
)
page.get_by_label(
"Username"
).fill("adminuser")
page.get_by_label(
"Password"
).fill("AdminPassword123")
page.get_by_role(
"button",
name="Login"
).click()
def create_test_order(page):
page.goto(
"https://example.com/products"
)
page.get_by_text(
"Laptop"
).click()
page.get_by_role(
"button",
name="Add to Cart"
).click()
page.get_by_role(
"button",
name="Checkout"
).click()
page.get_by_role(
"button",
name="Place Order"
).click()
A good project structure cannot compensate for fragile locators.
❌ Fragile Locator
page.locator(
"div.container:nth-child(2) button"
).click()
✓ Semantic Locator
page.get_by_role(
"button",
name="Submit Order"
).click()
Prefer user-facing roles and labels, and use dedicated test IDs when the application provides a stable test contract.
Page Objects can perform interactions, while tests should generally express the business-level validation.
class CheckoutPage:
def __init__(self, page):
self.page = page
self.place_order_button = page.get_by_role(
"button",
name="Place Order"
)
def place_order(self):
self.place_order_button.click()
def test_customer_can_place_order(page):
checkout = CheckoutPage(page)
checkout.place_order()
expect(
page.get_by_role(
"heading",
name="Order Confirmation"
)
).to_be_visible()
Modern web applications often update asynchronously. Playwright's web-first assertions are designed to wait for the expected condition instead of checking the page only once.
❌ Avoid - Immediate Property Check
This checks the current value immediately. If the UI has not updated yet, the assertion can fail even though the application is still processing the action.
def test_save_customer(page):
page.get_by_role(
"button",
name="Save"
).click()
message = page.locator(
"#message"
).text_content()
assert message == "Customer saved"
#message. The direct property check
does not provide the same web-first retry behavior
as a Playwright locator assertion.
✓ Prefer - Web-First Assertion
Use Playwright's assertion API for dynamic UI conditions.
from playwright.sync_api import expect
def test_save_customer(page):
page.get_by_role(
"button",
name="Save"
).click()
expect(
page.locator("#message")
).to_have_text(
"Customer saved"
)
Visibility Assertion
expect(
page.get_by_role(
"heading",
name="Dashboard"
)
).to_be_visible()
Text Assertion
expect(
page.get_by_role(
"heading",
name="Order Confirmation"
)
).to_have_text(
"Order Confirmation"
)
URL Assertion
expect(page).to_have_url(
"https://example.com/dashboard"
)
Dynamic Text With Regular Expression
import re
from playwright.sync_api import expect
expect(
page.get_by_role(
"status"
)
).to_have_text(
re.compile(
r"Order \d+ created"
)
)
Common settings should be centralized instead of repeated throughout every test.
[pytest]
addopts =
-v
testpaths =
tests
Browser and environment-specific configuration can also be centralized through fixtures, command-line options and environment variables.
Consider an application where an admin approves a newly registered customer.
customer_page.py
from playwright.sync_api import Page
class CustomerPage:
def __init__(self, page: Page):
self.page = page
self.customer_search = page.get_by_placeholder(
"Search customers"
)
self.approve_button = page.get_by_role(
"button",
name="Approve"
)
def open(self):
self.page.goto(
"https://example.com/admin/customers"
)
def search_customer(self, email):
self.customer_search.fill(email)
self.page.get_by_role(
"button",
name="Search"
).click()
def approve_customer(self):
self.approve_button.click()
test_customer_approval.py
from playwright.sync_api import expect
from pages.customer_page import CustomerPage
def test_admin_can_approve_customer(page):
customer = CustomerPage(page)
# ARRANGE
customer.open()
# ACT
customer.search_customer(
"customer@example.com"
)
customer.approve_customer()
# ASSERT
expect(
page.get_by_text("Approved")
).to_be_visible()
Large projects often need to execute only a subset of tests: smoke tests, regression tests, checkout tests, API tests, and so on.
import pytest
@pytest.mark.smoke
def test_user_can_login(page):
...
@pytest.mark.regression
def test_customer_can_checkout(page):
...
Register the markers in pytest.ini.
[pytest]
markers =
smoke: critical smoke tests
regression: regression tests
A large test suite may benefit from parallel execution. Parallel execution works best when tests do not unexpectedly share mutable state.
With pytest, teams commonly use tools such as
pytest-xdist when they need distributed or
parallel test execution.
A robust test framework should provide enough information to diagnose failures quickly.
Screenshot
Capture visual state when a failure occurs.
Trace
Use Playwright tracing when detailed execution diagnostics are needed.
Logs
Record useful business and technical information.
Reports
Produce readable results for developers and CI.
The objective is: "which test failed, at what step, with what UI state, and why?"
Hard-coded sleeps pause the test for a fixed amount of time. They do not express the actual condition the test is waiting for.
❌ Avoid - Hard Wait
A fixed sleep can be too short when the application is slow and unnecessarily long when the application is fast.
import time
def test_customer_save(page):
page.goto(
"https://example.com/customers"
)
page.get_by_role(
"button",
name="Save"
).click()
# ❌ Fixed wait
time.sleep(5)
message = page.locator(
"#message"
).text_content()
assert message == "Customer saved"
• Adds unnecessary delay
• May still be insufficient on a slow environment
• Does not describe what the test is waiting for
• Makes failures harder to diagnose
✓ Prefer - Condition-Based Waiting
Express the actual condition that must become true.
from playwright.sync_api import expect
def test_customer_save(page):
page.goto(
"https://example.com/customers"
)
page.get_by_role(
"button",
name="Save"
).click()
# ✓ Wait for the actual UI condition
expect(
page.locator("#message")
).to_have_text(
"Customer saved"
)
Example - Wait for a Button to Become Enabled
from playwright.sync_api import expect
def test_submit_button(page):
page.goto(
"https://example.com/form"
)
page.get_by_label(
"Name"
).fill("John Smith")
submit_button = page.get_by_role(
"button",
name="Submit"
)
expect(
submit_button
).to_be_enabled()
submit_button.click()
Example - Wait for a Loading Indicator to Disappear
from playwright.sync_api import expect
def test_customer_list_loads(page):
page.goto(
"https://example.com/customers"
)
loading = page.get_by_text(
"Loading..."
)
customers = page.get_by_role(
"row"
)
expect(
loading
).to_be_hidden()
expect(
customers
).not_to_have_count(0)
Example - Wait for Navigation State Through URL Assertion
from playwright.sync_api import expect
def test_login_redirect(page):
page.goto(
"https://example.com/login"
)
page.get_by_label(
"Username"
).fill("testuser")
page.get_by_label(
"Password"
).fill("Password123")
page.get_by_role(
"button",
name="Login"
).click()
expect(page).to_have_url(
"https://example.com/dashboard"
)
Ask: "What condition tells me the application is ready?"
from playwright.sync_api import expect
from pages.login_page import LoginPage
from pages.dashboard_page import DashboardPage
def test_valid_user_can_login(page):
# =========================
# ARRANGE
# =========================
login = LoginPage(page)
dashboard = DashboardPage(page)
login.open()
# =========================
# ACT
# =========================
login.login(
"testuser",
"Password123"
)
# =========================
# ASSERT
# =========================
expect(
dashboard.heading
).to_be_visible()
expect(
dashboard.heading
).to_have_text(
"Dashboard"
)
# =========================
# BUSINESS VALIDATION
# =========================
expect(
dashboard.user_menu
).to_contain_text(
"testuser"
)
This structure makes the intent of the test immediately visible to another engineer.
| Problem | Fragile Approach | Robust Approach |
|---|---|---|
| Locators | Repeated CSS/XPath everywhere | Centralized reusable locators |
| Setup | Repeated browser/login setup | Fixtures |
| Test dependency | Test B requires Test A | Independent test state |
| Synchronization | Hard sleeps | Playwright actions and web-first assertions |
| Validation | One large generic assertion | Specific business assertions |
| Data | Large hard-coded datasets | Dedicated test data/configuration |
| Debugging | Only console output | Reports, screenshots and traces where appropriate |
| Execution | Tests rely on ordering | Tests designed for isolation and parallelism |
The key principle is separation of responsibility. Each layer should have a clear purpose and a small number of reasons to change.
Test = What should be validated?
Fixture = What setup does the test need?
Page Object = How does the UI work?
Locator = Which element should be used?
Action = What should the user do?
Assertion = What result should be true?