Files
ivangdavila_playwright/testing.md

4.4 KiB

Testing Patterns

What to Test First

Prioritize critical user journeys, auth boundaries, payments, file upload or download flows, and state transitions that are expensive to break.

Do not spend E2E budget on trivial presentational details that cheaper unit or component tests can cover.

Prefer assertions on what the user can observe: visible state, text, URL, enabled or disabled controls, downloads, navigation, and persisted app state.

Test Structure

import { test, expect } from '@playwright/test';

test.describe('Checkout Flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/products');
  });

  test('completes purchase with valid card', async ({ page }) => {
    await page.getByTestId('product-card')
      .filter({ hasText: 'Product A' })
      .click();
    await page.getByRole('button', { name: 'Add to Cart' }).click();
    await page.getByRole('link', { name: 'Checkout' }).click();
    await expect(page.getByRole('heading', { name: 'Order Summary' })).toBeVisible();
  });
});

Page Object Model

Use page objects when a flow is reused across many tests. Do not build a giant abstraction layer before duplication is real.

export class CheckoutPage {
  constructor(private page: Page) {}

  readonly cartItems = this.page.getByTestId('cart-item');
  readonly checkoutButton = this.page.getByRole('button', { name: 'Checkout' });
  readonly totalPrice = this.page.getByTestId('total-price');

  async removeItem(name: string) {
    await this.cartItems
      .filter({ hasText: name })
      .getByRole('button', { name: 'Remove' })
      .click();
  }

  async expectTotal(amount: string) {
    await expect(this.totalPrice).toHaveText(amount);
  }
}

Fixtures

import { test as base } from '@playwright/test';
import { CheckoutPage } from './pages/checkout.page';

type Fixtures = {
  checkoutPage: CheckoutPage;
};

export const test = base.extend<Fixtures>({
  checkoutPage: async ({ page }, use) => {
    await page.goto('/checkout');
    await use(new CheckoutPage(page));
  },
});

Isolation Rules

  • Keep tests independent so they can run alone, in parallel, or after retries without hidden dependencies.
  • If tests mutate shared backend state, use dedicated accounts, seeded data, or per-worker isolation instead of reusing one mutable user everywhere.
  • When auth is shared, prefer the Playwright setup-project pattern or one account per worker over ad hoc state reuse.

Mock What You Do Not Need to Re-Test

test('shows error on API failure', async ({ page }) => {
  await page.route('**/api/checkout', route => {
    route.fulfill({
      status: 500,
      body: JSON.stringify({ error: 'Payment failed' }),
    });
  });

  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Pay' }).click();
  await expect(page.getByText('Payment failed')).toBeVisible();
});

Avoid testing third-party widgets, analytics, payment processors, or upstream APIs end to end unless the point of the test is that exact integration.

Visual Regression

Use visual assertions for layout or rendering regressions that humans would otherwise miss. Keep viewport, fonts, and animations deterministic.

test('matches snapshot', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page).toHaveScreenshot('dashboard.png', {
    maxDiffPixels: 100,
  });
});

Parallelization

export default defineConfig({
  workers: process.env.CI ? 4 : undefined,
  fullyParallel: true,
});

test.describe.configure({ mode: 'parallel' });
test.describe.configure({ mode: 'serial' });

Authentication State

Persist auth only when the suite already standardizes that pattern and the stored state is safe to reuse.

const authFile = 'playwright/.auth/user.json';
// Reuse a saved auth file only in suites that intentionally standardize it.

For one-off debugging, privileged accounts, or stateful flows that mutate backend data, prefer logging in inside the test or using isolated worker accounts instead of carrying one shared session everywhere.

Assertions

await expect(locator).toBeVisible();
await expect(locator).toHaveText('Expected');
await expect(locator).toBeEnabled();
await expect(locator).toHaveAttribute('href', '/path');
await expect(page).toHaveURL(/dashboard/);

await expect.poll(async () => {
  return await page.evaluate(() => window.dataLoaded);
}).toBe(true);