Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds
Volver a colecciones
Guide12 min read

Playwright

Playwright is a browser automation framework for E2E testing from Microsoft. Complete guide - selectors, assertions, visual testing, API testing, and CI/CD.

Playwright - Complete guide to E2E testing

What is Playwright?

Playwright is a modern browser automation and end-to-end testing framework created by Microsoft. It supports all major browsers (Chromium, Firefox, WebKit) with a single consistent API, offering fast, reliable, and cross-browser testing of web applications.

Unlike older tools such as Selenium, Playwright was designed from the ground up with modern applications in mind - it handles auto-waiting, web components, shadow DOM, iframes, and many other challenging scenarios without additional configuration.

Why Playwright?

Key advantages

  1. Cross-browser - Chromium, Firefox, WebKit with a single API
  2. Auto-waiting - Automatic waiting for elements
  3. Speed - Parallel test execution
  4. Reliability - No flaky tests thanks to smart retries
  5. Codegen - Recording tests through interaction
  6. Trace Viewer - Debugging with full context
  7. API Testing - Built-in REST API testing

Playwright vs Cypress vs Selenium

FeaturePlaywrightCypressSelenium
BrowsersChromium, Firefox, WebKitChromium, Firefox (beta)All
SpeedVery fastFastSlow
Auto-waitingBuilt-inBuilt-inManual
ParallelNativePaid (Cloud)Requires Grid
iframesFull supportLimitedFull
Mobile emulationYesNoVia Appium
API testingBuilt-inPluginExternal
LanguagesJS/TS, Python, .NET, JavaJS/TS onlyAll

Installation and configuration

New project

Code
Bash
# Initialize a Playwright project
npm init playwright@latest

# Answer the prompts:
# - TypeScript or JavaScript
# - Test folder (tests/)
# - Add GitHub Actions workflow?
# - Install browsers?

Project structure

Code
TEXT
my-project/
├── tests/
│   ├── example.spec.ts
│   └── auth.spec.ts
├── playwright.config.ts
├── package.json
└── .github/
    └── workflows/
        └── playwright.yml

Configuring playwright.config.ts

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

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

  // Run tests in parallel
  fullyParallel: true,

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

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

  // Workers - parallel processes
  workers: process.env.CI ? 1 : undefined,

  // Reporter
  reporter: [
    ['html'],
    ['list'],
    process.env.CI ? ['github'] : ['line']
  ],

  // Global test settings
  use: {
    // Base URL
    baseURL: 'http://localhost:3000',

    // Trace on failure
    trace: 'on-first-retry',

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

    // Video on failure
    video: 'on-first-retry',
  },

  // Projects = browser configurations
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    // Mobile
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    },
  ],

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

Writing tests

Basic structure

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

test.describe('Homepage', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/')
  })

  test('has correct title', async ({ page }) => {
    await expect(page).toHaveTitle(/My App/)
  })

  test('navigation works', async ({ page }) => {
    await page.getByRole('link', { name: 'About' }).click()
    await expect(page).toHaveURL('/about')
  })
})

Locators - finding elements

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

test('locators examples', async ({ page }) => {
  await page.goto('/form')

  // ✅ Recommended - semantic locators
  // Role-based (accessibility)
  await page.getByRole('button', { name: 'Submit' }).click()
  await page.getByRole('link', { name: 'Home' }).click()
  await page.getByRole('heading', { name: 'Welcome' })
  await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com')
  await page.getByRole('checkbox', { name: 'Accept terms' }).check()

  // Label-based
  await page.getByLabel('Email').fill('test@example.com')
  await page.getByLabel('Password').fill('secret123')

  // Placeholder
  await page.getByPlaceholder('Enter your email').fill('test@example.com')

  // Text content
  await page.getByText('Welcome back')
  await page.getByText(/welcome/i) // Regex, case-insensitive

  // Test ID (for complex cases)
  await page.getByTestId('submit-button').click()

  // ⚠️ Fallback - CSS/XPath (less stable)
  await page.locator('.submit-btn').click()
  await page.locator('#email-input').fill('test@example.com')
  await page.locator('button[type="submit"]').click()
  await page.locator('//button[text()="Submit"]').click() // XPath
})

Chaining locators

Code
TypeScript
test('chained locators', async ({ page }) => {
  // Find an element within the context of another
  const form = page.locator('form#login')
  await form.getByLabel('Email').fill('test@example.com')
  await form.getByLabel('Password').fill('secret')
  await form.getByRole('button', { name: 'Login' }).click()

  // Filtering
  const rows = page.getByRole('row')
  const activeRow = rows.filter({ hasText: 'Active' })
  await activeRow.getByRole('button', { name: 'Edit' }).click()

  // Nth element
  await page.getByRole('listitem').nth(2).click() // Third element
  await page.getByRole('listitem').first().click()
  await page.getByRole('listitem').last().click()
})

Assertions

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

test('assertions examples', async ({ page }) => {
  await page.goto('/dashboard')

  // Page assertions
  await expect(page).toHaveTitle('Dashboard')
  await expect(page).toHaveURL(/dashboard/)
  await expect(page).toHaveURL('http://localhost:3000/dashboard')

  // Element visibility
  await expect(page.getByText('Welcome')).toBeVisible()
  await expect(page.getByText('Error')).toBeHidden()
  await expect(page.getByTestId('loading')).not.toBeVisible()

  // Element state
  await expect(page.getByRole('button')).toBeEnabled()
  await expect(page.getByRole('button')).toBeDisabled()
  await expect(page.getByRole('checkbox')).toBeChecked()
  await expect(page.getByRole('textbox')).toBeEditable()
  await expect(page.getByRole('textbox')).toBeFocused()

  // Element content
  await expect(page.getByRole('heading')).toHaveText('Dashboard')
  await expect(page.getByRole('heading')).toContainText('Dash')
  await expect(page.getByRole('textbox')).toHaveValue('initial value')
  await expect(page.getByRole('textbox')).toBeEmpty()

  // Element attributes
  await expect(page.getByRole('link')).toHaveAttribute('href', '/about')
  await expect(page.getByRole('img')).toHaveAttribute('alt', /logo/i)

  // CSS
  await expect(page.getByTestId('box')).toHaveCSS('color', 'rgb(0, 0, 0)')
  await expect(page.getByRole('button')).toHaveClass(/primary/)

  // Count
  await expect(page.getByRole('listitem')).toHaveCount(5)

  // Soft assertions (do not stop the test)
  await expect.soft(page.getByText('Optional')).toBeVisible()
})

Interactions

Code
TypeScript
test('interactions', async ({ page }) => {
  await page.goto('/form')

  // Clicking
  await page.getByRole('button').click()
  await page.getByRole('button').click({ button: 'right' }) // Right click
  await page.getByRole('button').dblclick() // Double click
  await page.getByRole('button').click({ modifiers: ['Control'] }) // Ctrl+click

  // Hover
  await page.getByText('Menu').hover()

  // Filling forms
  await page.getByLabel('Email').fill('test@example.com')
  await page.getByLabel('Email').clear()
  await page.getByLabel('Email').type('test@example.com') // Keystroke by keystroke

  // Press keys
  await page.getByLabel('Search').press('Enter')
  await page.keyboard.press('Escape')
  await page.keyboard.press('Control+A')

  // Select dropdown
  await page.getByLabel('Country').selectOption('poland')
  await page.getByLabel('Country').selectOption({ label: 'Poland' })
  await page.getByLabel('Multi').selectOption(['opt1', 'opt2'])

  // Checkbox & radio
  await page.getByLabel('Accept').check()
  await page.getByLabel('Accept').uncheck()
  await page.getByLabel('Accept').setChecked(true)

  // File upload
  await page.getByLabel('Upload').setInputFiles('path/to/file.pdf')
  await page.getByLabel('Upload').setInputFiles(['file1.pdf', 'file2.pdf'])

  // Drag and drop
  await page.getByTestId('source').dragTo(page.getByTestId('target'))

  // Focus
  await page.getByLabel('Email').focus()
  await page.getByLabel('Email').blur()
})

Waiting and timeouts

Code
TypeScript
test('waiting', async ({ page }) => {
  await page.goto('/dashboard')

  // Auto-waiting - Playwright automatically waits for elements
  // The following by itself waits until the element is visible and clickable
  await page.getByRole('button').click()

  // Explicit waits
  await page.waitForSelector('.loaded')
  await page.waitForLoadState('networkidle')
  await page.waitForLoadState('domcontentloaded')
  await page.waitForURL('/dashboard')

  // Wait for response
  const responsePromise = page.waitForResponse('**/api/data')
  await page.getByRole('button', { name: 'Load' }).click()
  const response = await responsePromise

  // Wait for request
  const requestPromise = page.waitForRequest('**/api/submit')
  await page.getByRole('button', { name: 'Submit' }).click()
  await requestPromise

  // Custom wait
  await page.waitForFunction(() => {
    return document.querySelector('.counter')?.textContent === '10'
  })

  // Timeout override
  await page.getByRole('button').click({ timeout: 10000 })

  // Poll until condition
  await expect(async () => {
    const count = await page.getByTestId('count').textContent()
    expect(parseInt(count!)).toBeGreaterThan(5)
  }).toPass({ timeout: 10000 })
})

Advanced scenarios

Authentication

TStests/auth.setup.ts
TypeScript
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test'

const authFile = 'playwright/.auth/user.json'

setup('authenticate', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('Email').fill('user@example.com')
  await page.getByLabel('Password').fill('password123')
  await page.getByRole('button', { name: 'Sign in' }).click()

  // Wait for redirect after login
  await page.waitForURL('/dashboard')

  // Save authentication state
  await page.context().storageState({ path: authFile })
})
TSplaywright.config.ts
TypeScript
// playwright.config.ts
export default defineConfig({
  projects: [
    // Setup project
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    // Tests that need auth
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
})
TStests/dashboard.spec.ts
TypeScript
// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test'

// This test uses the logged-in state
test('dashboard shows user data', async ({ page }) => {
  await page.goto('/dashboard')
  await expect(page.getByText('Welcome, User')).toBeVisible()
})

Multiple contexts (multi-user)

Code
TypeScript
test('admin and user interaction', async ({ browser }) => {
  // Admin context
  const adminContext = await browser.newContext({
    storageState: 'playwright/.auth/admin.json'
  })
  const adminPage = await adminContext.newPage()

  // User context
  const userContext = await browser.newContext({
    storageState: 'playwright/.auth/user.json'
  })
  const userPage = await userContext.newPage()

  // Admin creates something
  await adminPage.goto('/admin/posts')
  await adminPage.getByRole('button', { name: 'New Post' }).click()
  await adminPage.getByLabel('Title').fill('Test Post')
  await adminPage.getByRole('button', { name: 'Publish' }).click()

  // User sees it
  await userPage.goto('/posts')
  await expect(userPage.getByText('Test Post')).toBeVisible()

  // Cleanup
  await adminContext.close()
  await userContext.close()
})

API Testing

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

test.describe('API Tests', () => {
  test('GET /api/users', async ({ request }) => {
    const response = await request.get('/api/users')

    expect(response.ok()).toBeTruthy()
    expect(response.status()).toBe(200)

    const users = await response.json()
    expect(users).toHaveLength(10)
    expect(users[0]).toHaveProperty('email')
  })

  test('POST /api/users', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: {
        name: 'John Doe',
        email: 'john@example.com'
      }
    })

    expect(response.status()).toBe(201)

    const user = await response.json()
    expect(user.name).toBe('John Doe')
  })

  test('with authentication', async ({ request }) => {
    const response = await request.get('/api/me', {
      headers: {
        'Authorization': `Bearer ${process.env.API_TOKEN}`
      }
    })

    expect(response.ok()).toBeTruthy()
  })
})

Visual regression testing

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

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

  // Full page screenshot
  await expect(page).toHaveScreenshot('dashboard.png')

  // Element screenshot
  const chart = page.getByTestId('revenue-chart')
  await expect(chart).toHaveScreenshot('revenue-chart.png')

  // With options
  await expect(page).toHaveScreenshot('dashboard-full.png', {
    fullPage: true,
    maxDiffPixels: 100,
    threshold: 0.2,
  })
})
Code
Bash
# Update baseline screenshots
npx playwright test --update-snapshots

Mocking and intercepting

Code
TypeScript
test('mock API responses', async ({ page }) => {
  // Mock a specific endpoint
  await page.route('**/api/users', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Mock User' }
      ])
    })
  })

  await page.goto('/users')
  await expect(page.getByText('Mock User')).toBeVisible()
})

test('modify response', async ({ page }) => {
  await page.route('**/api/products', async route => {
    const response = await route.fetch()
    const json = await response.json()

    // Modify response
    json.products = json.products.slice(0, 3)

    await route.fulfill({ response, json })
  })

  await page.goto('/products')
})

test('abort requests', async ({ page }) => {
  // Block images
  await page.route('**/*.{png,jpg,jpeg}', route => route.abort())

  // Block analytics
  await page.route('**/analytics/**', route => route.abort())

  await page.goto('/page')
})

test('delay response', async ({ page }) => {
  await page.route('**/api/slow', async route => {
    await new Promise(resolve => setTimeout(resolve, 3000))
    await route.continue()
  })

  await page.goto('/page')
  // Test loading state
  await expect(page.getByText('Loading...')).toBeVisible()
})

Fixtures (custom setup)

TSfixtures.ts
TypeScript
// fixtures.ts
import { test as base, expect } from '@playwright/test'

// Extend base test with custom fixtures
export const test = base.extend<{
  adminPage: Page
  userPage: Page
  todoApp: TodoApp
}>({
  // Admin page fixture
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: 'playwright/.auth/admin.json'
    })
    const page = await context.newPage()
    await use(page)
    await context.close()
  },

  // User page fixture
  userPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: 'playwright/.auth/user.json'
    })
    const page = await context.newPage()
    await use(page)
    await context.close()
  },

  // Page Object fixture
  todoApp: async ({ page }, use) => {
    const todoApp = new TodoApp(page)
    await todoApp.goto()
    await use(todoApp)
  },
})

export { expect }

// Page Object Model
class TodoApp {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/todos')
  }

  async addTodo(text: string) {
    await this.page.getByPlaceholder('What needs to be done?').fill(text)
    await this.page.keyboard.press('Enter')
  }

  async toggleTodo(text: string) {
    await this.page.getByRole('listitem')
      .filter({ hasText: text })
      .getByRole('checkbox')
      .click()
  }

  async deleteTodo(text: string) {
    await this.page.getByRole('listitem')
      .filter({ hasText: text })
      .hover()
    await this.page.getByRole('button', { name: 'Delete' }).click()
  }

  async getTodoCount() {
    return await this.page.getByRole('listitem').count()
  }
}
TStests/todo.spec.ts
TypeScript
// tests/todo.spec.ts
import { test, expect } from '../fixtures'

test('can add and complete todos', async ({ todoApp }) => {
  await todoApp.addTodo('Buy groceries')
  await todoApp.addTodo('Do laundry')

  expect(await todoApp.getTodoCount()).toBe(2)

  await todoApp.toggleTodo('Buy groceries')
  await todoApp.deleteTodo('Do laundry')

  expect(await todoApp.getTodoCount()).toBe(1)
})

Running tests

Basic commands

Code
Bash
# Run all tests
npx playwright test

# Specific file
npx playwright test tests/login.spec.ts

# Specific test (grep)
npx playwright test -g "login works"

# Specific browser
npx playwright test --project=chromium
npx playwright test --project=firefox

# Headed mode (visible browser)
npx playwright test --headed

# Debug mode
npx playwright test --debug

# UI mode (interactive)
npx playwright test --ui

# Only failed tests
npx playwright test --last-failed

# Repeat test X times
npx playwright test --repeat-each=3

Reports

Code
Bash
# HTML report
npx playwright show-report

# Trace viewer
npx playwright show-trace trace.zip

Codegen - recording tests

Code
Bash
# Record interactions and generate code
npx playwright codegen https://example.com

# With device emulation
npx playwright codegen --device="iPhone 13"

# With viewport
npx playwright codegen --viewport-size=800,600

# Save to file
npx playwright codegen -o tests/recorded.spec.ts

CI/CD Integration

GitHub Actions

.github/workflows/playwright.yml
YAML
# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main, master]
  pull_request:
    branches: [main, master]

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

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 18

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright Browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        run: npx playwright test

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

Sharding (parallel CI)

Code
YAML
jobs:
  test:
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]

    steps:
      - name: Run tests
        run: npx playwright test --shard=${{ matrix.shard }}

Best practices

Stable selectors

Code
TypeScript
// ✅ Good - semantic, stable
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Email')
page.getByTestId('login-form')

// ❌ Avoid - brittle, implementation-dependent
page.locator('.btn-primary')
page.locator('#submit-btn')
page.locator('div > button:nth-child(2)')

Test isolation

Code
TypeScript
// ✅ Each test should be independent
test.beforeEach(async ({ page }) => {
  // Reset state before each test
  await page.goto('/')
})

// ❌ Avoid dependencies between tests
// Test A should not rely on state from Test B

Test ID for difficult elements

Code
HTML
<!-- In the application -->
<button data-testid="checkout-button">Complete Purchase</button>
Code
TypeScript
// In the test
await page.getByTestId('checkout-button').click()

FAQ - Frequently asked questions

How to debug failed tests?

  1. Run with --debug: npx playwright test --debug
  2. Use Trace Viewer to analyze trace.zip
  3. Add await page.pause() in the test code
  4. Use UI mode: npx playwright test --ui

How to test responsiveness?

Code
TypeScript
test('mobile layout', async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 667 })
  await page.goto('/')
  await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible()
})

How to handle a popup/dialog?

Code
TypeScript
page.on('dialog', dialog => dialog.accept())
await page.getByRole('button', { name: 'Delete' }).click()

How to test a new window/tab?

Code
TypeScript
const [newPage] = await Promise.all([
  page.waitForEvent('popup'),
  page.getByRole('link', { name: 'Open in new tab' }).click()
])
await expect(newPage).toHaveURL('/new-page')

Summary

Playwright is a powerful E2E testing tool offering:

  • Cross-browser - One test, all browsers
  • Auto-waiting - No more flaky tests
  • Speed - Parallel execution
  • Debugging - Trace viewer, UI mode, codegen
  • Flexibility - API testing, visual testing, mocking

Ideal for teams looking for reliable, fast E2E tests.