Playwright
Project layout
Keep tests under e2e/, grouped per app or feature:
e2e/
<app>/
a11y.spec.ts
navigation.spec.ts
seo.spec.ts
i18n.spec.ts
theme.spec.ts
forms.spec.ts
Configuration
Run each app as its own Playwright project against the static build:
// playwright.config.ts
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
projects: [
{
name: 'site',
use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:5173' },
testMatch: 'site/**/*.spec.ts',
},
],
webServer: [
{ command: 'npx serve dist/client -l 5173', port: 5173, reuseExistingServer: false },
],
});
Tests require a build first. Web servers serve the static dist/client/ output — server-side features (form actions, sessions) are not exercisable from E2E against the static build.
Commands
pnpm test:e2e # everything
pnpm test:e2e --project=site # single project
playwright test -g "SEO" # filter by name
playwright test --ui # interactive runner
Test patterns
Navigation
import { expect, test } from '@playwright/test';
test.describe('Navigation', () => {
test('homepage renders', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/.+/);
await expect(page.locator('h1')).toBeVisible();
});
test('contact link works', async ({ page }) => {
await page.goto('/');
await page.click('a[href="/contact"]');
await expect(page).toHaveURL('/contact');
});
});
SEO
test('homepage has OG meta tags', async ({ page }) => {
await page.goto('/');
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute('content', /./);
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute('content', /\.png$/);
});
test('robots.txt contains Sitemap', async ({ page }) => {
const res = await page.goto('/robots.txt');
expect(res?.status()).toBe(200);
expect(await res?.text()).toContain('Sitemap:');
});
test('sitemap is valid XML', async ({ page }) => {
const res = await page.goto('/sitemap.xml');
expect(res?.status()).toBe(200);
expect(await res?.text()).toContain('<urlset');
});
test('JSON-LD present', async ({ page }) => {
await page.goto('/');
const jsonLd = page.locator('script[type="application/ld+json"]').first();
await expect(jsonLd).toBeAttached();
const parsed = JSON.parse((await jsonLd.textContent())!);
expect(parsed['@context']).toBe('https://schema.org');
});
Accessibility (axe-core)
import AxeBuilder from '@axe-core/playwright';
test('homepage has no a11y violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});
i18n
test('german homepage has lang="de"', async ({ page }) => {
await page.goto('/de');
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
});
test('language switcher navigates', async ({ page }) => {
await page.goto('/');
await page.click('a:has-text("DE")');
await expect(page).toHaveURL('/de');
});
Forms
test('contact form renders all fields', async ({ page }) => {
await page.goto('/contact');
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(page.locator('input[name="email"]')).toBeVisible();
await expect(page.locator('textarea[name="message"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
});
Theme toggle
test('theme toggle flips dark class', async ({ page }) => {
await page.goto('/');
const html = page.locator('html');
const toggle = page.locator('button[aria-label*="dark" i], button[aria-label*="light" i]');
await toggle.click();
await expect(html).toHaveClass(/dark/);
});
Writing new tests
- Use descriptive
test.describegroups. - Test both locales when the feature is i18n-aware.
- For SEO tests, verify OG tags, canonical URLs, meta descriptions.
- For a11y tests, use
AxeBuilderand expect zero violations. - Build before running — E2E serves
dist/client/, not the dev server.
Common mistakes
- Forgetting to build first — tests run against the static output.
page.waitForNavigation()— preferawait expect(page).toHaveURL(...).- Hardcoding ports — use relative URLs;
baseURLis set per project. - Testing server-only logic — E2E cannot exercise SSR-only endpoints when running against a static build.