Comprehensive E2E Testing for Static Sites
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.