How to Build an End-to-End Testing Suite with Playwright | Full Stack Guide
End-to-end tests catch the bugs that unit tests miss — broken auth flows, form submissions that silently fail, race conditions in real browsers. Playwright runs tests across Chromium, Firefox, and WebKit with a single API, auto-waits for elements, and generates traces for debugging flaky tests. This guide builds a structured, maintainable test suite from scratch.
Prerequisites
- -Node.js 20+
Playwright requires Node.js 18 or later. Version 20+ is recommended for the best performance.
- -A Running Web Application
You need an app to test. This guide uses a Next.js app as the example, but Playwright works with any web framework.
- -Basic TypeScript Knowledge
The test suite uses TypeScript for type-safe page objects and fixtures.
- -GitHub Account (Optional)
For CI integration with GitHub Actions. Any CI system works, but this guide provides a GitHub Actions config.
Install Playwright and Configure the Test Runner
Install Playwright and its browser binaries, then configure the test runner with multiple projects for cross-browser testing. The config file defines base URL, timeouts, retry behavior, and which browsers to test against. Set up a clean directory structure for tests, page objects, and fixtures.
# Install Playwright
npm init playwright@latest
# This creates:
# - playwright.config.ts
# - tests/
# - tests-examples/
# Create the test architecture directories
mkdir -p tests/pages tests/fixtures tests/helpersTip: Run 'npx playwright install' after CI setup to download browser binaries — they're not included in node_modules.
Tip: Start with Chromium only during development, then add Firefox and WebKit before merging to main.
Configure Playwright for Multi-Browser and CI
Customize the Playwright config with separate projects for desktop and mobile viewports, sensible timeouts, and HTML reporting. The webServer option automatically starts your dev server before running tests. Configure retries to handle flaky network conditions in CI while keeping local runs strict.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'test-results/results.json' }],
...(process.env.CI ? [['github' as const]] : []),
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
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 5'] },
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 13'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});Tip: Set retries: 0 locally so flaky tests fail immediately — fix them instead of hiding behind retries.
Tip: Use trace: 'on-first-retry' to capture traces only when tests fail, saving disk space in CI.
Build Page Object Models for Maintainability
Create page object classes that encapsulate page interactions and selectors. When a UI element changes, you update one page object instead of dozens of tests. Each page object exposes semantic methods like login(), createTask(), and expectEmptyState() rather than raw selectors. This makes tests read like user stories.
// tests/pages/LoginPage.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
async expectRedirectToDashboard() {
await expect(this.page).toHaveURL('/dashboard');
}
}
// tests/pages/DashboardPage.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly heading: Locator;
readonly taskList: Locator;
readonly newTaskButton: Locator;
readonly taskInput: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole('heading', { name: 'Dashboard' });
this.taskList = page.getByRole('list', { name: 'Tasks' });
this.newTaskButton = page.getByRole('button', { name: 'New task' });
this.taskInput = page.getByPlaceholder('Task title');
}
async createTask(title: string) {
await this.newTaskButton.click();
await this.taskInput.fill(title);
await this.taskInput.press('Enter');
}
async expectTaskCount(count: number) {
await expect(this.taskList.getByRole('listitem')).toHaveCount(count);
}
async expectTaskVisible(title: string) {
await expect(this.taskList.getByText(title)).toBeVisible();
}
}Tip: Use getByRole, getByLabel, and getByText over CSS selectors — they're resilient to markup changes and match how users find elements.
Tip: Keep page objects focused on one page or component. If a modal appears on multiple pages, give it its own page object.
Create Custom Fixtures for Authentication State
Build custom fixtures that handle authentication setup so individual tests don't repeat login flows. Playwright fixtures extend the base test object with reusable setup logic. Create an authenticatedPage fixture that logs in once and reuses the session across tests in the same worker.
// tests/fixtures/auth.fixture.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import path from 'path';
type AuthFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
authenticatedPage: DashboardPage;
};
export const test = base.extend<AuthFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
authenticatedPage: async ({ browser }, use) => {
const storageStatePath = path.resolve(
'tests/.auth/user.json'
);
// Try to reuse existing auth state
let context;
try {
context = await browser.newContext({ storageState: storageStatePath });
} catch {
// First run: perform login and save state
context = await browser.newContext();
const page = await context.newPage();
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(
process.env.TEST_USER_EMAIL ?? 'test@example.com',
process.env.TEST_USER_PASSWORD ?? 'password123'
);
await expect(page).toHaveURL('/dashboard');
await context.storageState({ path: storageStatePath });
await page.close();
await context.close();
context = await browser.newContext({ storageState: storageStatePath });
}
const page = await context.newPage();
await page.goto('/dashboard');
const dashboardPage = new DashboardPage(page);
await use(dashboardPage);
await context.close();
},
});
export { expect } from '@playwright/test';Tip: Save auth state to a JSON file with storageState() so subsequent tests skip the login flow entirely.
Tip: Add tests/.auth/ to .gitignore — auth state files contain session tokens.
Write Test Suites Using Fixtures and Page Objects
Write actual test cases that combine your page objects and fixtures. Tests should read like user scenarios: given an authenticated user, when they create a task, then the task appears in the list. Group related tests with describe blocks and use test.beforeEach for shared navigation steps.
// tests/auth.spec.ts
import { test, expect } from './fixtures/auth.fixture';
test.describe('Authentication', () => {
test('redirects unauthenticated users to login', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL('/login');
});
test('shows error for invalid credentials', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login('wrong@email.com', 'badpassword');
await loginPage.expectError('Invalid email or password');
});
test('logs in with valid credentials', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login('test@example.com', 'password123');
await loginPage.expectRedirectToDashboard();
});
});
// tests/tasks.spec.ts
import { test, expect } from './fixtures/auth.fixture';
test.describe('Task Management', () => {
test('creates a new task', async ({ authenticatedPage }) => {
await authenticatedPage.createTask('Write documentation');
await authenticatedPage.expectTaskVisible('Write documentation');
});
test('shows empty state when no tasks exist', async ({ authenticatedPage }) => {
await authenticatedPage.expectTaskCount(0);
});
test('persists tasks after page reload', async ({ authenticatedPage }) => {
await authenticatedPage.createTask('Persistent task');
await authenticatedPage.page.reload();
await authenticatedPage.expectTaskVisible('Persistent task');
});
});Tip: Keep tests independent — each test should work in isolation. Use fixtures to reset state, not test ordering.
Tip: Name tests as user behaviors ('creates a new task') not implementation details ('calls POST /api/tasks').
Add Visual Regression Testing
Use Playwright's built-in screenshot comparison to catch unintended visual changes. Take full-page and component-level screenshots, then compare them against golden baselines. When the UI changes intentionally, update the snapshots. This catches CSS regressions that functional tests miss entirely.
// tests/visual.spec.ts
import { test, expect } from './fixtures/auth.fixture';
test.describe('Visual Regression', () => {
test('login page matches snapshot', async ({ loginPage }) => {
await loginPage.goto();
await expect(loginPage.page).toHaveScreenshot('login-page.png', {
fullPage: true,
maxDiffPixelRatio: 0.01,
});
});
test('dashboard matches snapshot', async ({ authenticatedPage }) => {
await expect(authenticatedPage.page).toHaveScreenshot('dashboard.png', {
fullPage: true,
maxDiffPixelRatio: 0.01,
});
});
test('button variants render correctly', async ({ page }) => {
await page.goto('/design-system');
const buttonSection = page.getByTestId('button-showcase');
await expect(buttonSection).toHaveScreenshot('button-variants.png');
});
});
// To update snapshots after intentional UI changes:
// npx playwright test --update-snapshotsTip: Run visual tests on a single browser (Chromium) to avoid maintaining snapshots per browser — font rendering differs.
Tip: Set maxDiffPixelRatio to allow tiny anti-aliasing differences between runs.
Set Up CI with GitHub Actions
Create a GitHub Actions workflow that runs your full Playwright test suite on every pull request. Cache browser binaries to speed up CI runs. Upload test reports and failure traces as artifacts so you can debug failures without reproducing them locally.
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
env:
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- name: Upload test report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 14
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: test-results/
retention-days: 7Tip: Use 'npx playwright install --with-deps' in CI — the --with-deps flag installs OS-level dependencies like fonts and libraries.
Tip: Upload reports with if: ${{ !cancelled() }} so you get reports even when tests fail.
Tip: Store test credentials in GitHub Secrets, never in the workflow file.
Next Steps
- -Add API testing alongside E2E tests using Playwright's request context for backend endpoint validation.
- -Implement test data factories that create and clean up test data via API calls in beforeEach hooks.
- -Add accessibility testing with @axe-core/playwright to catch WCAG violations automatically.
- -Set up Playwright's test generator ('npx playwright codegen') to speed up writing new tests.
Need help building this?
I've shipped production projects with these stacks. Let's build yours together.
Let's Talk