Andy Simon's Blog

asimon@blog:~/2025-01-03-playwright-e2e-testing/$ _

Comprehensive E2E Testing for Static Sites

by asimon
testingplaywrightci-cdtypescript

Building a robust Playwright test suite with page objects, external call blocking, and production monitoring


title: "Comprehensive E2E Testing for Static Sites" summary: "Building a robust Playwright test suite with page objects, external call blocking, and production monitoring" date: "2025-01-03" tags: ["testing", "playwright", "ci-cd", "typescript"] topics: ["developer-experience", "ci-cd", "typescript"] prerequisites: ["2025-01-01-github-actions-cicd"] related: ["2025-01-02-url-state-management", "2025-12-28-architecture-of-a-modern-static-blog"] author: "asimon" published: true

Comprehensive E2E Testing for Static Sites

End-to-end tests catch what unit tests miss: broken links, missing content, layout regressions, and integration failures. For a static site, they're especially valuable because there's no backend to test - the deployed HTML is the product.

This post covers the Playwright test architecture that validates every deployment.

Test Categories

Tests are organized by purpose, not by feature:

tests/e2e/
ā”œā”€ā”€ helpers/
│   └── page-objects.ts      # Shared page abstractions
ā”œā”€ā”€ routing.spec.ts          # @smoke - navigation, URLs, redirects
ā”œā”€ā”€ content.spec.ts          # @smoke - post rendering, MDX components
ā”œā”€ā”€ atom.spec.ts             # @smoke - RSS feed validation
ā”œā”€ā”€ terminal.spec.ts         # @smoke - interactive shell
ā”œā”€ā”€ sandbox.spec.ts          # @smoke - tools with URL state
ā”œā”€ā”€ infrastructure.spec.ts   # @infra - security headers, CDN
└── production-smoke.spec.ts # @prod - live site monitoring

Each category runs in different contexts:

| Tag | When | Purpose | |-----|------|---------| | @smoke | Every PR | Fast validation of core functionality | | @infra | Main branch | Security and infrastructure checks | | @prod | Scheduled + post-deploy | Live site health monitoring |

Page Object Pattern

Tests become brittle when they're full of selectors. Page objects abstract the DOM details:

export class HomePage {
  readonly page: Page;
  readonly postsList: Locator;
  readonly aboutLink: Locator;

  constructor(page: Page) {
    this.page = page;
    this.postsList = page.locator('main').first();
    this.aboutLink = page.getByRole('link', { name: 'About' });
  }

  async goto() {
    await this.page.goto('/');
  }

  async getPostSlugs(): Promise<string[]> {
    const links = await this.page.$$eval('a[href^="/"]', (anchors) =>
      anchors
        .map((a) => new URL(a.href).pathname.replace(/^\//, ''))
        .filter((path) => path && !path.includes('_next'))
    );
    return links;
  }
}

Tests use the abstraction instead of raw selectors:

test('homepage shows post list', async ({ page }) => {
  const home = new HomePage(page);
  await home.goto();

  const slugs = await home.getPostSlugs();
  expect(slugs.length).toBeGreaterThan(0);
});

When the DOM structure changes, you fix the page object once rather than every test.

External Call Blocking

Analytics and third-party scripts cause flaky tests. Block them:

export class TestUtils {
  static async blockExternalCalls(page: Page): Promise<void> {
    await page.route('**/*', (route) => {
      const url = route.request().url();
      const blockedDomains = [
        'google-analytics.com',
        'googletagmanager.com',
        'fonts.googleapis.com',
        'cdn.segment.com',
      ];

      if (blockedDomains.some((domain) => url.includes(domain))) {
        return route.abort();
      }
      return route.continue();
    });
  }
}

Call it in test setup:

test.beforeEach(async ({ page }) => {
  await TestUtils.blockExternalCalls(page);
});

This ensures:

  • Deterministic tests - No network variance
  • Faster execution - No waiting for third-party CDNs
  • Privacy in CI - No analytics tracking test runs

Testing Against Static Export

For local testing, serve the static build:

# Build the site
pnpm build

# Serve the static files
http-server ./out -p 4173

# Run tests against it
E2E_BASE_URL=http://localhost:4173 playwright test

The test config supports multiple environments:

// playwright.config.ts
const baseURL = process.env.E2E_BASE_URL || 'http://localhost:4173';

export default defineConfig({
  use: { baseURL },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'production-smoke',
      use: {
        ...devices['Desktop Chrome'],
        baseURL: 'https://asimon.blog',
      },
      testMatch: '**/production-smoke.spec.ts',
    },
  ],
});
šŸ’”

Parallel Execution

Set workers: 4 to run tests in parallel. Static files serve instantly, so parallel tests don't bottleneck on the server.

Content Validation Tests

For a blog, content is critical. Test it:

test('post pages render complete content', async ({ page }) => {
  const home = new HomePage(page);
  await home.goto();

  const slugs = await home.getPostSlugs();

  for (const slug of slugs.slice(0, 3)) { // Test first 3 posts
    const post = new PostPage(page);
    await post.goto(slug);

    // Title renders
    const title = await post.getTitle();
    expect(title).toBeTruthy();

    // Content section exists
    await expect(post.article).toBeVisible();

    // No broken images
    const images = await page.$$('img');
    for (const img of images) {
      const src = await img.getAttribute('src');
      if (src && !src.startsWith('data:')) {
        const naturalWidth = await img.evaluate((el) =>
          (el as HTMLImageElement).naturalWidth
        );
        expect(naturalWidth).toBeGreaterThan(0);
      }
    }
  }
});

MDX Component Testing

Custom MDX components need visual validation:

test('MDX content renders correctly with syntax highlighting', async ({ page }) => {
  await page.goto('/some-post-with-code');

  // Code blocks have syntax highlighting
  const codeBlocks = page.locator('pre code');
  const count = await codeBlocks.count();
  expect(count).toBeGreaterThan(0);

  // Highlighting classes applied
  const firstBlock = codeBlocks.first();
  const hasHighlighting = await firstBlock.evaluate((el) =>
    el.querySelector('.token, .hljs-keyword, [class*="syntax"]') !== null
  );
  expect(hasHighlighting).toBe(true);

  // Callout components render
  const callouts = page.locator('[class*="callout"], [class*="Callout"]');
  if (await callouts.count() > 0) {
    await expect(callouts.first()).toBeVisible();
  }
});

URL State Testing

For interactive tools, test the full state round-trip:

test('ROI calculator preserves state in URL', async ({ page }) => {
  await page.goto('/sandbox/roi');

  // Set some values
  await page.fill('input[name="buildInitCost"]', '200000');
  await page.fill('input[name="teamSize"]', '5');

  // Wait for debounced URL update
  await page.waitForTimeout(500);

  // Check URL contains state
  const url = new URL(page.url());
  expect(url.searchParams.get('buildInitCost')).toBe('200000');

  // Reload and verify persistence
  await page.reload();
  const buildCost = await page.inputValue('input[name="buildInitCost"]');
  expect(buildCost).toBe('200000');
});

Terminal Shell Testing

The interactive terminal requires special handling:

test('terminal commands work', async ({ page }) => {
  await page.goto('/');

  // Open terminal with keyboard
  await page.keyboard.press('`');
  await expect(page.locator('[role="dialog"]')).toBeVisible();

  // Type a command
  const input = page.locator('input[type="text"]');
  await input.fill('help');
  await input.press('Enter');

  // Verify output
  await expect(page.getByText('Available commands')).toBeVisible();

  // Navigate with command
  await input.fill('open 2025-12-30-building-an-interactive-terminal-shell');
  await input.press('Enter');

  // Terminal closes and navigates
  await expect(page).toHaveURL(/2025-12-30/);
});

test('tab completion works', async ({ page }) => {
  await page.goto('/');
  await page.keyboard.press('`');

  const input = page.locator('input[type="text"]');
  await input.fill('hel');
  await page.keyboard.press('Tab');

  // Should complete to 'help'
  await expect(input).toHaveValue('help');
});

Production Smoke Tests

Post-deployment tests run against the live site:

test('production CDN and security headers', async ({ request }) => {
  const response = await request.get('https://asimon.blog');

  expect(response.status()).toBe(200);

  const headers = response.headers();

  // Security headers present
  expect(headers['strict-transport-security']).toBeTruthy();
  expect(headers['x-content-type-options']).toBe('nosniff');

  // CloudFront serving
  const cloudfrontId = headers['x-amz-cf-id'];
  expect(cloudfrontId).toBeTruthy();
});

test('www redirect works', async ({ page }) => {
  await page.goto('https://www.asimon.blog');

  // Should redirect to non-www
  await expect(page).toHaveURL('https://asimon.blog/');
});

Production tests use more retries to handle CDN edge variance:

{
  name: 'production-smoke',
  retries: 3,
  timeout: 15000,
}

Responsive Testing

Test mobile layouts:

test.describe('mobile viewport', () => {
  test.use({ viewport: { width: 375, height: 667 } });

  test('terminal toggle is hidden on mobile', async ({ page }) => {
    await page.goto('/');

    const terminalToggle = page.locator('[data-testid="terminal-toggle"]');
    await expect(terminalToggle).not.toBeVisible();
  });

  test('navigation adapts to mobile', async ({ page }) => {
    await page.goto('/');

    // Mobile menu or adjusted layout
    const header = page.locator('header');
    await expect(header).toBeVisible();
  });
});

Browser Caching in CI

Playwright browsers are large. Cache them:

- name: Cache Playwright browsers
  uses: actions/cache@v3
  with:
    path: ~/.cache/ms-playwright
    key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}

- name: Install Playwright
  run: npx playwright install --with-deps chromium

With caching:

  • First run: ~2 minutes to download
  • Subsequent runs: ~5 seconds to restore

Test Execution Strategy

// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? undefined : 4,
  reporter: [
    ['list'],
    process.env.CI && ['html', { outputFolder: 'playwright-report' }],
    process.env.CI && ['github'],
  ].filter(Boolean),
});

Local development:

  • No retries (fast feedback)
  • 4 parallel workers
  • Simple list output

CI environment:

  • 2 retries (handle flakiness)
  • Auto workers (based on CPU)
  • HTML report + GitHub annotations

Debugging Failed Tests

When tests fail:

# Visual debugging
npx playwright test --headed --debug

# Run single test
npx playwright test -g "homepage shows post list"

# Show trace on failure
npx playwright test --trace on

# View report
npx playwright show-report

The trace viewer shows:

  • Screenshots at each step
  • Network requests
  • Console logs
  • DOM snapshots

Test File Organization

Each test file focuses on a logical area:

// routing.spec.ts
test.describe('Routing and URL handling @smoke', () => {
  test('homepage loads successfully', async ({ page }) => { /* ... */ });
  test('all post routes return 200', async ({ page }) => { /* ... */ });
  test('404 page works for unknown routes', async ({ page }) => { /* ... */ });
  test('clean URL rewrites work', async ({ page }) => { /* ... */ });
});

// content.spec.ts
test.describe('Content rendering @smoke', () => {
  test('homepage displays post list', async ({ page }) => { /* ... */ });
  test('post pages render complete content', async ({ page }) => { /* ... */ });
  test('MDX components render correctly', async ({ page }) => { /* ... */ });
});

The @smoke, @infra, and @prod tags in describe blocks allow selective execution:

npx playwright test --grep "@smoke"
npx playwright test --grep "@prod"

Summary

A comprehensive E2E suite for static sites:

  • Page objects for maintainable selectors
  • External blocking for deterministic tests
  • Category tags for selective execution
  • Production smoke tests for deployment validation
  • Browser caching for fast CI runs

The investment pays off: catch regressions before users do, validate every deployment automatically, and sleep better knowing the site works.