- Published on
E2E Tests for a Next.js Blog with Playwright
- Authors

- Name
- Susanne Moog
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()
})
})