Playwright E2E Testing: Page Objects and Best Practices
Unit tests verify that individual functions behave correctly. Integration tests verify that services work together. But neither catches what breaks when a user actually clicks through your application — broken navigation flows, JavaScript hydration errors, network-dependent rendering failures, and cross-browser rendering bugs. End-to-end tests with Playwright fill this gap, and they are far more reliable than their Cypress or Selenium predecessors.
Playwright runs real Chromium, Firefox, and WebKit browsers in parallel. It auto-waits for elements to be visible and stable before interacting. It captures screenshots, videos, and network traces on failure. And its TypeScript-first API makes writing maintainable tests a pleasure. This guide covers everything from configuration to page objects to CI integration for Next.js applications.
Key Takeaways
- Use
data-testidselectors exclusively — CSS selectors and text selectors break on UI changes- Page Object Model (POM) abstracts selectors into reusable classes — one change location per UI element
- Always use
await page.goto()+await expect(page).toHaveURL()— never assume navigation completes synchronously- Use
test.describe.parallel()and--workersflag for faster CI runs- Record tests with
playwright codegenbut always refactor to page objects before committing- Capture screenshots on failure via
screenshot: 'only-on-failure'in config- Test accessibility alongside behavior with
@axe-core/playwright- Mock API responses for flaky third-party calls; use real API for critical happy paths
Configuration
// apps/web/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'playwright-report/results.json' }],
...(process.env.CI ? [['github'] as ['github']] : []),
],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 7'] },
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 14'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
Test Directory Structure
tests/
public/ # Tests for unauthenticated public pages
homepage.spec.ts
blog.spec.ts
services.spec.ts
sitemap.spec.ts
i18n.spec.ts
seo.spec.ts
admin/ # Tests for authenticated admin pages
contacts.spec.ts
orders.spec.ts
dashboard.spec.ts
fixtures/
auth.fixture.ts # Authentication fixtures
data.fixture.ts # Test data factories
pages/ # Page Object Model classes
HomePage.ts
BlogPage.ts
ContactsPage.ts
LoginPage.ts
helpers/
api.helper.ts # API call helpers for test setup
Page Object Model
The Page Object Model is the single most important pattern for maintainable E2E tests. When a selector changes, you update one place — not every test that uses it.
// tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
readonly forgotPasswordLink: Locator;
constructor(page: Page) {
this.page = page;
// Always use data-testid selectors
this.emailInput = page.getByTestId('login-email');
this.passwordInput = page.getByTestId('login-password');
this.submitButton = page.getByTestId('login-submit');
this.errorMessage = page.getByTestId('login-error');
this.forgotPasswordLink = page.getByTestId('forgot-password-link');
}
async goto() {
await this.page.goto('/auth/login');
await expect(this.page).toHaveURL('/auth/login');
await expect(this.submitButton).toBeVisible();
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async loginAndWaitForRedirect(email: string, password: string, expectedUrl: string) {
await this.login(email, password);
await expect(this.page).toHaveURL(expectedUrl, { timeout: 10_000 });
}
async expectError(message: string) {
await expect(this.errorMessage).toBeVisible();
await expect(this.errorMessage).toContainText(message);
}
}
// tests/pages/ContactsPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class ContactsPage {
readonly page: Page;
readonly addButton: Locator;
readonly searchInput: Locator;
readonly table: Locator;
readonly dialog: Locator;
readonly nameInput: Locator;
readonly emailInput: Locator;
readonly saveButton: Locator;
readonly successToast: Locator;
constructor(page: Page) {
this.page = page;
this.addButton = page.getByTestId('contacts-add-button');
this.searchInput = page.getByTestId('contacts-search');
this.table = page.getByTestId('contacts-table');
this.dialog = page.getByTestId('contact-dialog');
this.nameInput = page.getByTestId('contact-name-input');
this.emailInput = page.getByTestId('contact-email-input');
this.saveButton = page.getByTestId('contact-save-button');
this.successToast = page.getByTestId('toast-success');
}
async goto() {
await this.page.goto('/dashboard/contacts');
await expect(this.table).toBeVisible();
}
async openAddDialog() {
await this.addButton.click();
await expect(this.dialog).toBeVisible();
}
async createContact(name: string, email: string) {
await this.openAddDialog();
await this.nameInput.fill(name);
await this.emailInput.fill(email);
await this.saveButton.click();
await expect(this.successToast).toBeVisible();
}
async searchFor(query: string) {
await this.searchInput.fill(query);
await this.page.waitForTimeout(500); // Debounce delay
}
async getRowCount() {
return this.table.getByRole('row').count();
}
async expectContactInTable(name: string) {
await expect(this.table).toContainText(name);
}
}
Authentication Fixtures
// tests/fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';
type AuthFixture = {
adminPage: Page;
userPage: Page;
};
export const test = base.extend<AuthFixture>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
// Login via API to bypass UI (faster, more reliable)
const response = await page.request.post('/auth/login', {
data: {
email: process.env.TEST_ADMIN_EMAIL || '[email protected]',
password: process.env.TEST_ADMIN_PASSWORD || 'testpassword123',
},
});
if (!response.ok()) {
throw new Error(`Auth failed: ${response.status()}`);
}
// Cookies are set automatically on the context
await use(page);
await context.close();
},
userPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.request.post('/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL || '[email protected]',
password: process.env.TEST_USER_PASSWORD || 'testpassword123',
},
});
await use(page);
await context.close();
},
});
export { expect } from '@playwright/test';
Writing Tests
// tests/admin/contacts.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
import { ContactsPage } from '../pages/ContactsPage';
test.describe('Contacts Management', () => {
test('admin can create, search, and delete a contact', async ({ adminPage }) => {
const contactsPage = new ContactsPage(adminPage);
const testName = `Test User ${Date.now()}`;
const testEmail = `test-${Date.now()}@example.com`;
await contactsPage.goto();
// Create
await contactsPage.createContact(testName, testEmail);
await contactsPage.expectContactInTable(testName);
// Search
await contactsPage.searchFor(testName);
const rowCount = await contactsPage.getRowCount();
expect(rowCount).toBe(2); // 1 header + 1 result
// Delete
await adminPage.getByTestId(`contact-delete-${testEmail}`).click();
await adminPage.getByTestId('confirm-delete').click();
await expect(contactsPage.successToast).toBeVisible();
await expect(contactsPage.table).not.toContainText(testName);
});
test('shows validation errors for invalid contact data', async ({ adminPage }) => {
const contactsPage = new ContactsPage(adminPage);
await contactsPage.goto();
await contactsPage.openAddDialog();
// Submit without filling required fields
await contactsPage.saveButton.click();
await expect(adminPage.getByTestId('error-name')).toContainText('required');
await expect(adminPage.getByTestId('error-email')).toContainText('required');
});
});
Public Page Tests
// tests/public/homepage.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Homepage', () => {
test('renders hero section with correct heading', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/ECOSIRE/);
const hero = page.getByTestId('hero-heading');
await expect(hero).toBeVisible();
await expect(hero).not.toBeEmpty();
});
test('navigation links are all accessible', async ({ page }) => {
await page.goto('/');
const navLinks = page.getByRole('navigation').getByRole('link');
const count = await navLinks.count();
for (let i = 0; i < count; i++) {
const link = navLinks.nth(i);
const href = await link.getAttribute('href');
// All nav links should have valid hrefs
expect(href).toBeTruthy();
expect(href).not.toBe('#');
}
});
test('CTA button links to services page', async ({ page }) => {
await page.goto('/');
await page.getByTestId('hero-cta').click();
await expect(page).toHaveURL(/\/services/);
});
});
i18n Tests
// tests/public/i18n.spec.ts
import { test, expect } from '@playwright/test';
const testLocales = ['zh', 'es', 'ar', 'fr'];
test.describe('Internationalization', () => {
for (const locale of testLocales) {
test(`/${locale}/ renders with correct lang attribute`, async ({ page }) => {
await page.goto(`/${locale}/`);
const html = page.locator('html');
await expect(html).toHaveAttribute('lang', locale);
});
}
test('Arabic locale sets dir="rtl"', async ({ page }) => {
await page.goto('/ar/');
const html = page.locator('html');
await expect(html).toHaveAttribute('dir', 'rtl');
});
test('language switcher changes locale', async ({ page }) => {
await page.goto('/');
await page.getByTestId('language-switcher').click();
await page.getByTestId('locale-option-fr').click();
await expect(page).toHaveURL('/fr/');
await expect(page.locator('html')).toHaveAttribute('lang', 'fr');
});
test('hreflang tags are present on all pages', async ({ page }) => {
await page.goto('/');
const hreflangs = await page.locator('link[rel="alternate"][hreflang]').all();
// Should have 11 locale hreflangs + x-default
expect(hreflangs.length).toBeGreaterThanOrEqual(12);
});
});
Accessibility Tests
// tests/public/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility', () => {
const pages = ['/', '/blog', '/services', '/about', '/contact'];
for (const path of pages) {
test(`${path} has no WCAG 2.1 AA violations`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
if (results.violations.length > 0) {
const report = results.violations.map((v) =>
`${v.id}: ${v.description} — ${v.nodes.length} element(s)`
).join('\n');
expect.fail(`Accessibility violations on ${path}:\n${report}`);
}
});
}
test('skip link is the first focusable element', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toHaveAttribute('href', '#main-content');
});
});
CI Integration
# .github/workflows/ci.yml (E2E section)
e2e-tests:
runs-on: ubuntu-latest
needs: [unit-tests, build]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: cd apps/web && npx playwright install --with-deps chromium firefox
- name: Run E2E tests
run: cd apps/web && npx playwright test --project=chromium --project=firefox
env:
PLAYWRIGHT_BASE_URL: ${{ secrets.STAGING_URL }}
TEST_ADMIN_EMAIL: ${{ secrets.TEST_ADMIN_EMAIL }}
TEST_ADMIN_PASSWORD: ${{ secrets.TEST_ADMIN_PASSWORD }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: apps/web/playwright-report/
retention-days: 7
Frequently Asked Questions
When should I use data-testid vs ARIA roles vs text selectors?
Prefer data-testid as the primary selector — it survives UI redesigns, copy changes, and refactors. Use ARIA role selectors (getByRole('button', { name: 'Submit' })) for testing accessibility simultaneously. Avoid text content selectors (getByText('Submit')) because they break when copy changes. Never use CSS class selectors — they are implementation details that change with styling work.
How many projects (browsers) should I run in CI?
For pull requests, run Chromium only to keep CI fast. For main branch merges, run all three desktop browsers (Chromium, Firefox, WebKit). For release candidates, add mobile viewports. WebKit catches Safari-specific layout bugs; Firefox catches quirks that Chrome and Safari both hide. Tiering saves CI minutes while catching cross-browser bugs before release.
How do I handle flaky tests caused by timing issues?
Playwright's auto-wait should handle most timing issues — use await expect(locator).toBeVisible() rather than page.waitForTimeout(). If auto-wait is not enough, use page.waitForResponse() for network-dependent state, page.waitForLoadState('networkidle') for heavy pages, or page.waitForFunction() for custom conditions. Set retries: 2 in CI to distinguish genuine failures from infrastructure flakiness.
Should E2E tests use the real database or a seeded test database?
Use a dedicated staging environment with a seeded test database for E2E tests. Never run E2E tests against production. For CI, spin up a Docker Compose stack with a fresh database seeded with test data. Create and clean up test data via API calls in beforeAll/afterAll hooks rather than relying on pre-seeded data that can drift.
How do I test file downloads in Playwright?
Use the page.waitForDownload() pattern: const [download] = await Promise.all([page.waitForEvent('download'), page.click('[data-testid="download-button"]')]); const path = await download.path(); expect(path).toBeTruthy();. You can also check download.suggestedFilename() and read the file contents for validation.
Next Steps
Playwright E2E tests are the last safety net before users see your application. Invested properly — with page objects, data-testid selectors, auth fixtures, and CI integration — they catch what unit tests miss and give you the confidence to deploy on Fridays.
ECOSIRE maintains 29 Playwright E2E test files covering 416+ test cases across public pages, admin workflows, and accessibility for all 11 locales. Explore our frontend engineering services to learn how we build quality-gated Next.js applications.
Written by
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Grow Your Business with ECOSIRE
Enterprise solutions across ERP, eCommerce, AI, analytics, and automation.
Related Articles
Accounting Automation: Eliminate Manual Bookkeeping in 2026
Automate bookkeeping with bank feed automation, receipt scanning, invoice matching, AP/AR automation, and month-end close acceleration in 2026.
AI Agents for Business: The Definitive Guide (2026)
Comprehensive guide to AI agents for business: how they work, use cases, implementation roadmap, cost analysis, governance, and future trends for 2026.
AI Agents vs RPA: Which Automation Technology is Right for Your Business?
Deep comparison of LLM-powered AI agents versus traditional RPA bots — capabilities, costs, use cases, and a decision matrix for choosing the right approach.