Skip to content
Guides

Playwright - Automated E2E Testing for Web Applications

Published on:
·7 min read·Author: MDS Software Solutions Group

Playwright Automated E2E

poradniki

Playwright - E2E Testing for Web Applications

End-to-end (E2E) testing is a crucial part of ensuring the quality of web applications. Playwright, created by Microsoft, has rapidly become one of the most popular browser automation frameworks. It offers multi-browser support, intelligent auto-waiting, powerful locators, and a rich set of developer tools. In this guide, we will show you how to fully leverage Playwright's capabilities in TypeScript-based projects.

What is Playwright?#

Playwright is a modern E2E test automation framework that allows you to test web applications across Chromium, Firefox, and WebKit browsers using a single API. Unlike many older tools, Playwright was built from the ground up for modern single-page applications, natively handling asynchronous operations, Shadow DOM, iframes, and multiple tabs.

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

test('homepage displays the correct title', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page).toHaveTitle(/Example/);
});

Installation is straightforward:

npm init playwright@latest

This command creates a complete project structure with a playwright.config.ts configuration file, a test directory, and a sample test to get you started.

Playwright vs Cypress vs Selenium#

Choosing an E2E testing tool can be challenging. Let's compare the three most popular solutions.

Selenium is the veteran of browser automation. It supports multiple programming languages and has a massive community. However, its WebDriver-based architecture often results in slow and flaky tests. Configuration is complex, and the API can feel unintuitive.

Cypress revolutionized E2E testing with an excellent Developer Experience. It runs directly inside the browser, providing fast feedback. Its limitations include support only for Chromium-based browsers (with experimental Firefox support), no multi-tab support, and a synchronous architecture that makes testing complex scenarios difficult.

Playwright combines the best of both worlds. It offers support for all major browsers, native async/await patterns, multi-tab testing, browser context isolation, and built-in developer tools. It is faster than Selenium and more flexible than Cypress.

// Playwright - multi-browser testing in a single config file
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  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'] } },
  ],
});

Multi-Browser Testing#

One of Playwright's greatest advantages is native multi-browser support. Each browser is downloaded automatically during installation and can be updated with a single command:

npx playwright install

Playwright supports three rendering engines: Chromium (Chrome, Edge), Firefox (Gecko), and WebKit (Safari). This means you can test your application on engines that cover over 95% of the browser market.

Tests running across multiple browsers are configured in playwright.config.ts by defining projects. Each project can have its own settings, such as viewport size, mobile device emulation, or geolocation.

export default defineConfig({
  projects: [
    {
      name: 'Desktop Chrome',
      use: {
        browserName: 'chromium',
        viewport: { width: 1920, height: 1080 },
      },
    },
    {
      name: 'Mobile Safari',
      use: {
        browserName: 'webkit',
        viewport: { width: 390, height: 844 },
        isMobile: true,
        hasTouch: true,
      },
    },
  ],
});

Auto-Waiting - Intelligent Element Synchronization#

One of the most common issues in E2E testing is flakiness caused by manually managing element waits. Playwright solves this with its auto-waiting mechanism, which automatically waits for elements to meet specific conditions before performing an action.

When you call page.click('button'), Playwright automatically waits until the button is visible, enabled, stable (not animating), not obscured by other elements, and ready to receive events. This drastically reduces the number of flaky tests.

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

  // Playwright automatically waits for fields to be ready
  await page.fill('#email', 'user@example.com');
  await page.fill('#password', 'securePassword123');
  await page.click('button[type="submit"]');

  // Auto-waiting also works with assertions
  await expect(page.locator('.dashboard')).toBeVisible();
  await expect(page).toHaveURL('/dashboard');
});

You don't need to add artificial delays or explicit waitFor calls in most cases. Playwright handles synchronization automatically.

Locators - Precise Element Selection#

Locators are the core of element interaction in Playwright. Unlike simple CSS selectors or XPath, Playwright locators are "lazy" - they don't query the element immediately but at the moment an action is performed. This makes them resilient to DOM changes.

test('product search', async ({ page }) => {
  await page.goto('/products');

  // ARIA role locator - best practice
  const searchInput = page.getByRole('searchbox', { name: 'Search products' });
  await searchInput.fill('laptop');

  // Text locator
  const submitButton = page.getByRole('button', { name: 'Search' });
  await submitButton.click();

  // Test ID locator - when other methods don't apply
  const resultsList = page.getByTestId('search-results');
  await expect(resultsList).toBeVisible();

  // Chaining locators - filtering
  const firstProduct = page.getByRole('listitem')
    .filter({ hasText: 'Laptop Pro' })
    .first();
  await expect(firstProduct).toContainText('Laptop Pro');

  // Placeholder locator
  const filterInput = page.getByPlaceholder('Filter results...');
  await filterInput.fill('16GB RAM');
});

Playwright recommends using locators based on ARIA roles (getByRole), labels (getByLabel), text (getByText), and test attributes (getByTestId). This approach makes tests more resilient to HTML structure changes and simultaneously verifies your application's accessibility.

Assertions - Verifying Application State#

Playwright provides a rich set of assertions that automatically retry until the condition is met or the timeout expires. These "web-first assertions" eliminate the problem of false negative results.

test('shopping cart', async ({ page }) => {
  await page.goto('/cart');

  // Element assertions
  const cartHeader = page.getByRole('heading', { name: 'Cart' });
  await expect(cartHeader).toBeVisible();
  await expect(cartHeader).toHaveText('Cart (3 items)');

  // List assertions
  const items = page.getByRole('listitem');
  await expect(items).toHaveCount(3);

  // CSS attribute assertions
  const totalPrice = page.getByTestId('total-price');
  await expect(totalPrice).toHaveCSS('font-weight', '700');
  await expect(totalPrice).toContainText('USD');

  // Page assertions
  await expect(page).toHaveURL(/\/cart/);
  await expect(page).toHaveTitle('Cart - My Store');

  // Negation assertions
  const emptyMessage = page.getByText('Your cart is empty');
  await expect(emptyMessage).not.toBeVisible();

  // Input value assertions
  const quantityInput = page.getByLabel('Quantity').first();
  await expect(quantityInput).toHaveValue('1');
});

Page Object Model - Test Organization Pattern#

The Page Object Model (POM) is a design pattern that encapsulates page interactions in dedicated classes. This makes tests more readable, easier to maintain, and resilient to UI changes.

// pages/login.page.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 expectSuccessRedirect() {
    await expect(this.page).toHaveURL('/dashboard');
  }
}

// tests/login.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from '../pages/login.page';

test.describe('Login', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test('successful login', async () => {
    await loginPage.login('user@example.com', 'password123');
    await loginPage.expectSuccessRedirect();
  });

  test('wrong password shows error message', async () => {
    await loginPage.login('user@example.com', 'wrong');
    await loginPage.expectError('Invalid credentials');
  });
});

Fixtures - Managing Test State#

Fixtures in Playwright allow you to configure and share resources between tests. You can create custom fixtures that automatically initialize Page Objects, log in users, prepare test data, or configure mocks.

// fixtures/base.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';

type MyFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  authenticatedPage: DashboardPage;
};

export const test = base.extend<MyFixtures>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await use(loginPage);
  },

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

  authenticatedPage: async ({ page }, use) => {
    // Automatic login before each test
    await page.goto('/login');
    await page.getByLabel('Email').fill('admin@example.com');
    await page.getByLabel('Password').fill('admin123');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');

    const dashboardPage = new DashboardPage(page);
    await use(dashboardPage);
  },
});

export { expect } from '@playwright/test';

// tests/dashboard.spec.ts
import { test, expect } from '../fixtures/base.fixture';

test('logged-in user sees dashboard', async ({ authenticatedPage }) => {
  await expect(authenticatedPage.welcomeMessage).toContainText('Welcome');
});

The authenticatedPage fixture automatically logs the user in before each test that uses it. This eliminates code duplication and ensures a consistent initial state.

API Testing#

Playwright is not limited to UI testing. The built-in APIRequestContext lets you make HTTP requests, which is useful for preparing test data, verifying backend state, or testing the API itself.

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

test.describe('Products API', () => {
  test('GET /api/products returns a list of products', async ({ request }) => {
    const response = await request.get('/api/products');

    expect(response.status()).toBe(200);

    const products = await response.json();
    expect(products).toHaveLength(10);
    expect(products[0]).toHaveProperty('name');
    expect(products[0]).toHaveProperty('price');
  });

  test('POST /api/products creates a new product', async ({ request }) => {
    const newProduct = {
      name: 'New Product',
      price: 99.99,
      category: 'electronics',
    };

    const response = await request.post('/api/products', {
      data: newProduct,
    });

    expect(response.status()).toBe(201);
    const created = await response.json();
    expect(created.name).toBe('New Product');
    expect(created.id).toBeDefined();
  });

  test('prepare data via API, verify in UI', async ({ page, request }) => {
    // Create product via API
    const response = await request.post('/api/products', {
      data: { name: 'Test Laptop', price: 4999.00, category: 'laptops' },
    });
    const product = await response.json();

    // Verify in the user interface
    await page.goto(`/products/${product.id}`);
    await expect(page.getByRole('heading')).toContainText('Test Laptop');
    await expect(page.getByTestId('price')).toContainText('$4,999.00');
  });
});

Visual Comparisons#

Playwright offers built-in visual comparison (snapshot testing) that detects unintended changes in page appearance. On the first run, it creates reference screenshots, and on subsequent runs, it compares them with the current state.

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

test('homepage appearance', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage.png');
});

test('product card component appearance', async ({ page }) => {
  await page.goto('/products');

  const productCard = page.getByTestId('product-card').first();
  await expect(productCard).toHaveScreenshot('product-card.png', {
    maxDiffPixelRatio: 0.05, // 5% tolerance
  });
});

test('responsive appearance on mobile', async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 812 });
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage-mobile.png');
});

test('comparison with masks for dynamic content', async ({ page }) => {
  await page.goto('/dashboard');

  await expect(page).toHaveScreenshot('dashboard.png', {
    mask: [
      page.getByTestId('current-date'),
      page.getByTestId('random-banner'),
    ],
  });
});

To update reference screenshots after intentional UI changes, simply run:

npx playwright test --update-snapshots

Trace Viewer - Debugging Tests#

Trace Viewer is one of Playwright's most powerful tools. It records a complete trace of test execution, including screenshots at every step, DOM snapshots, network logs, and console output. This makes debugging even the most complex scenarios intuitive.

// playwright.config.ts
export default defineConfig({
  use: {
    // Record trace only on failures
    trace: 'on-first-retry',

    // Or record always (useful in CI)
    // trace: 'on',
  },
});

After running tests with trace recording, the .zip trace file can be opened in the browser:

npx playwright show-trace trace.zip

Trace Viewer displays a chronological list of actions with corresponding screenshots, allows you to inspect DOM state before and after each action, shows network request details, and displays console logs. It is an invaluable tool for analyzing failures in CI environments where you don't have direct browser access.

Test Generator (Codegen)#

Playwright Codegen is an interactive test generator that records your browser interactions and automatically generates test code. It is an excellent way to quickly create test scaffolds.

npx playwright codegen https://example.com

Running this command opens a browser with an inspector tool. Every click, text input, or navigation is automatically translated into Playwright code. The generator intelligently selects locators, preferring ARIA roles and test attributes.

Codegen also supports generating code for recording authentication state:

npx playwright codegen --save-storage=auth.json https://example.com/login

The recorded authentication data can then be reused in tests, eliminating the need to log in for every test:

export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'tests',
      dependencies: ['setup'],
      use: {
        storageState: 'playwright/.auth/user.json',
      },
    },
  ],
});

CI/CD Integration#

Playwright integrates seamlessly with popular CI/CD systems. The official documentation provides ready-made configurations for GitHub Actions, GitLab CI, Azure Pipelines, and many more.

# .github/workflows/e2e-tests.yml
name: E2E Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - 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: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

The key option in CI is --with-deps, which installs system dependencies required by browsers. This eliminates the need to manually configure the environment.

Parallel Test Execution#

Playwright runs test files in parallel by default, using multiple worker processes. Tests within a single file run sequentially unless you explicitly configure otherwise.

// playwright.config.ts
export default defineConfig({
  // Number of parallel workers
  workers: process.env.CI ? 2 : undefined, // Limit in CI, auto locally

  // Full parallelism - even tests within a single file
  fullyParallel: true,

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

  // Timeout per individual test
  timeout: 30_000,
});

For tests to run in parallel, they must be independent of each other. Each test should operate on its own data and not rely on state left by other tests. Playwright facilitates this through browser context isolation - each test receives a fresh context by default.

test.describe('parallel tests', () => {
  test.describe.configure({ mode: 'parallel' });

  test('test A', async ({ page }) => {
    // Runs in parallel with test B
    await page.goto('/feature-a');
    await expect(page.getByRole('heading')).toContainText('Feature A');
  });

  test('test B', async ({ page }) => {
    // Runs in parallel with test A
    await page.goto('/feature-b');
    await expect(page.getByRole('heading')).toContainText('Feature B');
  });
});

Reporting#

Playwright offers several built-in reporters and the ability to create custom ones. The most popular is the HTML reporter, which generates an interactive report with test results.

// playwright.config.ts
export default defineConfig({
  reporter: [
    // HTML reporter - interactive report
    ['html', { outputFolder: 'playwright-report', open: 'never' }],

    // Console reporter - test list
    ['list'],

    // JUnit - CI/CD integration
    ['junit', { outputFile: 'results/junit.xml' }],

    // JSON - for further processing
    ['json', { outputFile: 'results/results.json' }],
  ],
});

After running tests, the HTML report can be opened with:

npx playwright show-report

The HTML report contains detailed information about each test: execution time, screenshots, logs, traces, and error details. It is an ideal tool for analyzing results in CI/CD and sharing them with your team.

Best Practices#

Let's wrap up with the most important best practices when working with Playwright:

  1. Use ARIA role-based locators - getByRole, getByLabel, getByText instead of CSS selectors.
  2. Don't add artificial delays - rely on auto-waiting and web-first assertions.
  3. Isolate tests - each test should be independent and run in a clean context.
  4. Use the Page Object Model - encapsulate page logic in dedicated classes.
  5. Prepare data via API - use the request fixture to quickly create test data.
  6. Record traces in CI - configure trace: 'on-first-retry' for debugging failures.
  7. Run tests in parallel - design tests as independent to benefit from full parallelism.
  8. Tag your tests - use test.describe and tags to organize test suites.

Conclusion#

Playwright is a powerful and mature tool that significantly elevates the quality of web application testing. With multi-browser support, intelligent auto-waiting, powerful locators, built-in Trace Viewer, and a test generator, it enables you to write stable and maintainable E2E tests. CI/CD integration, parallel execution, and rich reporting make Playwright a perfect fit for modern software development workflows.


Need help implementing E2E tests in your project? MDS Software Solutions Group specializes in building comprehensive testing strategies, QA automation, and CI/CD pipeline integration. Contact us to learn how we can help your team deliver top-quality software.

Author
MDS Software Solutions Group

Team of programming experts specializing in modern web technologies.

Playwright - Automated E2E Testing for Web Applications | MDS Software Solutions Group | MDS Software Solutions Group