Writing Playwright Tests¶
Playwright tests live in apps/customer-frontend-e2e/ and drive a real browser against the customer-facing storefront. They are used for two purposes:
- Local / PR testing — Playwright boots a local preview build and runs tests against it
- Staging smoke tests — after a production deploy, the same tests run against the live staging URL to confirm nothing is broken
File structure¶
apps/customer-frontend-e2e/
├── src/
│ ├── pages/ ← Page Object files (one per screen)
│ │ ├── storefront.page.ts
│ │ ├── product.page.ts
│ │ └── checkout.page.ts
│ ├── storefront-browse.spec.ts ← Storefront browsing tests
│ └── checkout.spec.ts ← Checkout flow tests
└── playwright.config.ts
Page Object Model¶
All selectors and page interactions live in Page Object classes — not in spec files. This keeps specs readable and means selector changes only need to be made in one place.
Creating a Page Object¶
// src/pages/storefront.page.ts
import { Page } from '@playwright/test';
export class StorefrontPage {
constructor(private page: Page) {}
async goto(storefrontUrl: string) {
await this.page.goto(storefrontUrl);
}
async getProductCards() {
return this.page.locator('[data-testid="product-card"]');
}
async searchProducts(query: string) {
await this.page.getByRole('searchbox').fill(query);
await this.page.getByRole('button', { name: 'Search' }).click();
}
async clickProduct(name: string) {
await this.page.getByText(name).first().click();
}
}
Using a Page Object in a spec¶
// src/storefront-browse.spec.ts
import { test, expect } from '@playwright/test';
import { StorefrontPage } from './pages/storefront.page';
test.describe('Storefront browsing', () => {
test('should display product listing', async ({ page }) => {
const storefront = new StorefrontPage(page);
await storefront.goto('/');
const cards = await storefront.getProductCards();
await expect(cards.first()).toBeVisible();
});
test('should filter products by search', async ({ page }) => {
const storefront = new StorefrontPage(page);
await storefront.goto('/');
await storefront.searchProducts('shirt');
const cards = await storefront.getProductCards();
await expect(cards.first()).toBeVisible();
});
});
data-testid attributes¶
Playwright selectors should use data-testid attributes where possible. These are stable — they don't change when text or class names are updated for design reasons.
Add to the component:
Use in tests:
For interactive elements with clear accessible roles (buttons, links, inputs), prefer Playwright's built-in role selectors:
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByLabel('Email address').fill('test@example.com');
What to test¶
Focus on high-value user journeys. Cover the happy path in full, then add one or two negative/edge cases.
Priority flows¶
| Flow | Spec file | Status |
|---|---|---|
| Storefront product browsing | storefront-browse.spec.ts |
To build |
| Customer checkout | checkout.spec.ts |
To build |
| Authentication (login/logout) | auth.spec.ts |
Backlog |
| Admin product management | admin-product.spec.ts (admin-portal-e2e) |
Backlog |
Checkout flow example¶
// src/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { StorefrontPage } from './pages/storefront.page';
import { ProductPage } from './pages/product.page';
import { CheckoutPage } from './pages/checkout.page';
test('customer can complete a purchase', async ({ page }) => {
const storefront = new StorefrontPage(page);
const product = new ProductPage(page);
const checkout = new CheckoutPage(page);
await storefront.goto('/');
await storefront.clickProduct('Test Product');
await product.addToCart();
await product.proceedToCheckout();
await checkout.fillContactDetails({
firstName: 'Jane',
lastName: 'Smith',
email: 'jane@example.com',
});
await checkout.fillShippingAddress({
line1: '123 Test Street',
city: 'Sydney',
postcode: '2000',
country: 'Australia',
});
await checkout.placeOrder();
await expect(page.getByTestId('order-confirmation')).toBeVisible();
});
Running tests¶
# Run all Playwright tests (auto-starts preview server)
pnpm nx e2e customer-frontend-e2e
# Run a specific spec
pnpm nx e2e customer-frontend-e2e --project=chromium -- storefront-browse
# Run against staging instead of local
BASE_URL=https://staging.yourapp.com pnpm nx e2e customer-frontend-e2e
To debug a failing test interactively:
Playwright also generates a trace file on failure. Open it with:
CI behaviour¶
On pull requests, Playwright builds and runs the preview server locally — no external dependencies needed. The playwright.config.ts is already configured for this:
webServer: {
command: 'pnpm exec nx run customer-frontend:preview',
url: 'http://localhost:4300',
reuseExistingServer: !process.env.CI,
}
On staging smoke test runs (BASE_URL is set), the web server step is skipped and Playwright hits the URL directly.
Common mistakes¶
Hardcoded text selectors — avoid page.getByText('Add to Cart') for buttons. Text changes break tests. Use data-testid or role selectors.
Missing await — every Playwright interaction is async. Forgetting await causes tests to pass incorrectly or produce confusing errors.
Testing visual design — Playwright tests are for functional flows, not pixel-perfect layout. Don't assert exact colours, font sizes, or margins. Use Test Execution for visual/cross-browser checks.
Slow tests from repeated logins — if multiple tests need an authenticated session, use Playwright's storageState to save and reuse the session rather than logging in fresh for each test.