Published on

E2E Tests for a Next.js Blog with Playwright

Authors

I recently added Playwright e2e tests to this blog. Here is the setup and a few things that caught me off guard.

1) Install

npm install -D @playwright/test
npx playwright install chromium

2) Configure

Create playwright.config.ts in the project root:

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', { open: 'never' }]],
  use: {
    baseURL: 'http://localhost:3778',
    trace: 'on-first-retry',
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
  webServer: {
    command: 'npx contentlayer2 build && npx cross-env INIT_CWD=$PWD next dev --port 3778',
    url: 'http://localhost:3778',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
})

The webServer block starts and stops the dev server automatically. reuseExistingServer means local runs reuse a running dev server if one is already up.

Add to package.json:

"test:e2e": "playwright test"

Add to .gitignore:

/playwright-report
/test-results

3) Write tests

Tests live in e2e/*.spec.ts:

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

test('homepage renders', async ({ page }) => {
  await page.goto('/')
  await expect(page).toHaveTitle(/susi\.dev/)
  await expect(page.locator('header')).toBeVisible()
})

Run with:

npm run test:e2e

Tests through the accessibility tree

Playwright's locators — getByRole, getByLabel, getByText — query the accessibility tree, not the DOM. That is what screen readers see: semantic roles, accessible names, heading structure.

This means your tests are also accessibility checks. getByRole('button', { name: 'Search' }) passes only if that button exists and has the correct accessible name. A missing label or an unlabelled icon button will fail the test — no separate audit tool needed.

When a locator fails, Playwright prints the ARIA tree for the relevant part of the page so you can see exactly what it found.

Surprises

getByText() matches SVG <title> elements

SVG titles are part of the accessibility tree — screen readers use them as the accessible name for graphics. getByText('susi.dev') matched both the visible site name and the <title> inside the logo SVG, triggering a strict mode violation. Scoping to header does not help.

// strict mode violation: matches SVG title + visible text
await expect(header.getByText('susi.dev')).toBeVisible()

// works
await expect(header.getByRole('link', { name: 'susi.dev' })).toBeVisible()

CSS-hidden elements are invisible to Playwright

Elements hidden with display: none or visibility: hidden are excluded from the accessibility tree. A Tailwind sm:hidden heading is not visible to Playwright at desktop viewport even though it is in the DOM — on tag pages here the h1 only shows on mobile:

// fails — h1 is sm:hidden at desktop viewport
await expect(page.locator('h1')).toBeVisible()

// works
await expect(page.locator('h3').filter({ hasText: /^typo3/i })).toBeVisible()

Scroll-triggered elements need a scroll first

A scroll-to-top button that checks window.scrollY > 50 will never be visible at the start of a test. I test the always-visible alternative instead:

// times out — only renders after scrollY > 50
await expect(page.locator('button[aria-label="Scroll To Top"]')).toBeVisible()

// works
await expect(page.getByRole('link', { name: 'Back to the blog' })).toBeVisible()

I don't use a global mobile project

A mobile-chrome project in playwright.config.ts runs every test at mobile viewport, including tests that assert on elements hidden at mobile widths. I test mobile selectively with test.use({ viewport }) inside a dedicated describe block instead:

test.describe('Mobile navigation', () => {
  test.use({ viewport: { width: 390, height: 844 } })

  test('hamburger menu opens', async ({ page }) => {
    await page.goto('/')
    await page.getByRole('button', { name: 'Toggle Menu' }).click()
    const dialog = page.getByRole('dialog')
    await expect(dialog.getByRole('link', { name: 'Blog' })).toBeVisible()
  })
})