Skip to content

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:

  1. Local / PR testing — Playwright boots a local preview build and runs tests against it
  2. 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:

<div data-testid="product-card">...</div>
<button data-testid="add-to-cart">Add to Cart</button>

Use in tests:

await page.locator('[data-testid="product-card"]').first().click();

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:

pnpm exec playwright test --ui

Playwright also generates a trace file on failure. Open it with:

pnpm exec playwright show-trace test-results/<run>/trace.zip

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.