Skip to content

Commit e7ce1a4

Browse files
authored
Merge pull request #3424 from Shopify/e2e-line-items
feat(e2e): add cart line item E2E test suite
2 parents ab18c4c + 5a034c6 commit e7ce1a4

File tree

3 files changed

+647
-81
lines changed

3 files changed

+647
-81
lines changed

e2e/CLAUDE.md

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# E2E Testing Guidelines
2+
3+
## Test Isolation
4+
5+
Playwright automatically provides test isolation - each test runs in its own browser context with isolated storage, cookies, and state. You generally don't need to manually clear cookies or storage between tests.
6+
7+
**Exception:** If you need to clear state within a single test (e.g., testing empty cart after clearing), do so explicitly in that test.
8+
9+
## Test Organization
10+
11+
### Test Setup Strategy
12+
13+
Per [Playwright best practices](https://playwright.dev/docs/best-practices#make-tests-as-isolated-as-possible): Use `beforeEach` hooks to eliminate repetitive setup steps while maintaining test isolation.
14+
15+
```typescript
16+
// GOOD: Use beforeEach for shared setup
17+
test.describe('Quantity Management', () => {
18+
test.beforeEach(async ({storefront}) => {
19+
await storefront.goto('/');
20+
await storefront.navigateToFirstProduct();
21+
await storefront.addToCart();
22+
});
23+
24+
test('increases quantity', async ({storefront}) => {
25+
const firstItem = storefront.getCartLineItemByIndex(0);
26+
await storefront.increaseLineItemQuantity(firstItem);
27+
expect(await storefront.getLineItemQuantity(firstItem)).toBe(2);
28+
});
29+
});
30+
31+
// ACCEPTABLE: Duplicate simple 1-2 line setups when it improves clarity
32+
test('adds item to empty cart', async ({storefront}) => {
33+
await storefront.goto('/');
34+
await storefront.navigateToFirstProduct();
35+
await storefront.addToCart();
36+
37+
await expect(storefront.getCartLineItems()).toHaveCount(1);
38+
});
39+
40+
// AVOID: Repeating 3+ lines in every test
41+
test('increases quantity', async ({storefront}) => {
42+
await storefront.goto('/'); // Repeated
43+
await storefront.navigateToFirstProduct(); // Repeated
44+
await storefront.addToCart(); // Repeated
45+
// Use beforeEach instead
46+
});
47+
```
48+
49+
## Selector Strategy
50+
51+
### DOM Elements over CSS
52+
Always choose selectors based on **DOM elements and semantic structure**, NOT CSS classes or styles. Tests should reflect how a user perceives and interacts with the page.
53+
54+
**Priority order for selectors:**
55+
1. **Role + accessible name** (preferred): `getByRole('button', {name: 'Add to cart'})`
56+
2. **Role + landmark**: `getByRole('banner').getByRole('link', {name: /cart/i})`
57+
3. **Text content**: `getByText('Continue to Checkout')`
58+
4. **Test IDs** (when semantic selectors aren't possible): `getByTestId('cart-drawer')`
59+
5. **CSS classes** (last resort only): Only when semantic selectors are impractical
60+
61+
**Always try role-based selectors first.** Only use CSS classes when:
62+
- The element has no semantic role
63+
- Multiple similar elements need disambiguation
64+
- Role-based selectors would be overly complex
65+
66+
### Write Tests from User Perspective
67+
Write tests based on how a user would perceive events, not implementation details.
68+
69+
```typescript
70+
// GOOD: Wait for user-visible state change
71+
await storefront.addGiftCard('GIFT123');
72+
await expect(giftCardInput).toHaveValue(''); // Input cleared = success
73+
await expect(applyButton).toBeEnabled(); // Button enabled = ready
74+
75+
// AVOID: Waiting for network requests (implementation detail)
76+
await storefront.addGiftCard('GIFT123');
77+
await page.waitForResponse(resp => resp.url().includes('cart'));
78+
```
79+
80+
### Waiting for State Changes
81+
Wait for the **visible effect** rather than the underlying mechanism:
82+
83+
```typescript
84+
// GOOD: Wait for actual data change
85+
await increaseButton.click();
86+
await expect.poll(() => getLineItemQuantity(item)).toBe(2);
87+
88+
// AVOID: Waiting for button state (re-enables before data updates)
89+
await increaseButton.click();
90+
await expect(increaseButton).toBeEnabled();
91+
```
92+
93+
## Running Tests
94+
95+
### Always Use Headless Mode
96+
Tests should ALWAYS be run in headless mode, both in development and in CI. This:
97+
- Prevents browser windows from interfering with other work
98+
- Ensures consistent behavior across environments
99+
- Is faster than headed mode
100+
101+
The Playwright config does not specify a headed mode by default, so tests run headless automatically. Never add `headless: false` to the config or pass `--headed` flag:
102+
103+
```bash
104+
# CORRECT: Run tests (headless by default)
105+
npx playwright test --project=skeleton
106+
107+
# AVOID: Running with headed browser
108+
npx playwright test --project=skeleton --headed # Don't do this
109+
```
110+
111+
If you need to debug visually, use Playwright's trace viewer or UI mode temporarily, but never commit headed configuration.
112+
113+
## Fixture Design Principles
114+
115+
### Deep Modules
116+
Following John Ousterhout's principles, fixtures should expose **entity locators** but hide **implementation details**:
117+
118+
```typescript
119+
// GOOD: Entity locators are public
120+
getCartLineItems() // Returns the domain entities
121+
getCartDrawer() // Returns the cart drawer element
122+
123+
// GOOD: Button locators hidden inside action methods
124+
async increaseLineItemQuantity(lineItem) {
125+
const button = lineItem.getByRole('button', {name: 'Increase quantity'}); // Hidden
126+
await button.click();
127+
// ... wait for effect
128+
}
129+
```
130+
131+
### Visibility-Aware Locators
132+
The skeleton template has both a cart drawer (aside) and a cart page. Both contain similar elements but only one is visible at a time.
133+
134+
```typescript
135+
// GOOD: Role-based selectors with chaining for specificity
136+
getCartLineItems() {
137+
return this.page.getByRole('list', {name: /cart|line items/i}).getByRole('listitem');
138+
}
139+
140+
getCheckoutButton() {
141+
return this.page.getByRole('button', {name: /checkout/i});
142+
}
143+
144+
// ACCEPTABLE: Test IDs when semantic selectors need disambiguation
145+
getCartLineItems() {
146+
return this.page.getByTestId('cart-line-items').getByRole('listitem');
147+
}
148+
149+
// AVOID: CSS selectors (DOM can change, leading to flaky tests)
150+
getCartLineItems() {
151+
return this.page.locator('li.cart-line:visible'); // Not resilient
152+
}
153+
```
154+
155+
**Why avoid CSS?** Per [Playwright docs](https://playwright.dev/docs/locators), CSS selectors are "not recommended as the DOM can often change leading to non resilient tests." Use role-based selectors that reflect how users perceive the page.

e2e/fixtures/storefront.ts

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type {Page, BrowserContext} from '@playwright/test';
1+
import type {Page, BrowserContext, Locator} from '@playwright/test';
22
import {expect} from '@playwright/test';
33

44
// Privacy Banner element IDs
@@ -804,6 +804,179 @@ export class StorefrontPage {
804804
]);
805805
}
806806

807+
// ============================================================================
808+
// Cart Line Item Helpers
809+
// ============================================================================
810+
811+
/**
812+
* Returns all visible cart line items. Uses visibility filter to avoid
813+
* matching hidden elements in the cart drawer when on the cart page.
814+
*/
815+
getCartLineItems(): Locator {
816+
return this.page.locator('li.cart-line:visible');
817+
}
818+
819+
/**
820+
* Returns a specific visible cart line item by index.
821+
*/
822+
getCartLineItemByIndex(index: number): Locator {
823+
return this.page.locator('li.cart-line:visible').nth(index);
824+
}
825+
826+
/**
827+
* Returns the cart drawer dialog (uses semantic role selector).
828+
*/
829+
getCartDrawer(): Locator {
830+
return this.page.getByRole('dialog');
831+
}
832+
833+
/**
834+
* Returns the visible empty cart message (uses regex to handle curly apostrophe U+2019).
835+
* Scoped to visible .cart-main to avoid matching hidden drawer content.
836+
*/
837+
getCartEmptyMessage(): Locator {
838+
return this.page
839+
.locator('.cart-main:visible')
840+
.getByText(/Looks like you haven.t added anything yet/);
841+
}
842+
843+
/**
844+
* Returns the checkout button/link.
845+
*/
846+
getCheckoutButton(): Locator {
847+
return this.page.getByRole('link', {name: /Continue to Checkout/i});
848+
}
849+
850+
/**
851+
* Opens the cart aside drawer by clicking the cart badge in the header.
852+
*/
853+
async openCartAside(): Promise<void> {
854+
const cartLink = this.page
855+
.getByRole('banner')
856+
.getByRole('link', {name: /cart/i});
857+
await cartLink.click();
858+
await expect(this.getCartDrawer()).toBeVisible({timeout: 5000});
859+
}
860+
861+
/**
862+
* Closes the cart aside drawer.
863+
*/
864+
async closeCartAside(): Promise<void> {
865+
const closeButton = this.getCartDrawer().getByRole('button', {
866+
name: 'Close',
867+
});
868+
if (await closeButton.isVisible()) {
869+
await closeButton.click();
870+
await expect(this.getCartDrawer()).not.toBeVisible();
871+
}
872+
}
873+
874+
/**
875+
* Gets the cart badge count from the header.
876+
*/
877+
async getCartBadgeCount(): Promise<number> {
878+
const cartLink = this.page
879+
.getByRole('banner')
880+
.getByRole('link', {name: /cart/i});
881+
const text = await cartLink.textContent();
882+
const match = text?.match(/\d+/);
883+
return match ? parseInt(match[0], 10) : 0;
884+
}
885+
886+
/**
887+
* Extracts the quantity number from a line item.
888+
*/
889+
async getLineItemQuantity(lineItem: Locator): Promise<number> {
890+
const quantityText = await lineItem
891+
.locator('small')
892+
.filter({hasText: 'Quantity:'})
893+
.textContent();
894+
const match = quantityText?.match(/Quantity:\s*(\d+)/);
895+
return match ? parseInt(match[1], 10) : 0;
896+
}
897+
898+
/**
899+
* Increases quantity and waits for the quantity text to actually change.
900+
* Uses expect.poll to wait for the effect, not just button re-enablement.
901+
*/
902+
async increaseLineItemQuantity(lineItem: Locator): Promise<void> {
903+
const currentQuantity = await this.getLineItemQuantity(lineItem);
904+
const increaseButton = lineItem.getByRole('button', {
905+
name: 'Increase quantity',
906+
});
907+
908+
await expect(increaseButton).toBeEnabled({timeout: 5000});
909+
await increaseButton.click();
910+
911+
await expect
912+
.poll(() => this.getLineItemQuantity(lineItem), {timeout: 10000})
913+
.toBe(currentQuantity + 1);
914+
}
915+
916+
/**
917+
* Decreases quantity and waits for the quantity text to actually change.
918+
*/
919+
async decreaseLineItemQuantity(lineItem: Locator): Promise<void> {
920+
const currentQuantity = await this.getLineItemQuantity(lineItem);
921+
const decreaseButton = lineItem.getByRole('button', {
922+
name: 'Decrease quantity',
923+
});
924+
925+
await expect(decreaseButton).toBeEnabled({timeout: 5000});
926+
await decreaseButton.click();
927+
928+
await expect
929+
.poll(() => this.getLineItemQuantity(lineItem), {timeout: 10000})
930+
.toBe(currentQuantity - 1);
931+
}
932+
933+
/**
934+
* Removes a line item and waits for it to disappear.
935+
*/
936+
async removeLineItem(lineItem: Locator): Promise<void> {
937+
const removeButton = lineItem.getByRole('button', {name: 'Remove'});
938+
await expect(removeButton).toBeEnabled({timeout: 5000});
939+
await removeButton.click();
940+
await expect(lineItem).not.toBeVisible({timeout: 10000});
941+
}
942+
943+
/**
944+
* Extracts subtotal as a number (removes currency formatting).
945+
* Handles multiple currency formats like "$1,234.56" or "1.234,56 €".
946+
* Uses :visible to match only the current context (drawer or page).
947+
*/
948+
async getSubtotalAmount(): Promise<number> {
949+
const subtotalElement = this.page.locator('.cart-subtotal:visible dd');
950+
const text = await subtotalElement.textContent();
951+
if (!text) return 0;
952+
953+
// For US format ($1,234.56): remove commas, keep decimal point
954+
// For EU format (1.234,56 €): swap comma/dot
955+
const cleaned = text.replace(/[^\d.,]/g, '');
956+
// Detect format: if last separator is comma, it's EU format
957+
const lastCommaIndex = cleaned.lastIndexOf(',');
958+
const lastDotIndex = cleaned.lastIndexOf('.');
959+
960+
let numericString: string;
961+
if (lastCommaIndex > lastDotIndex) {
962+
// EU format: 1.234,56 -> 1234.56
963+
numericString = cleaned.replace(/\./g, '').replace(',', '.');
964+
} else {
965+
// US format: 1,234.56 -> 1234.56
966+
numericString = cleaned.replace(/,/g, '');
967+
}
968+
969+
return parseFloat(numericString) || 0;
970+
}
971+
972+
/**
973+
* Clears all cookies to reset cart and session state.
974+
* Cart state is stored in a cookie, so clearing cookies resets the cart.
975+
*/
976+
async clearAllCookies(): Promise<void> {
977+
await this.context.clearCookies();
978+
}
979+
807980
/**
808981
* Set the `withPrivacyBanner` value by intercepting the Hydrogen JS bundle.
809982
* This injects code to directly set the value before Hydrogen's default check.

0 commit comments

Comments
 (0)