Astro E2E Testing with Playwright

Astro E2E Testing with Playwright

End-to-end tests are where Astro's architecture really gets tested — navigation between pages, form submissions, SSR data loading, hydration of islands, and the subtle differences between your dev server and production build. Playwright is the right tool for this. It's fast, runs in real browsers, handles async hydration natively, and has first-class support for testing across different server modes.

This guide covers everything from initial setup to advanced patterns like testing SSR pages, production build validation, and visual regression.

Why Playwright for Astro?

Astro generates both static and server-rendered pages, often in the same project. Playwright handles both modes without configuration changes — you point it at your dev server or production preview, and it behaves identically. Key advantages:

  • Auto-waiting: Playwright waits for elements to be visible and stable before interacting, which handles Astro's island hydration gracefully
  • Multiple browser engines: Test in Chromium, Firefox, and WebKit simultaneously
  • Network interception: Mock API calls or test real endpoints
  • Trace viewer: Replay failed test recordings frame by frame
  • Built-in screenshot diffing: Visual regression without additional libraries

Initial Setup

Install Playwright in your Astro project:

npm init playwright@latest

This runs an interactive setup. Choose:

  • TypeScript
  • e2e as the test directory
  • Add a GitHub Actions workflow (optional but recommended)
  • Download browser binaries

The installer creates playwright.config.ts. Configure it for Astro:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: 'http://localhost:4321',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 13'] },
    },
  ],

  // Start Astro dev server before running tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:4321',
    reuseExistingServer: !process.env.CI,
    stdout: 'pipe',
    stderr: 'pipe',
  },
});

Testing SSR Pages

SSR pages render on every request — they can include user-specific data, query parameters, and dynamic content. Testing them requires verifying both the HTML structure and the data rendered.

Consider an SSR product page that fetches from an API:

---
// src/pages/products/[id].astro
export const prerender = false; // Enable SSR for this page

const { id } = Astro.params;
const response = await fetch(`https://api.example.com/products/${id}`);

if (!response.ok) {
  return Astro.redirect('/404');
}

const product = await response.json();
---

<html>
  <body>
    <main>
      <h1>{product.name}</h1>
      <p class="price">${product.price.toFixed(2)}</p>
      <p class="description">{product.description}</p>
      <button class="add-to-cart" data-product-id={product.id}>
        Add to Cart
      </button>
    </main>
  </body>
</html>

E2E test for this page:

// e2e/products.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Product pages (SSR)', () => {
  test('renders product details from API', async ({ page }) => {
    await page.goto('/products/123');

    await expect(page.locator('h1')).toBeVisible();
    await expect(page.locator('.price')).toContainText('$');
    await expect(page.locator('.add-to-cart')).toBeEnabled();
  });

  test('redirects to 404 for invalid product ID', async ({ page }) => {
    const response = await page.goto('/products/nonexistent-id-99999');
    expect(response?.status()).toBe(404);
  });

  test('includes correct product ID in add-to-cart button', async ({ page }) => {
    await page.goto('/products/123');

    const button = page.locator('.add-to-cart');
    const productId = await button.getAttribute('data-product-id');
    expect(productId).toBe('123');
  });

  test('renders different content for different product IDs', async ({ page }) => {
    await page.goto('/products/1');
    const firstTitle = await page.locator('h1').textContent();

    await page.goto('/products/2');
    const secondTitle = await page.locator('h1').textContent();

    expect(firstTitle).not.toBe(secondTitle);
  });
});

Testing Navigation

Navigation tests verify that routing works correctly — links go to the right pages, back/forward works, and any client-side transitions (if you're using View Transitions) complete properly.

// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Site navigation', () => {
  test('homepage links navigate to correct pages', async ({ page }) => {
    await page.goto('/');

    // Click the blog link and verify navigation
    await page.click('a[href="/blog"]');
    await expect(page).toHaveURL('/blog');
    await expect(page.locator('h1')).toContainText('Blog');
  });

  test('browser back button returns to previous page', async ({ page }) => {
    await page.goto('/');
    await page.click('a[href="/about"]');
    await expect(page).toHaveURL('/about');

    await page.goBack();
    await expect(page).toHaveURL('/');
  });

  test('navigation menu is accessible on mobile', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/');

    // Mobile menu trigger
    const menuButton = page.locator('[aria-label="Open menu"]');
    await expect(menuButton).toBeVisible();
    await menuButton.click();

    // Navigation links should be visible after opening menu
    await expect(page.locator('nav[aria-label="Main navigation"]')).toBeVisible();
  });

  test('View Transitions complete without visual glitch', async ({ page }) => {
    // Enable View Transitions testing
    await page.goto('/');

    // Listen for navigation to complete
    const navigationPromise = page.waitForURL('/blog');
    await page.click('a[href="/blog"]');
    await navigationPromise;

    // Verify content loaded after transition
    await expect(page.locator('article')).toHaveCount({ minimum: 1 });
  });

  test('404 page renders for unknown routes', async ({ page }) => {
    const response = await page.goto('/this-page-does-not-exist');
    expect(response?.status()).toBe(404);
    await expect(page.locator('h1')).toContainText('404');
  });
});

Testing Form Submissions

Form testing in Astro covers both client-side validation and server-side processing (for SSR projects using Astro's form actions or API routes).

// e2e/forms.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Contact form', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/contact');
  });

  test('submits form with valid data', async ({ page }) => {
    await page.fill('[name="name"]', 'Jane Smith');
    await page.fill('[name="email"]', 'jane@example.com');
    await page.fill('[name="message"]', 'This is a test message with enough content.');

    await page.click('[type="submit"]');

    // Wait for success message
    await expect(page.locator('.form-success')).toBeVisible();
    await expect(page.locator('.form-success')).toContainText('Thank you');
  });

  test('shows validation errors for empty submission', async ({ page }) => {
    await page.click('[type="submit"]');

    await expect(page.locator('[data-error="name"]')).toBeVisible();
    await expect(page.locator('[data-error="email"]')).toBeVisible();
    await expect(page.locator('[data-error="message"]')).toBeVisible();
  });

  test('shows email format validation error', async ({ page }) => {
    await page.fill('[name="email"]', 'not-an-email');
    await page.click('[type="submit"]');

    await expect(page.locator('[data-error="email"]')).toContainText('valid email');
  });

  test('form persists values after failed submission', async ({ page }) => {
    await page.fill('[name="name"]', 'Jane Smith');
    await page.fill('[name="email"]', 'not-valid-email');
    await page.click('[type="submit"]');

    // Name field should still have its value
    await expect(page.locator('[name="name"]')).toHaveValue('Jane Smith');
  });

  test('disables submit button during submission', async ({ page }) => {
    await page.fill('[name="name"]', 'Jane Smith');
    await page.fill('[name="email"]', 'jane@example.com');
    await page.fill('[name="message"]', 'A valid message for testing');

    // Intercept the network request to slow it down
    await page.route('/api/contact', async (route) => {
      await new Promise(resolve => setTimeout(resolve, 500));
      route.fulfill({ status: 200, body: JSON.stringify({ success: true }) });
    });

    await page.click('[type="submit"]');

    // Button should be disabled during submission
    await expect(page.locator('[type="submit"]')).toBeDisabled();
  });
});

Testing Dev vs Production Builds

Dev and production builds can behave differently in Astro. Dev runs without minification, with hot reload, and with more verbose error messages. Production builds are minified, optimized, and may have different asset paths. Testing both catches build-specific issues.

Create a separate Playwright config for production:

// playwright.prod.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  use: {
    baseURL: 'http://localhost:4322',
  },
  projects: [
    {
      name: 'chromium-prod',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
  webServer: {
    command: 'npm run build && npm run preview',
    url: 'http://localhost:4322',
    timeout: 120000, // Build takes time
    reuseExistingServer: false,
  },
});

Write tests that run against both configs:

// e2e/build-invariants.spec.ts
import { test, expect } from '@playwright/test';

// These tests should pass in both dev and production
test.describe('Build invariants', () => {
  test('CSS is loaded and applied', async ({ page }) => {
    await page.goto('/');

    const header = page.locator('header');
    const backgroundColor = await header.evaluate(
      el => getComputedStyle(el).backgroundColor
    );

    // Header should have a non-transparent background
    expect(backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
  });

  test('images load correctly', async ({ page }) => {
    await page.goto('/');

    const images = page.locator('img');
    const count = await images.count();

    for (let i = 0; i < count; i++) {
      const img = images.nth(i);
      const naturalWidth = await img.evaluate(
        (el: HTMLImageElement) => el.naturalWidth
      );
      expect(naturalWidth).toBeGreaterThan(0);
    }
  });

  test('no JavaScript errors on page load', async ({ page }) => {
    const errors: string[] = [];
    page.on('console', msg => {
      if (msg.type() === 'error') errors.push(msg.text());
    });

    await page.goto('/');
    await page.waitForLoadState('networkidle');

    expect(errors).toHaveLength(0);
  });

  test('HTML is valid (basic checks)', async ({ page }) => {
    await page.goto('/');

    // Check for duplicate IDs
    const duplicateIds = await page.evaluate(() => {
      const ids = Array.from(document.querySelectorAll('[id]')).map(el => el.id);
      const seen = new Set<string>();
      const duplicates: string[] = [];
      ids.forEach(id => {
        if (seen.has(id)) duplicates.push(id);
        seen.add(id);
      });
      return duplicates;
    });

    expect(duplicateIds).toHaveLength(0);
  });
});

Add package.json scripts:

{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:headed": "playwright test --headed"
  }
}

Visual Regression Testing

Playwright's built-in screenshot comparison catches unintended UI changes. This is particularly useful after Astro version updates, CSS framework upgrades, or design system changes.

// e2e/visual.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Visual regression', () => {
  test('homepage matches snapshot', async ({ page }) => {
    await page.goto('/');
    await page.waitForLoadState('networkidle');

    // Hide dynamic content that changes between runs
    await page.evaluate(() => {
      const dateElements = document.querySelectorAll('[data-dynamic="date"]');
      dateElements.forEach(el => (el as HTMLElement).style.visibility = 'hidden');
    });

    await expect(page).toHaveScreenshot('homepage.png', {
      fullPage: true,
      // Allow small pixel differences for antialiasing
      maxDiffPixelRatio: 0.02,
    });
  });

  test('blog post page matches snapshot', async ({ page }) => {
    await page.goto('/blog/my-first-post');
    await page.waitForLoadState('networkidle');

    await expect(page).toHaveScreenshot('blog-post.png', {
      fullPage: true,
    });
  });

  test('mobile homepage matches snapshot', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/');
    await page.waitForLoadState('networkidle');

    await expect(page).toHaveScreenshot('homepage-mobile.png', {
      fullPage: true,
    });
  });
});

Update snapshots intentionally with:

npx playwright test --update-snapshots

Testing with Network Mocking

For SSR pages that depend on external APIs, use Playwright's route interception to create deterministic tests:

// e2e/api-mocking.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Pages with external API dependencies', () => {
  test('renders product list from mocked API', async ({ page }) => {
    // Intercept the API call made by the SSR page
    await page.route('https://api.example.com/products*', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify([
          { id: 1, name: 'Widget Pro', price: 49.99 },
          { id: 2, name: 'Gadget Plus', price: 99.99 },
        ]),
      });
    });

    await page.goto('/products');

    await expect(page.locator('.product-card')).toHaveCount(2);
    await expect(page.getByText('Widget Pro')).toBeVisible();
    await expect(page.getByText('$49.99')).toBeVisible();
  });

  test('shows error state when API fails', async ({ page }) => {
    await page.route('https://api.example.com/products*', route => {
      route.fulfill({ status: 500 });
    });

    await page.goto('/products');

    await expect(page.locator('.error-message')).toBeVisible();
    await expect(page.locator('.error-message')).toContainText('Unable to load products');
  });
});

CI Integration

# .github/workflows/e2e.yml
name: E2E Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci
      - run: npx playwright install --with-deps

      - name: Run E2E tests (dev build)
        run: npm run test:e2e

      - name: Run E2E tests (production build)
        run: npm run test:e2e:prod

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

Practical Tips

Locator strategy: Prefer page.getByRole(), page.getByLabel(), and page.getByText() over CSS selectors. They're more resilient to markup changes and test behavior rather than implementation.

Avoid waitForTimeout: Use await expect(locator).toBeVisible() or waitForLoadState('networkidle') instead of arbitrary sleep delays.

Test isolation: Each test should work independently. Use test.beforeEach to navigate to the right page rather than relying on test order.

Flaky test detection: Run with --repeat-each=5 to detect timing-sensitive tests before they land in CI.

npx playwright test --repeat-each=5 --grep="@flaky-check"

With this setup, your Astro project has E2E coverage across SSR pages, navigation flows, form submissions, and visual appearance — all validated in real browsers.

Read more

Start now free