Imagine you have just finished building a beautiful web application using Next.js. It looks great, but how do you know it actually works for your users? Manual testing is tedious and prone to human error. This is where End-to-End (E2E) testing comes in, and Playwright is currently the gold standard for this task.
What is E2E Testing anyway?
Think of E2E testing as a "Robot Customer." Instead of testing small pieces of code (like unit testing), E2E testing simulates a real user's journey. It opens a browser (Chrome, Firefox, Safari), clicks buttons, fills out forms, and verifies that the app behaves exactly as intended from the user's perspective.
Playwright is a powerful tool by Microsoft that makes this incredibly fast and reliable. Let's see how it works in practice within the Ginbok CMS Next.js project.
The Problem: Fragile Tests
In the past, writing tests for our Next.js frontend involved tightly coupling the test code to specific CSS classes. For instance, testing a blog page might have looked like this:
// Fragile test example
const blogItems = page.locator('.blog-card-item'); // DANGER!
await expect(blogItems).toBeVisible();
What happens if we redesign the app and use a new theme (like our elegant Broadsheet theme)? The class changes to .article-item, and suddenly, all our tests fail, even though the feature still works perfectly!
The Solution: Theme-Agnostic Tests
To fix this in our Ginbok project, we rewrote our Playwright test specs (for homepage, blog listing, and search) using Role-based and Text-based Selectors. This makes our tests "theme-agnostic", meaning they test the semantic structure of the DOM, not the visual styles.
Example 1: A Resilient Blog Listing Test
Here is how we now verify the "Filter Tabs" and "Pagination" on our Broadsheet theme without relying on brittle CSS classes:
import { test, expect } from '@playwright/test';
test.describe('Blog Listing Page', () => {
test('should display filter tabs resiliently', async ({ page }) => {
await page.goto('/blog');
// ✅ GOOD: Using ARIA roles instead of CSS classes
const tablist = page.locator('[role="tablist"]');
await expect(tablist).toBeVisible();
const tabs = page.locator('[role="tab"]');
const count = await tabs.count();
expect(count).toBeGreaterThanOrEqual(2); // At least "All" + 1 category
});
test('should navigate via pagination', async ({ page }) => {
await page.goto('/blog');
// ✅ GOOD: Using accessibility labels
const pagination = page.getByLabel('Pagination');
// ✅ GOOD: Using visible text content
const page2Link = pagination.getByText('02');
await page2Link.click();
await page.waitForURL(/p=2/);
expect(page.url()).toContain('p=2');
});
});
Example 2: A Resilient Search Test
Search is a critical user journey. Here is how to test it without coupling to any CSS class or theme-specific markup:
import { test, expect } from '@playwright/test';
test.describe('Search Page', () => {
test('should return results for a valid query', async ({ page }) => {
await page.goto('/search?q=playwright');
// ✅ GOOD: Find the search input by its ARIA role + label
const searchInput = page.getByRole('searchbox', { name: /search/i });
await expect(searchInput).toHaveValue('playwright');
// ✅ GOOD: Assert results exist by semantic landmark, not CSS class
const resultsList = page.getByRole('list', { name: /search results/i });
await expect(resultsList).toBeVisible();
const items = resultsList.getByRole('listitem');
const count = await items.count();
expect(count).toBeGreaterThan(0);
});
test('should show empty state for no results', async ({ page }) => {
await page.goto('/search?q=xyznonexistentkeyword');
// ✅ GOOD: Use visible text to assert the empty state message
const emptyMessage = page.getByText(/no results found/i);
await expect(emptyMessage).toBeVisible();
});
});
Why this matters for your Next.js Apps
By writing tests this way, you gain several massive benefits:
- Confidence Across Themes: You can completely swap out your CSS (e.g., from Default to Broadsheet) and your tests will still pass because the underlying HTML structure (like
<article>,<h1>, orrole="tab") remains consistent. - Better Accessibility (a11y): By forcing yourself to write tests against ARIA roles and labels, you inherently build a more accessible application for screen readers.
- Zero Maintenance: You spend time building features, not fixing broken tests every time a designer decides to tweak a button class.
The New Development Workflow with Playwright E2E
Integrating Playwright changes not just how you test, but how your entire team ships features. Here is the before-and-after picture:
❌ Old workflow (without E2E):
Code → Manual click-through in browser → "Looks fine to me"
→ Deploy → Bug reported by user in production → Hotfix panic
✅ New workflow (with Playwright):
Code → npx playwright test (local, ~30s)
→ Tests catch regressions immediately
→ Open PR → CI runs full E2E suite on every push
→ Deploy only when green ✅
→ Ship with confidence
Step 1 — Decide whether to write a new spec or update an existing one
Not every feature change requires the same action on your test suite. Before touching any .spec.ts file, ask yourself these three questions:
1. Is there a new URL or user journey? → Create a new spec file
2. Did an existing flow change? → Update the existing spec
3. Only added unrelated UI (colors, widgets, sidebar)?
→ No spec changes needed
Some concrete examples to make this clear:
- ✅ Add
/newsletterpage → newnewsletter.spec.tsneeded - ✅ Search now requires pressing Enter → update
search.spec.tsto add the keypress step - ✅ Add a required captcha to a form → update the form submission spec to handle the new step
- ✅ Change URL from
/blog?p=2to/blog/page/2→ update allwaitForURLassertions - ⏭️ Add "Related Posts" widget to sidebar → existing specs unaffected, skip
- ⏭️ Refactor API layer or optimize DB queries → E2E tests only care about browser output, skip
- ⏭️ Swap theme colors or adjust spacing → role-based selectors don't care, skip
The rule of thumb: a feature is only considered done when its spec exists and passes — not after it's deployed. This is the "Definition of Done" mindset that prevents test debt from accumulating.
Step 2 — Run locally in UI mode during development
Use npx playwright test --ui to open the interactive Playwright UI. You can watch your Robot Customer navigate the app in real-time, step through each action, and see exactly where it fails. This replaces most of your manual browser click-throughs.
Step 3 — Gate every PR with CI
Add Playwright to your CI pipeline (GitHub Actions, Azure DevOps, etc.) so tests run automatically on every pull request. A failing test blocks the merge — no more "it worked on my machine" incidents reaching production.
# Example: Azure DevOps pipeline steps
- script: npm ci
displayName: 'Install dependencies'
- script: npx playwright install --with-deps
displayName: 'Install Playwright browsers'
- task: Npm@1
inputs:
command: 'custom'
customCommand: 'exec playwright test --reporter=junit'
displayName: 'Run Playwright E2E tests'
- task: PublishTestResults@2
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: 'test-results/*.xml'
displayName: 'Publish test results'
Step 4 — Use the HTML report to debug failures
When a test fails in CI, Playwright generates a detailed HTML report with screenshots, videos, and traces. Run npx playwright show-report locally to step through the exact moment the failure occurred — no guesswork needed.
The result? Your team ships faster, your users hit fewer bugs, and theme redesigns become a non-event instead of a testing nightmare.
Ready to try it out? Just run npx playwright test --ui to watch your "Robot Customer" seamlessly navigate your site in real-time!