Skip to main content
Crashbytes logoCrashbytes
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Browse Articles
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Network
Theme
Browse Articles
Crashbytes logoCrashbytes

Expert insights on web development, technology trends, and programming best practices. Learn from real-world experiences and cutting-edge techniques that help you build better software.

Follow Us

Our Sites

  • 🔮 Predictions
  • 📰 Breaking News
  • 🎨 AI Art
  • 📖 Short Stories
  • View All →
  • Products →

Sitemap

  • Home
  • All Articles
  • Open Source
  • Services
  • About Us
  • Contact
  • Donate Compute

Popular Topics

  • Serverless
  • Cloud Architecture
  • DevOps
  • Kubernetes
  • Platform Engineering

Resources

  • Privacy Policy
  • Terms of Service
  • Sitemap
  • RSS Feed
  • PGP Key

Stay Updated

Get the latest articles, tutorials, and insights delivered to your inbox. Join our community of developers and never miss an update.

© 2021-2026 Crashbytes® by Blackhole Software, LLC. All rights reserved.
| Reg. U.S. Pat. & Tm. Off.

Made for the developer community

  1. Home
  2. /
  3. Articles
  4. /
  5. Playwright Browser Automation Testing: Complete Tutorial from Zero to Production
TestingDecember 6, 202525 min read• By Michael Eakins

Playwright Browser Automation Testing: Complete Tutorial from Zero to Production

Learn Playwright browser automation from fundamentals through production deployment. Covers installation, test writing, fixtures, parallel execution, visual testing, and enterprise CI/CD integration with practical examples.

Quick Takeaways

What you'll learn in this article

25 min read
Intermediate
  • 1

    Learn Playwright browser automation from fundamentals through production deployment

  • 2

    Covers installation, test writing, fixtures, parallel execution, visual testing, and enterprise CI/CD integration with practical examples

Keep reading for detailed implementation, code examples, and real-world results

Playwright Browser Automation Testing: Complete Tutorial from Zero to Production

Modern web applications demand robust testing strategies that match their complexity. Enter Playwright - Microsoft's open-source browser automation framework that fundamentally changed how we approach end-to-end testing. Unlike legacy tools that feel like archaeological artifacts, Playwright was built for the modern web: auto-waiting, parallel execution, and multi-browser support aren't afterthoughts - they're core design principles.

I've spent the past 18 months migrating enterprise test suites from Selenium and Cypress to Playwright. The productivity gains aren't marginal - they're transformative. Tests that took 45 minutes now complete in 6. Flaky tests that required constant maintenance now run reliably. The developer experience shifts from fighting your tooling to actually testing your application.

This tutorial covers everything you need to go from zero Playwright knowledge to production-ready test automation. We'll build a complete test suite with real-world patterns, not trivial examples. By the end, you'll understand not just how to write Playwright tests, but how to architect test infrastructure that scales.

GitHub Repository: All code examples are available at github.com/CrashBytes/ByteSizedExamples/tree/main/playwright-tutorial with a fully configured demo application.

Why Playwright Beats the Alternatives

Before diving into code, understand what makes Playwright different. The testing landscape is crowded: Selenium, Cypress, Puppeteer, TestCafe. Each has trade-offs. Playwright isn't just another option - it represents a generational leap.

Auto-waiting eliminates the single biggest source of test flakiness. With Selenium, you write explicit waits everywhere: waitForElementVisible, waitUntil, custom retry logic. Miss one wait and your test becomes a race condition that passes locally but fails in CI. Playwright automatically waits for elements to be actionable - visible, enabled, stable on screen. You write await page.click('.button') and Playwright handles the complexity.

True cross-browser testing without compromises. Selenium requires separate WebDriver installations for each browser, version compatibility hell, and browser-specific quirks. Cypress only supports Chrome-based browsers. Playwright bundles browser binaries (Chromium, Firefox, WebKit) and provides a consistent API across all three. Write once, test everywhere. The WebKit support is critical - it's the only way to truly test Safari behavior without running tests on macOS.

Parallel execution by default. Most frameworks require complex setup for parallel test execution. Playwright runs tests in parallel out of the box, utilizing all available CPU cores. A 100-test suite that takes 25 minutes sequentially completes in 3 minutes with parallelization. Enterprise test suites with thousands of tests become practical.

Browser contexts provide perfect isolation. Every test gets a fresh browser context (incognito profile equivalent) with independent cookies, storage, and cache. No test pollution. No flaky failures because Test A left behind state that affects Test B. Clean slate every time.

Network interception and mocking are first-class features. Most frameworks require workarounds or plugins for API mocking. Playwright makes it trivial: intercept requests, mock responses, simulate network conditions, test offline behavior. Critical for testing error states and edge cases without requiring backend changes.

The developer experience compounds these technical advantages. TypeScript support is excellent with full IntelliSense. The test runner (built-in, no need for Jest or Mocha) provides parallel execution, retries, and beautiful HTML reports. Debugging is straightforward with page.pause() for interactive debugging and trace viewer for post-mortem analysis.

Installation and Setup

Let's build this right from the start. Initialize a new project:

mkdir playwright-tutorial
cd playwright-tutorial
npm init -y
npm install -D @playwright/test
npx playwright install

The playwright install command downloads browser binaries (Chromium, Firefox, WebKit). This takes about 300MB but ensures version consistency across your team. No "works on my machine" browser version issues.

Create playwright.config.ts:

import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests',

  // Maximum time one test can run
  timeout: 30 * 1000,

  // Parallel execution - utilize all CPU cores
  fullyParallel: true,

  // Fail build on CI if you accidentally left test.only
  forbidOnly: !!process.env.CI,

  // Retry failed tests in CI environments
  retries: process.env.CI ? 2 : 0,

  // Limit parallel workers in CI (shared resources)
  workers: process.env.CI ? 2 : undefined,

  // Reporter configuration
  reporter: [
    ['html'],
    ['json', { outputFile: 'test-results.json' }],
    ['junit', { outputFile: 'test-results.xml' }],
  ],

  use: {
    // Base URL for page.goto('/') calls
    baseURL: 'http://localhost:3000',

    // Collect trace on first retry for debugging
    trace: 'on-first-retry',

    // Screenshot on failure
    screenshot: 'only-on-failure',

    // Video on first retry (storage vs debugging trade-off)
    video: 'retain-on-failure',
  },

  // Configure projects for multiple browsers
  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 12'] },
    },
  ],

  // Run local dev server before tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

This configuration is production-ready. The projects array enables true cross-browser testing - every test runs against all configured browsers unless you specify otherwise. The webServer option automatically starts your dev server before tests and kills it after, eliminating manual server management.

Your First Test: Beyond Hello World

Let's write a real test, not a trivial example. We'll test a login flow with proper assertions and error handling.

Create tests/auth.spec.ts:

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

test.describe('Authentication', () => {
  test('successful login with valid credentials', async ({ page }) => {
    // Navigate to login page
    await page.goto('/login')

    // Verify login page loaded
    await expect(page).toHaveTitle(/Login/)

    // Fill login form
    await page.fill('input[name="email"]', 'test@example.com')
    await page.fill('input[name="password"]', 'SecurePassword123!')

    // Submit form
    await page.click('button[type="submit"]')

    // Wait for navigation and verify dashboard loaded
    await page.waitForURL('/dashboard')
    await expect(page.locator('h1')).toHaveText('Dashboard')

    // Verify user menu shows correct email
    await expect(page.locator('[data-testid="user-email"]')).toHaveText(
      'test@example.com'
    )
  })

  test('login fails with invalid password', async ({ page }) => {
    await page.goto('/login')

    await page.fill('input[name="email"]', 'test@example.com')
    await page.fill('input[name="password"]', 'WrongPassword')
    await page.click('button[type="submit"]')

    // Verify error message appears
    const errorMessage = page.locator('[role="alert"]')
    await expect(errorMessage).toBeVisible()
    await expect(errorMessage).toContainText('Invalid credentials')

    // Verify we stayed on login page
    await expect(page).toHaveURL(/\/login/)
  })

  test('login form validation prevents empty submission', async ({ page }) => {
    await page.goto('/login')

    // Click submit without filling form
    await page.click('button[type="submit"]')

    // Browser native validation should prevent submission
    // Check if we're still on login page (navigation didn't happen)
    await expect(page).toHaveURL(/\/login/)

    // Verify form fields show validation state
    const emailInput = page.locator('input[name="email"]')
    await expect(emailInput).toHaveAttribute('aria-invalid', 'true')
  })
})

Notice the patterns here. We use data-testid attributes for user interface elements that need testing - more reliable than CSS selectors that change with styling. The expect(page).toHaveURL() matcher with regex provides flexibility for query parameters. waitForURL automatically handles navigation timing.

Run the tests:

npx playwright test

Playwright runs all tests in parallel across all configured browsers. You'll see output showing tests executing against chromium, firefox, and webkit simultaneously. The HTML report opens automatically on failure.

Advertisement

Page Object Model: Architecture That Scales

As test suites grow, maintaining hundreds of tests becomes impossible without proper architecture. The Page Object Model (POM) encapsulates page interactions into reusable classes. Here's production-ready POM implementation.

Create tests/pages/LoginPage.ts:

import { Page, Locator } 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.locator('input[name="email"]')
    this.passwordInput = page.locator('input[name="password"]')
    this.submitButton = page.locator('button[type="submit"]')
    this.errorMessage = page.locator('[role="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 expectLoginSuccess(expectedUrl: RegExp = /\/dashboard/) {
    await this.page.waitForURL(expectedUrl)
  }

  async expectLoginFailure(expectedError: string) {
    await expect(this.errorMessage).toBeVisible()
    await expect(this.errorMessage).toContainText(expectedError)
  }
}

Refactor the test to use the page object:

import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'

test.describe('Authentication with POM', () => {
  test('successful login', async ({ page }) => {
    const loginPage = new LoginPage(page)

    await loginPage.goto()
    await loginPage.login('test@example.com', 'SecurePassword123!')
    await loginPage.expectLoginSuccess()

    await expect(page.locator('[data-testid="user-email"]')).toHaveText(
      'test@example.com'
    )
  })

  test('invalid password shows error', async ({ page }) => {
    const loginPage = new LoginPage(page)

    await loginPage.goto()
    await loginPage.login('test@example.com', 'WrongPassword')
    await loginPage.expectLoginFailure('Invalid credentials')
  })
})

The benefits scale exponentially. When login form styling changes, you update one file instead of twenty tests. When you add OAuth login, you extend the LoginPage class instead of modifying every test. Maintenance burden drops dramatically.

Fixtures: Dependency Injection for Tests

Fixtures are Playwright's dependency injection system. They eliminate repetitive setup code and enable composable test utilities. Built-in fixtures include page, context, and browser. Custom fixtures extend this system.

Create tests/fixtures.ts:

import { test as base } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
import { DashboardPage } from './pages/DashboardPage'

// Extend base test with custom fixtures
export const test = base.extend<{
  loginPage: LoginPage
  dashboardPage: DashboardPage
  authenticatedPage: Page
}>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page)
    await use(loginPage)
  },

  dashboardPage: async ({ page }, use) => {
    const dashboardPage = new DashboardPage(page)
    await use(dashboardPage)
  },

  // Auto-authenticated page fixture
  authenticatedPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page)
    await loginPage.goto()
    await loginPage.login('test@example.com', 'SecurePassword123!')
    await loginPage.expectLoginSuccess()

    // Now use() provides an already-authenticated page
    await use(page)
  },
})

export { expect } from '@playwright/test'

Use fixtures in tests:

import { test, expect } from './fixtures'

test('authenticated user can view dashboard', async ({
  authenticatedPage,
  dashboardPage,
}) => {
  // Page is already authenticated - fixture handled login
  await expect(authenticatedPage).toHaveURL(/\/dashboard/)

  // Use dashboard page object
  await dashboardPage.expectWelcomeMessage('Welcome, test@example.com')
})

The authenticatedPage fixture runs before each test that uses it, logging in automatically. Tests focus on what they're testing, not authentication setup. This pattern shines with complex fixtures: database seeding, API mocking, feature flag configuration.

For tests that need different authentication states, create multiple fixtures:

export const test = base.extend<{
  adminUser: Page
  regularUser: Page
  guestUser: Page
}>({
  adminUser: async ({ page }, use) => {
    await loginAs(page, 'admin@example.com', 'AdminPass123!')
    await use(page)
  },

  regularUser: async ({ page }, use) => {
    await loginAs(page, 'user@example.com', 'UserPass123!')
    await use(page)
  },

  guestUser: async ({ page }, use) => {
    // Just use page without authentication
    await use(page)
  },
})

Now tests explicitly declare their authentication requirements:

test('admin can delete users', async ({ adminUser }) => {
  // Test runs with admin authentication
})

test('regular user cannot delete users', async ({ regularUser }) => {
  // Test runs with regular user authentication
})

API Mocking and Network Interception

Testing real API calls is slow and unreliable. Network conditions vary. Backend services fail. Test data gets polluted. Playwright's network interception solves this.

Mock API responses:

test('dashboard loads with mocked user data', async ({ page }) => {
  // Intercept API call and return mock data
  await page.route('**/api/user/profile', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        id: '123',
        email: 'test@example.com',
        name: 'Test User',
        role: 'admin',
        createdAt: '2024-01-01T00:00:00Z',
      }),
    })
  })

  await page.goto('/dashboard')

  // Verify UI renders mocked data correctly
  await expect(page.locator('[data-testid="user-name"]')).toHaveText(
    'Test User'
  )
  await expect(page.locator('[data-testid="user-role"]')).toHaveText('admin')
})

Test error states:

test('dashboard shows error on API failure', async ({ page }) => {
  await page.route('**/api/user/profile', async route => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal server error' }),
    })
  })

  await page.goto('/dashboard')

  await expect(page.locator('[role="alert"]')).toBeVisible()
  await expect(page.locator('[role="alert"]')).toContainText(
    'Failed to load profile'
  )
})

Simulate network conditions:

test('app handles slow network gracefully', async ({ page }) => {
  // Delay all responses by 5 seconds
  await page.route('**/*', async route => {
    await new Promise(resolve => setTimeout(resolve, 5000))
    await route.continue()
  })

  await page.goto('/dashboard')

  // Verify loading state appears
  await expect(page.locator('[data-testid="loading-spinner"]')).toBeVisible()

  // Verify content eventually loads
  await expect(page.locator('h1')).toHaveText('Dashboard', { timeout: 10000 })
})

For complex scenarios, create fixture-based mocking:

export const test = base.extend<{
  mockApi: void
}>({
  mockApi: async ({ page }, use) => {
    // Mock all API routes
    await page.route('**/api/**', async route => {
      const url = route.request().url()

      if (url.includes('/api/user/profile')) {
        await route.fulfill({
          status: 200,
          body: JSON.stringify({
            /* user data */
          }),
        })
      } else if (url.includes('/api/posts')) {
        await route.fulfill({
          status: 200,
          body: JSON.stringify({
            /* posts data */
          }),
        })
      } else {
        // Pass through unmocked requests
        await route.continue()
      }
    })

    await use()
  },
})

Visual Testing and Screenshots

Automated visual regression testing catches UI bugs that functional tests miss: layout shifts, CSS regressions, rendering issues. Playwright's screenshot comparison is deterministic and fast.

Basic screenshot test:

test('homepage visual regression', async ({ page }) => {
  await page.goto('/')

  // First run: creates baseline screenshot
  // Subsequent runs: compares against baseline
  await expect(page).toHaveScreenshot('homepage.png')
})

Component-specific screenshots:

test('product card renders correctly', async ({ page }) => {
  await page.goto('/products')

  const productCard = page.locator('[data-testid="product-card"]').first()
  await expect(productCard).toHaveScreenshot('product-card.png')
})

Handle dynamic content:

test('dashboard screenshot with masked dynamic content', async ({ page }) => {
  await page.goto('/dashboard')

  // Mask elements that change every render (dates, random IDs)
  await expect(page).toHaveScreenshot('dashboard.png', {
    mask: [
      page.locator('[data-testid="timestamp"]'),
      page.locator('[data-testid="session-id"]'),
    ],
  })
})

Cross-browser visual testing requires per-browser baselines. Playwright handles this automatically - homepage-chromium.png, homepage-firefox.png, homepage-webkit.png. Different browsers render slightly differently; separate baselines prevent false positives.

Configure visual testing thresholds in playwright.config.ts:

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      // Allow 5% pixel difference before failing
      maxDiffPixelRatio: 0.05,

      // Threshold for considering a pixel different
      threshold: 0.2,
    },
  },
})

Visual tests are powerful but expensive. Screenshot comparison consumes significant CI time and storage. Use strategically: critical user flows, marketing pages, component libraries. Don't screenshot every page.

Parallel Execution and Test Isolation

Playwright's parallel execution is its secret weapon. A 500-test suite that takes 2 hours sequentially completes in 8 minutes with parallelization. Configuration is minimal.

Configure workers in playwright.config.ts:

export default defineConfig({
  // Use all CPU cores locally, limit workers in CI
  workers: process.env.CI ? 4 : undefined,

  // Run tests within a file in parallel
  fullyParallel: true,
})

Test isolation is automatic through browser contexts. Every test gets a fresh context with clean state. But tests manipulating shared resources (database, API state) need coordination.

Serial execution for dependent tests:

test.describe.serial('Database migration sequence', () => {
  test('create users table', async ({ page }) => {
    // Run first
  })

  test('populate users table', async ({ page }) => {
    // Run second, after previous test
  })

  test('verify users table', async ({ page }) => {
    // Run third, after previous test
  })
})

The .serial modifier runs tests sequentially within the describe block. Other describe blocks still run in parallel.

Worker-scoped fixtures for expensive setup:

import { test as base } from '@playwright/test'

const test = base.extend<{}, { workerDatabase: Database }>({
  workerDatabase: [
    async ({}, use) => {
      // Setup runs once per worker
      const db = await createTestDatabase()
      await db.seed()

      await use(db)

      // Teardown runs once per worker
      await db.destroy()
    },
    { scope: 'worker' },
  ],
})

Worker-scoped fixtures run once per worker thread instead of once per test. Use for expensive operations: database setup, server initialization, file system preparation.

Debugging Playwright Tests

When tests fail in CI but pass locally, debugging is critical. Playwright provides excellent tools.

UI Mode - Interactive debugging:

npx playwright test --ui

UI Mode runs tests in a browser with time-travel debugging. Pause, step through, inspect DOM state at each action. Far superior to console.log debugging.

Debug Mode - Terminal debugging:

npx playwright test --debug

Opens Playwright Inspector with the test paused. Step through line by line, inspect locators, watch network activity.

Trace Viewer - Post-mortem debugging:

Configure trace collection:

export default defineConfig({
  use: {
    trace: 'on-first-retry',
  },
})

View traces after test failures:

npx playwright show-trace trace.zip

Trace viewer shows a timeline of all actions, screenshots at each step, network activity, console logs, and DOM snapshots. Everything you need to diagnose failures without reproducing them.

Headed Mode - See browser during test execution:

npx playwright test --headed

Runs tests with visible browser. Useful for understanding what's happening when tests behave unexpectedly.

Slow Motion - Watch tests execute slowly:

npx playwright test --headed --slow-mo=1000

Adds 1 second delay between actions. Helps understand timing-related issues.

Advertisement

CI/CD Integration

Playwright tests are worthless if they don't run in CI. Here's production GitHub Actions configuration.

Create .github/workflows/playwright.yml:

name: Playwright Tests
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest

    strategy:
      matrix:
        # Run tests across multiple Node versions
        node-version: [18, 20]

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          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

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report-node-${{ matrix.node-version }}
          path: playwright-report/
          retention-days: 30

      - name: Upload test traces
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-traces-node-${{ matrix.node-version }}
          path: test-results/
          retention-days: 30

This configuration runs tests on every push and pull request, tests against multiple Node versions (matrix strategy), and uploads test artifacts for debugging failures.

For organizations with existing CI infrastructure (Jenkins, GitLab CI, CircleCI), the pattern is similar:

  1. Install Node.js
  2. Install dependencies (npm ci)
  3. Install Playwright browsers (npx playwright install --with-deps)
  4. Run tests (npx playwright test)
  5. Archive reports and traces

The --with-deps flag installs system dependencies needed by browsers (fonts, media codecs, etc.). Critical in Docker containers.

Docker Integration:

FROM mcr.microsoft.com/playwright:v1.40.0-focal

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

CMD ["npx", "playwright", "test"]

Microsoft maintains official Playwright Docker images with all dependencies pre-installed. Use these for consistent CI environments.

Advanced Patterns: Authentication State Reuse

Logging in for every test is expensive. A 100-test suite spending 2 seconds per login wastes 3+ minutes. Playwright's authentication state reuse eliminates this.

Create a global setup file that authenticates once and saves state:

tests/global-setup.ts:

import { chromium, FullConfig } from '@playwright/test'

async function globalSetup(config: FullConfig) {
  const browser = await chromium.launch()
  const page = await browser.newPage()

  // Perform authentication
  await page.goto('http://localhost:3000/login')
  await page.fill('input[name="email"]', 'test@example.com')
  await page.fill('input[name="password"]', 'SecurePassword123!')
  await page.click('button[type="submit"]')
  await page.waitForURL('**/dashboard')

  // Save authentication state
  await page.context().storageState({ path: 'auth.json' })
  await browser.close()
}

export default globalSetup

Configure Playwright to run global setup and reuse state:

export default defineConfig({
  globalSetup: require.resolve('./tests/global-setup'),

  use: {
    storageState: 'auth.json',
  },
})

Now every test starts with authentication already complete. Login happens once per test run instead of once per test. 100 tests save 3+ minutes.

Multiple authentication states for different user roles:

async function globalSetup(config: FullConfig) {
  // Admin user
  await authenticate('admin@example.com', 'AdminPass', 'admin-auth.json')

  // Regular user
  await authenticate('user@example.com', 'UserPass', 'user-auth.json')

  // Guest (no auth)
  const browser = await chromium.launch()
  const context = await browser.newContext()
  await context.storageState({ path: 'guest-auth.json' })
  await browser.close()
}

Use different states per test:

test.use({ storageState: 'admin-auth.json' })
test('admin-only feature', async ({ page }) => {
  // Runs with admin authentication
})

test.use({ storageState: 'user-auth.json' })
test('regular user feature', async ({ page }) => {
  // Runs with regular user authentication
})

Testing Mobile Experiences

Playwright's device emulation enables mobile testing without physical devices. The emulation is comprehensive: viewport size, user agent, touch events, geolocation, timezone.

Test mobile viewport:

import { devices } from '@playwright/test'

test('mobile navigation menu', async ({ browser }) => {
  const context = await browser.newContext({
    ...devices['iPhone 12'],
  })
  const page = await context.newPage()

  await page.goto('/')

  // Mobile hamburger menu should be visible
  await expect(page.locator('[data-testid="mobile-menu-button"]')).toBeVisible()

  // Desktop navigation should be hidden
  await expect(page.locator('[data-testid="desktop-nav"]')).toBeHidden()
})

Configure mobile testing in projects:

export default defineConfig({
  projects: [
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    },
    {
      name: 'Tablet',
      use: { ...devices['iPad Pro'] },
    },
  ],
})

Tests run against all device configurations. Verify responsive layouts work correctly across form factors.

Geolocation testing:

test('store locator uses user location', async ({ browser }) => {
  const context = await browser.newContext({
    geolocation: { longitude: -122.4194, latitude: 37.7749 }, // San Francisco
    permissions: ['geolocation'],
  })
  const page = await context.newPage()

  await page.goto('/stores')
  await page.click('[data-testid="use-my-location"]')

  // Verify stores sorted by distance from SF
  const firstStore = page.locator('[data-testid="store-card"]').first()
  await expect(firstStore).toContainText('San Francisco')
})

Accessibility Testing Integration

Playwright integrates with accessibility testing tools. Combine with axe-core for automated a11y validation.

Install axe-playwright:

npm install -D @axe-core/playwright

Create accessibility test helper:

import { test as base } from '@playwright/test'
import { injectAxe, checkA11y } from '@axe-core/playwright'

export const test = base.extend({
  page: async ({ page }, use) => {
    await use(page)
  },
})

test('homepage has no accessibility violations', async ({ page }) => {
  await page.goto('/')

  // Inject axe-core
  await injectAxe(page)

  // Check for violations
  await checkA11y(page)
})

Configure axe to check specific standards:

await checkA11y(page, null, {
  runOnly: {
    type: 'tag',
    values: ['wcag2a', 'wcag2aa', 'wcag21aa'],
  },
})

Check specific regions:

await checkA11y(page, '[data-testid="checkout-form"]', {
  rules: {
    'color-contrast': { enabled: true },
    label: { enabled: true },
  },
})

Accessibility tests catch issues functional tests miss: missing ARIA labels, insufficient color contrast, keyboard navigation problems, screen reader compatibility issues.

Performance Testing Basics

Playwright can measure performance metrics during test execution. Collect Web Vitals data:

test('homepage loads within performance budget', async ({ page }) => {
  await page.goto('/')

  // Measure Largest Contentful Paint
  const lcp = await page.evaluate(() => {
    return new Promise(resolve => {
      new PerformanceObserver(list => {
        const entries = list.getEntries()
        const lastEntry = entries[entries.length - 1]
        resolve(lastEntry.renderTime || lastEntry.loadTime)
      }).observe({ entryTypes: ['largest-contentful-paint'] })
    })
  })

  // LCP should be under 2.5 seconds (good threshold)
  expect(lcp).toBeLessThan(2500)
})

Measure JavaScript execution time:

test('dashboard renders efficiently', async ({ page }) => {
  const startMark = `navigation-start-${Date.now()}`

  await page.goto('/dashboard')
  await page.evaluate(mark => performance.mark(mark), startMark)

  // Wait for dashboard to fully render
  await page.waitForSelector('[data-testid="dashboard-loaded"]')

  const renderTime = await page.evaluate(mark => {
    performance.mark('dashboard-rendered')
    performance.measure('dashboard-render', mark, 'dashboard-rendered')
    const measure = performance.getEntriesByName('dashboard-render')[0]
    return measure.duration
  }, startMark)

  // Dashboard should render in under 1 second
  expect(renderTime).toBeLessThan(1000)
})

These aren't comprehensive performance tests (use Lighthouse or WebPageTest for that), but they catch obvious regressions during development.

Real-World Testing Patterns

After implementing Playwright across multiple enterprise projects, certain patterns emerge as consistently valuable.

Test Data Builder Pattern:

class UserBuilder {
  private data = {
    email: 'test@example.com',
    password: 'Password123!',
    name: 'Test User',
    role: 'user' as 'user' | 'admin',
  }

  withEmail(email: string) {
    this.data.email = email
    return this
  }

  withRole(role: 'user' | 'admin') {
    this.data.role = role
    return this
  }

  asAdmin() {
    this.data.role = 'admin'
    this.data.email = 'admin@example.com'
    return this
  }

  build() {
    return this.data
  }
}

// Usage
test('admin features', async ({ page }) => {
  const adminUser = new UserBuilder()
    .asAdmin()
    .withEmail('custom-admin@example.com')
    .build()

  await loginAs(page, adminUser)
})

Custom Assertions:

export const customExpect = {
  async toBeFullyLoaded(page: Page) {
    await expect(page.locator('body')).toHaveAttribute('data-loaded', 'true')
    await expect(page.locator('[data-loading]')).toHaveCount(0)
  },

  async toHaveNoConsoleErrors(page: Page) {
    const errors: string[] = []
    page.on('console', msg => {
      if (msg.type() === 'error') errors.push(msg.text())
    })

    await page.waitForLoadState('networkidle')
    expect(errors).toHaveLength(0)
  },
}

// Usage
test('page loads cleanly', async ({ page }) => {
  await page.goto('/')
  await customExpect.toBeFullyLoaded(page)
  await customExpect.toHaveNoConsoleErrors(page)
})

Conditional Testing:

test('feature flag dependent test', async ({ page }) => {
  await page.goto('/')

  const featureEnabled = await page.evaluate(() => {
    return window.featureFlags?.['new-dashboard'] === true
  })

  if (!featureEnabled) {
    test.skip()
  }

  // Test new dashboard feature
  await page.click('[data-testid="new-dashboard-link"]')
  // ...
})

Migration from Other Frameworks

If you're coming from Selenium or Cypress, migration patterns are straightforward.

From Selenium:

// Selenium
const element = await driver.findElement(By.css('.button'))
await driver.wait(until.elementIsVisible(element))
await element.click()

// Playwright
await page.click('.button')
// Auto-waiting built in
// Selenium
await driver.executeScript('return document.querySelector(".data").textContent')

// Playwright
await page.locator('.data').textContent()

From Cypress:

// Cypress
cy.get('.button').click()
cy.url().should('include', '/dashboard')

// Playwright
await page.click('.button')
await expect(page).toHaveURL(/\/dashboard/)

The mental model shift: Playwright is promise-based (async/await), Cypress uses command chaining. Playwright tests run in Node.js with full access to modules, Cypress runs in browser context with limited access.

Conclusion: Test Infrastructure That Scales

Playwright represents a fundamental improvement in browser automation testing. The difference isn't marginal - it's the difference between test suites that developers actively avoid running versus test infrastructure that provides genuine confidence in deployment.

I've watched engineering teams transform their release processes after migrating to Playwright. Tests that were so flaky they got disabled entirely now run reliably in CI. Manual testing that consumed hours of QA time gets automated. Production bugs caught in testing instead of production. The compound effect on development velocity is substantial.

The patterns in this tutorial - Page Object Model, fixtures, authentication state reuse, parallel execution - aren't theoretical best practices. They're battle-tested approaches that emerged from managing test suites with thousands of tests across multiple browser combinations. Start with these foundations and you'll avoid the technical debt that makes test maintenance unsustainable.

The ecosystem around Playwright is maturing rapidly. Microsoft's investment in the project shows in the quality of tooling: VS Code integration, trace viewer, UI mode debugging. The community provides libraries for specialized needs: visual regression testing, accessibility validation, performance monitoring. You're building on a solid foundation.

Testing isn't just about catching bugs. It's about enabling confidence to move fast. With Playwright, your test suite becomes an asset that accelerates development instead of a burden that slows it down. That shift in perspective - from tests as necessary evil to tests as competitive advantage - is what separates good engineering organizations from great ones.

The complete code for all examples in this tutorial is available at github.com/CrashBytes/ByteSizedExamples/tree/main/playwright-tutorial. Clone it, modify it, break it. The best way to learn testing frameworks is to test real applications, not trivial examples.

Further Reading

Looking to expand your testing expertise? My prediction on AI-powered test generation reaching 70% automation by Q3 2026 explores how LLMs will transform test authoring workflows. For CI/CD integration patterns beyond Playwright, see my comprehensive guide on modern CI/CD architecture with GitHub Actions, ArgoCD, and progressive delivery. And if you're curious about how AI is transforming quality assurance roles overall, my analysis of QA engineer displacement by AI testing tools provides workforce implications and transition strategies.

Browser testing isn't disappearing - it's evolving. Playwright positions you to take advantage of that evolution instead of fighting against outdated tooling. Build test infrastructure that compounds in value over time instead of degrading into maintenance burden. Your future self will thank you.

Advertisement

Was this article helpful?

Your feedback helps us improve our content and create more valuable resources

We appreciate honest feedback - it helps us serve you better

Work with us

This analysis is what we do for clients

CrashBytes consults on enterprise AI strategy and implementation, builds custom web and mobile software, and places senior engineers on corp-to-corp engagements.

See Services

Enjoyed this? Get the next one.

Join developers getting CrashBytes articles, tutorials, and predictions in their inbox. No spam, unsubscribe anytime.

Related Topics

tutorialtestingautomationplaywrightjavascripttypescriptci-cdquality-assuranceweb-developmentbrowser-testing
Back to Articles
← PreviousAI Automation of Paralegals and Legal Assistants: Document Review, Research, and the $160M Signal of Workforce DisplacementNext →AI Space Robotics Revolution: How Stanford's ISS Breakthrough Paves the Path to Autonomous Mars Exploration

From across the CrashBytes network

More than the blog — predictions, news, fiction, and AI art.

PredictionCustom AI Chips Reach Commodity Status by Q4 2027: Cloud Provider Competition Drives Democratization
NewsWeek In Review July 19-25, 2026 - The Week The Money Moved To The Metering Layer
Short StoryThe Answer Key
AI ArtThe Room That Remembers

Continue Your Learning Journey

Explore more articles related to Testing and expand your knowledge.

📄Tutorial

The MCP Testing Crisis (And How We Fixed It): Complete Tutorial for @crashbytes/mcp-test-kit

The Model Context Protocol ecosystem had a critical gap: no simple, production-ready testing framework. Learn why we built @crashbytes/mcp-test-kit and how to test your MCP servers properly.

22 min readRead more
📄tutorial

Tutorial: GitHub Actions CI/CD Complete Guide - Workflow Automation from Zero to Production

Master GitHub Actions from scratch with hands-on examples. Build complete CI/CD pipelines with testing, linting, deployment, and advanced workflow patterns for modern development teams.

22 min readRead more
📄Engineering

Build an LLM-as-Judge Evaluation Harness in TypeScript: Scorers, Rubrics, and a CI Gate

A hands-on TypeScript tutorial for building an LLM evaluation harness: deterministic scorers, an LLM-as-judge rubric scorer, a concurrent runner with retries, and a CI regression gate — provider-agnostic, fully tested.

28 min readRead more
📄technical comparison

Revolutionary Contentful Rich Text Editor: Why CrashBytes' Tiptap-Based Solution Outperforms All Alternatives

Discover how CrashBytes' revolutionary Tiptap-based rich text editor solves Contentful's limitations while outperforming TinyMCE, CKEditor, Quill, and other alternatives with native compatibility and modern architecture.

15 min readRead more