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
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,71 @@ The project includes a GitHub Actions workflow (`.github/workflows/tests.yml`) t
6. **Error Handling**: Comprehensive error messages and assertions
7. **Documentation**: Clear docstrings and README

## Design Decisions

- **Playwright + pytest**:
- Playwright provides modern, reliable browser automation with built-in auto-waiting and powerful debugging tools.
- pytest offers a simple, expressive test syntax and rich plugin ecosystem (fixtures, markers, HTML reports).
- **Page Object Model (POM)**:
- Pages encapsulate selectors and actions so tests focus on business flows.
- Mapping:
- `LoginPage` → `tests/test_login.py`
- `InventoryPage` → `tests/test_cart.py`, `tests/test_checkout.py`
- `CartPage` → `tests/test_cart.py`, `tests/test_checkout.py`
- `CheckoutPage` → `tests/test_checkout.py`
- **Suite layering via markers**:
- `@pytest.mark.login` for authentication coverage.
- `@pytest.mark.cart` for cart and inventory/cart interactions.
- `@pytest.mark.checkout` for checkout flows.
- A subset of high-value end-to-end tests can be additionally marked as `smoke` for faster CI runs.

## Flakiness Strategy

- Rely on **Playwright auto-waiting** instead of arbitrary sleeps.
- Use explicit readiness checks such as:
- `InventoryPage.is_loaded()` after login or refresh.
- `CartPage.is_loaded()` after navigating to the cart.
- `CheckoutPage.is_step_one_loaded()` and `CheckoutPage.is_overview_loaded()` around checkout transitions.
- For absence checks (e.g., empty cart badge), use low/zero timeouts so tests fail fast instead of waiting for the full default timeout.
- Keep tests **stateless and isolated** by using fresh pages/contexts via pytest fixtures.

## Navigation & Edge Coverage

Navigation-related scenarios covered by the suite include:

- Direct navigation to `inventory.html` without login redirects back to the login page.
- After logout, attempts to access `inventory.html` are redirected to login.
- Cart state persists across:
- Navigation between inventory and cart.
- Refreshing the cart page.
- Refreshing the inventory page (cart badge count preserved).
- Checkout back/forward behavior:
- From checkout step two, using browser Back returns to step one with data still filled.
- Using browser Forward returns to step two with overview still loaded.
- Logged-in users can open `inventory.html` in a **new tab** and remain authenticated.

## Debugging Failures

Useful commands during local debugging:

- Run tests in headed mode:
```bash
pytest --headed
```
- Slow down interactions for visual inspection:
```bash
pytest --headed --slowmo 200
```
- Run a specific test with verbose output:
```bash
pytest -v tests/test_checkout.py::TestCheckout::test_complete_checkout_flow
```

Artifacts:

- **Screenshots / videos**: Saved under `test-results/` when tests fail (configured via pytest/Playwright options).
- **HTML report**: Generated as `reports/report.html` after pytest runs.

## Troubleshooting

### Common Issues
Expand Down
77 changes: 77 additions & 0 deletions pages/checkout_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ def __init__(self, page: Page):
self.error_message = page.locator('[data-test="error"]')

# Checkout Overview Step
self.overview_items = page.locator(".cart_item")
self.subtotal_label = page.locator(".summary_subtotal_label")
self.tax_label = page.locator(".summary_tax_label")
self.total_label = page.locator(".summary_total_label")
self.finish_button = page.get_by_role("button", name="Finish")

# Checkout Complete Step
Expand All @@ -44,6 +48,19 @@ def fill_customer_info(self, first_name: str, last_name: str, postal_code: str)
self.first_name_input.fill(first_name)
self.last_name_input.fill(last_name)
self.postal_code_input.fill(postal_code)

def is_step_one_loaded(self) -> bool:
"""
Check whether checkout step one (customer information) is loaded.

Returns:
True if all required input fields are visible.
"""
return (
self.first_name_input.is_visible()
and self.last_name_input.is_visible()
and self.postal_code_input.is_visible()
)

def continue_to_overview(self) -> None:
"""Click continue button to proceed to checkout overview."""
Expand Down Expand Up @@ -91,3 +108,63 @@ def back_to_home(self) -> None:
def cancel_checkout(self) -> None:
"""Cancel checkout and return to cart."""
self.cancel_button.click()

def get_overview_items(self) -> list[dict]:
"""
Get all items listed on the checkout overview page.

Returns:
List of dictionaries containing item name, price and quantity.
"""
items: list[dict] = []
for item in self.overview_items.all():
name = item.locator(".inventory_item_name").inner_text()
price = item.locator(".inventory_item_price").inner_text()
quantity = item.locator(".cart_quantity").inner_text()
items.append(
{
"name": name,
"price": price,
"quantity": quantity,
}
)
return items

@staticmethod
def _parse_amount_from_label(label_text: str) -> float:
"""
Parse a monetary amount from a summary label such as
'Item total: $39.98' or 'Tax: $3.20'.
"""
parts = label_text.split("$")
if len(parts) < 2:
return 0.0
number_part = parts[-1].strip()
try:
return float(number_part)
except ValueError:
return 0.0

def get_subtotal(self) -> float:
"""Return the item subtotal value from the overview page."""
text = self.subtotal_label.inner_text()
return self._parse_amount_from_label(text)

def get_tax(self) -> float:
"""Return the tax value from the overview page."""
text = self.tax_label.inner_text()
return self._parse_amount_from_label(text)

def get_total(self) -> float:
"""Return the total value from the overview page."""
text = self.total_label.inner_text()
return self._parse_amount_from_label(text)

def is_overview_loaded(self) -> bool:
"""
Check whether checkout overview (step two) is loaded.

Returns:
True if the subtotal label is visible.
"""
return self.subtotal_label.is_visible()
75 changes: 63 additions & 12 deletions pages/inventory_page.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
Page Object Model for SauceDemo Inventory/Products Page.
"""
from playwright.sync_api import Page, Locator
from playwright.sync_api import Page, Locator, expect


class InventoryPage:
Expand All @@ -15,11 +15,14 @@ def __init__(self, page: Page):
page: Playwright Page instance
"""
self.page = page
self.cart_icon = page.locator('.shopping_cart_link')
self.sort_dropdown = page.locator('[data-test="product_sort_container"]')
self.product_items = page.locator('.inventory_item')
self.menu_button = page.locator('#react-burger-menu-btn')
self.logout_link = page.locator('#logout_sidebar_link')
self.cart_icon = page.locator(".shopping_cart_link")
self.sort_dropdown = page.locator(".product_sort_container")
self.product_items = page.locator(".inventory_item")
self.product_name_elements = page.locator(".inventory_item_name")
self.product_price_elements = page.locator(".inventory_item_price")
self.product_desc_elements = page.locator(".inventory_item_desc")
self.menu_button = page.locator("#react-burger-menu-btn")
self.logout_link = page.locator("#logout_sidebar_link")

def is_loaded(self) -> bool:
"""
Expand All @@ -38,7 +41,7 @@ def add_item_to_cart(self, item_name: str) -> None:
item_name: Name of the product to add
"""
# Find the product item container
item = self.page.locator('.inventory_item').filter(has_text=item_name)
item = self.page.locator(".inventory_item").filter(has_text=item_name)
# Click the "Add to cart" button for this item
add_button = item.locator('button').filter(has_text='Add to cart')
add_button.click()
Expand All @@ -50,8 +53,8 @@ def remove_item_from_cart(self, item_name: str) -> None:
Args:
item_name: Name of the product to remove
"""
item = self.page.locator('.inventory_item').filter(has_text=item_name)
remove_button = item.locator('button').filter(has_text='Remove')
item = self.page.locator(".inventory_item").filter(has_text=item_name)
remove_button = item.locator("button").filter(has_text="Remove")
remove_button.click()

def get_cart_count(self) -> int:
Expand All @@ -61,7 +64,7 @@ def get_cart_count(self) -> int:
Returns:
Number of items in cart, 0 if badge is not visible
"""
cart_badge = self.page.locator('.shopping_cart_badge')
cart_badge = self.page.locator(".shopping_cart_badge")
if cart_badge.count() == 0:
return 0
if cart_badge.is_visible(timeout=0):
Expand All @@ -75,6 +78,8 @@ def sort_by(self, option: str) -> None:
Args:
option: Sort option value (e.g., 'az', 'za', 'lohi', 'hilo')
"""
expect(self.product_items.first).to_be_visible()
self.sort_dropdown.wait_for(state="attached")
self.sort_dropdown.select_option(option)

def open_cart(self) -> None:
Expand All @@ -88,8 +93,54 @@ def get_product_names(self) -> list[str]:
Returns:
List of product names
"""
product_name_elements = self.page.locator('.inventory_item_name')
return [name.inner_text() for name in product_name_elements.all()]
return [name.inner_text() for name in self.product_name_elements.all()]

def get_products(self) -> list[dict]:
"""
Get all products displayed in the inventory with their basic information.

Returns:
List of dictionaries containing product name, description and price.
"""
products: list[dict] = []
for item in self.product_items.all():
name = item.locator(".inventory_item_name").inner_text()
description = item.locator(".inventory_item_desc").inner_text()
price = item.locator(".inventory_item_price").inner_text()
products.append(
{
"name": name,
"description": description,
"price": price,
}
)
return products

def get_product_prices(self) -> list[float]:
"""
Get the list of product prices as floats.

Returns:
List of product prices.
"""
prices: list[float] = []
for price_el in self.product_price_elements.all():
text = price_el.inner_text().strip().replace("$", "")
try:
prices.append(float(text))
except ValueError:
continue
return prices

def open_product_details(self, item_name: str) -> None:
"""
Open the product details page for a given item.

Args:
item_name: Name of the product whose details page should be opened.
"""
item = self.page.locator(".inventory_item").filter(has_text=item_name)
item.locator(".inventory_item_name").click()

def logout(self) -> None:
"""Logout from the application."""
Expand Down
21 changes: 21 additions & 0 deletions pages/login_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ def __init__(self, page: Page):
self.password_input = page.get_by_placeholder("Password")
self.login_button = page.get_by_role("button", name="Login")
self.error_message = page.locator('[data-test="error"]')
# Error UI elements
self.error_icon = page.locator(".error_icon")
self.error_close_button = page.locator('[data-test="error-button"]')

def goto(self) -> None:
"""Navigate to the login page."""
Expand Down Expand Up @@ -47,6 +50,24 @@ def get_error_message(self) -> str:
if self.error_message.is_visible():
return self.error_message.inner_text()
return ""

def dismiss_error(self) -> None:
"""
Dismiss the error message using the close (X) button if visible.
"""
if self.error_close_button.is_visible():
self.error_close_button.click()

def has_error_icon(self) -> bool:
"""
Check whether the error icon is visible next to the form fields.

Returns:
True if the error icon is visible, False otherwise.
"""
if self.error_icon.count() == 0:
return False
return self.error_icon.first.is_visible()

def is_loaded(self) -> bool:
"""
Expand Down
Loading