🔥 0
0
Lesson 4 of 10 25 min +200 XP

Auto-Wait & Web-First Assertions

This is Playwright's superpower. Understanding auto-wait is the difference between tests that work sometimes and tests that work always.

The Problem: Race Conditions

E-commerce sites are asynchronous. When you click "Add to Cart":

  • JavaScript sends an API request
  • Server processes the request
  • Response comes back
  • UI updates the cart count

Your test runs much faster than this. Without proper waiting:

// Dangerous: Race condition
await page.click('.add-to-cart');
const count = await page.textContent('.cart-count');
expect(count).toBe('1');  // Might still be '0'!

How Auto-Wait Works

When you call await page.click(selector), Playwright doesn't immediately click. It waits for actionability checks:

Playwright Actionability Checks

Only after ALL checks pass does Playwright perform the action.

Actionability by Operation

ActionChecks Required
click()Attached, Visible, Stable, Enabled, Receivable
fill()Attached, Visible, Enabled, Editable
check()Attached, Visible, Stable, Enabled
hover()Attached, Visible, Stable
focus()Attached
selectOption()Attached, Visible, Enabled

E-Commerce Auto-Wait Examples

Add to Cart Flow

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

test('add product to cart', async ({ page }) => {
  await page.goto('/products/wireless-headphones');

  // Playwright waits for button to be:
  // - Present in DOM (after page loads)
  // - Visible (not hidden behind modal)
  // - Stable (not animating in)
  // - Enabled (not disabled during loading)
  // - Receivable (not blocked by overlay)
  await page.getByRole('button', { name: 'Add to Cart' }).click();

  // No explicit wait needed - auto-wait handles it
});

Form Filling

test('checkout form', async ({ page }) => {
  await page.goto('/checkout');

  // fill() waits for the input to be visible and editable
  await page.getByLabel('Card Number').fill('4111111111111111');

  // If there's a loading spinner on the form,
  // Playwright waits for input to become enabled
  await page.getByLabel('Expiration').fill('12/25');
  await page.getByLabel('CVV').fill('123');

  // Button might be disabled until form is valid
  // click() waits for it to become enabled
  await page.getByRole('button', { name: 'Pay Now' }).click();
});

Dropdown Selection

test('select shipping method', async ({ page }) => {
  await page.goto('/cart');

  // selectOption() waits for dropdown to be interactive
  await page.getByRole('combobox', { name: 'Shipping Method' }).selectOption('express');

  // The price might update asynchronously
  // Use web-first assertion (covered below)
  await expect(page.getByTestId('shipping-cost')).toHaveText('$15.99');
});

Web-First Assertions

Regular assertions check immediately and fail if the condition isn't met:

// Bad: Checks once, fails immediately
expect(await page.textContent('.cart-total')).toBe('$129.99');

Web-first assertions auto-retry until timeout:

// Good: Retries until condition is met or timeout
await expect(page.locator('.cart-total')).toHaveText('$129.99');

Available Web-First Assertions

// Visibility
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();

// Text content
await expect(locator).toHaveText('Expected text');
await expect(locator).toContainText('partial');
await expect(locator).toHaveText(/regex pattern/);

// Form state
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toHaveValue('input value');

// Element state
await expect(locator).toBeAttached();
await expect(locator).toHaveAttribute('href', '/cart');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveCount(5);

// Page-level
await expect(page).toHaveTitle('Shopping Cart');
await expect(page).toHaveURL(/\/checkout/);

E-Commerce Assertion Patterns

test('complete purchase flow', async ({ page }) => {
  await page.goto('/products');

  // Add product
  await page.getByRole('button', { name: 'Add to Cart' }).first().click();

  // Assert cart updated (retries until cart count shows 1)
  await expect(page.getByTestId('cart-count')).toHaveText('1');

  // Go to cart
  await page.getByRole('link', { name: 'Cart' }).click();

  // Assert navigation (retries until URL matches)
  await expect(page).toHaveURL(/\/cart/);

  // Assert product in cart (retries until visible)
  await expect(page.getByRole('heading', { name: 'Your Cart' })).toBeVisible();
  await expect(page.getByTestId('cart-item')).toHaveCount(1);

  // Proceed to checkout
  await page.getByRole('button', { name: 'Checkout' }).click();

  // Fill and submit
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByRole('button', { name: 'Continue' }).click();

  // Assert success (retries until confirmation appears)
  await expect(page.getByText('Order Confirmed')).toBeVisible();
});

Soft Assertions

Continue test execution even if assertion fails:

test('product page has required elements', async ({ page }) => {
  await page.goto('/products/laptop');

  // Soft assertions - test continues even if these fail
  await expect.soft(page.getByRole('img', { name: /laptop/i })).toBeVisible();
  await expect.soft(page.getByTestId('product-price')).toBeVisible();
  await expect.soft(page.getByTestId('product-rating')).toBeVisible();
  await expect.soft(page.getByRole('button', { name: 'Add to Cart' })).toBeEnabled();

  // All failures reported at end of test
});

Custom Timeouts

Per-Assertion Timeout

// Wait up to 30 seconds for slow operations
await expect(page.getByText('Payment processed')).toBeVisible({
  timeout: 30000,
});

// Quick check (fail fast if not immediate)
await expect(page.getByTestId('error-message')).not.toBeVisible({
  timeout: 1000,
});

Per-Action Timeout

// Slow-loading image
await page.getByRole('img', { name: 'Product' }).click({
  timeout: 10000,
});

Global Timeout Configuration

// playwright.config.ts
export default defineConfig({
  use: {
    actionTimeout: 15000,  // Default for actions
  },
  expect: {
    timeout: 10000,  // Default for assertions
  },
});

Anti-Patterns to Avoid

Anti-Pattern 1: Manual Sleep

// BAD: Never do this
await page.click('.add-to-cart');
await page.waitForTimeout(2000);  // Arbitrary wait
expect(await page.textContent('.cart-count')).toBe('1');

// GOOD: Web-first assertion
await page.click('.add-to-cart');
await expect(page.locator('.cart-count')).toHaveText('1');

Why it's bad:

  • If operation takes 500ms, you waste 1500ms
  • If operation takes 2500ms, test fails
  • Inconsistent timing across environments

Anti-Pattern 2: waitForSelector Before Action

// UNNECESSARY: Playwright already waits
await page.waitForSelector('.add-to-cart');  // Redundant
await page.click('.add-to-cart');

// CORRECT: Just click - auto-wait handles it
await page.click('.add-to-cart');

Anti-Pattern 3: Manual Polling

// BAD: Manual retry loop
let cartCount = '0';
for (let i = 0; i < 10; i++) {
  cartCount = await page.textContent('.cart-count');
  if (cartCount === '1') break;
  await page.waitForTimeout(500);
}
expect(cartCount).toBe('1');

// GOOD: Web-first assertion does this automatically
await expect(page.locator('.cart-count')).toHaveText('1');

Anti-Pattern 4: Immediate Text Check

// BAD: Single check, no retry
const total = await page.locator('.cart-total').textContent();
expect(total).toBe('$129.99');  // Fails if DOM not updated yet

// GOOD: Auto-retry assertion
await expect(page.locator('.cart-total')).toHaveText('$129.99');

When You DO Need Explicit Waits

Sometimes auto-wait isn't enough:

Waiting for Network

// Wait for API response before asserting
await page.getByRole('button', { name: 'Load More' }).click();

// Wait for the products API call to complete
await page.waitForResponse(response =>
  response.url().includes('/api/products') &&
  response.status() === 200
);

await expect(page.getByTestId('product-card')).toHaveCount(20);

Waiting for Navigation

// Wait for navigation after form submit
await Promise.all([
  page.waitForNavigation(),
  page.getByRole('button', { name: 'Submit Order' }).click(),
]);

// Or use the recommended approach
await page.getByRole('button', { name: 'Submit Order' }).click();
await page.waitForURL('**/order-confirmation');

Waiting for Load State

// Wait for page to fully load
await page.goto('/products');
await page.waitForLoadState('networkidle');

// Useful for SPAs with many async resources

Waiting for Element State

// Wait for loading spinner to disappear
await page.locator('.loading-spinner').waitFor({ state: 'hidden' });

// Wait for element to be attached
await page.locator('.dynamic-content').waitFor({ state: 'attached' });

Real-World E-Commerce Patterns

Price Update After Quantity Change

test('cart total updates on quantity change', async ({ page }) => {
  await page.goto('/cart');

  // Change quantity
  await page.getByRole('spinbutton', { name: 'Quantity' }).fill('3');

  // Price updates asynchronously - web-first assertion handles it
  await expect(page.getByTestId('item-subtotal')).toHaveText('$89.97');
  await expect(page.getByTestId('cart-total')).toHaveText('$89.97');
});

Applying Promo Code

test('promo code applies discount', async ({ page }) => {
  await page.goto('/cart');

  await page.getByPlaceholder('Promo code').fill('SAVE20');
  await page.getByRole('button', { name: 'Apply' }).click();

  // Wait for discount calculation
  await expect(page.getByText('Discount applied')).toBeVisible();
  await expect(page.getByTestId('discount-amount')).toHaveText('-$25.99');
});

Inventory Check

test('shows out of stock message', async ({ page }) => {
  await page.goto('/products/limited-item');

  // Product page loads, check inventory status
  await expect(page.getByText('Out of Stock')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Add to Cart' })).toBeDisabled();
});

Key Takeaways

  • Auto-wait is automatic: Every action waits for actionability
  • Web-first assertions retry: Use expect(locator) not expect(await value)
  • Avoid manual waits: waitForTimeout is almost always wrong
  • Timeouts are configurable: Adjust per-assertion or globally
  • Use explicit waits sparingly: Network conditions, navigation, load states
  • E-commerce is async: Cart updates, price calculations, inventory checks all benefit from auto-wait

Next Lesson

Your tests work reliably now. But as your test suite grows, you'll have duplicate code everywhere. In the next lesson, we'll implement the Page Object Model to organize tests for maintainability.

Locators That Don't Break