Testing

E2E Testing with Playwright: Complete Tutorial

A complete beginner-to-proficient tutorial on E2E testing with Playwright, covering setup, page objects, locators, API mocking, authentication reuse, visual testing, and CI/CD integration.

E2E Testing with Playwright: Complete Tutorial

Overview

End-to-end testing validates your application from the user's perspective -- clicking buttons, filling forms, navigating pages, and verifying that everything works together. Playwright is Microsoft's open-source E2E testing framework, and after using Selenium for years and Cypress for a few more, I can tell you it is the best option available right now. This tutorial takes you from zero to a production-ready test suite with page objects, API mocking, authentication reuse, and CI/CD integration.

Prerequisites

  • Node.js 18+ installed
  • A working Express.js application (or any web app -- the concepts translate)
  • Basic familiarity with HTML selectors and the DOM
  • A terminal and a code editor

Why Playwright Over Cypress and Selenium

This is an opinionated section, and I have opinions.

Selenium was the standard for over a decade. It works. It also requires a WebDriver binary, has flaky waits, and makes you write explicit sleep statements that you will regret. The API is verbose and the debugging experience is painful.

Cypress improved on Selenium significantly. It runs inside the browser, has a beautiful test runner, and introduced automatic waiting. But it only supports Chromium (they added Firefox and WebKit later, but it is still Chromium-first). It cannot handle multiple tabs or browser contexts. And the free tier limitations on Cypress Cloud push you toward a paid product.

Playwright gives you:

  • True cross-browser testing: Chromium, Firefox, and WebKit from one API
  • Multiple browser contexts and tabs in a single test
  • Auto-waiting that actually works -- no cy.wait(5000) hacks
  • Built-in API mocking via route handlers
  • Authentication state reuse with storageState
  • A trace viewer that is the best debugging tool I have ever used in testing
  • Parallel execution out of the box
  • No vendor lock-in, no paid tiers for core features

I switched my teams to Playwright in 2023 and have not looked back.


Installation and Project Setup

Start by initializing Playwright in your project:

npm init playwright@latest

The CLI walks you through setup. Pick JavaScript (not TypeScript, for this tutorial), put tests in a tests folder, and say yes to installing browsers. When it finishes, you get this structure:

project/
  tests/
    example.spec.js
  playwright.config.js
  package.json

Playwright installs its own browser binaries. This is intentional -- it guarantees consistent behavior across environments. If you need to install browsers separately (CI environments, for example):

npx playwright install

To install system dependencies on Linux (Ubuntu/Debian CI runners):

npx playwright install-deps

Your package.json should now include:

{
  "devDependencies": {
    "@playwright/test": "^1.42.0"
  },
  "scripts": {
    "test:e2e": "npx playwright test",
    "test:e2e:headed": "npx playwright test --headed",
    "test:e2e:debug": "npx playwright test --debug"
  }
}

Writing Your First Test

Delete the example spec and create tests/homepage.spec.js:

var { test, expect } = require('@playwright/test');

test('homepage loads and displays the correct title', function() {
    return async function({ page }) {
        await page.goto('http://localhost:8080');
        await expect(page).toHaveTitle(/My Application/);
        await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
    }();
});

Actually, let me correct that. Playwright tests use async functions directly. Here is the proper pattern with CommonJS and var:

var { test, expect } = require('@playwright/test');

test('homepage loads and displays the correct title', async function({ page }) {
    await page.goto('http://localhost:8080');

    await expect(page).toHaveTitle(/My Application/);
    await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

test('navigation links work', async function({ page }) {
    await page.goto('http://localhost:8080');

    await page.getByRole('link', { name: 'About' }).click();
    await expect(page).toHaveURL(/.*about/);
    await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
});

Run it:

npx playwright test tests/homepage.spec.js

Output:

Running 2 tests using 2 workers
  2 passed (3.2s)

Notice it ran two tests in parallel using two workers. That is the default behavior.


Locator Strategies

This is where most people go wrong. Playwright gives you semantic locators that are resilient to UI changes. Use them in this order of preference:

1. getByRole -- The Gold Standard

// Buttons
page.getByRole('button', { name: 'Submit' })

// Links
page.getByRole('link', { name: 'Sign In' })

// Headings
page.getByRole('heading', { name: 'Dashboard', level: 2 })

// Text inputs
page.getByRole('textbox', { name: 'Email' })

// Checkboxes
page.getByRole('checkbox', { name: 'Remember me' })

getByRole mirrors how screen readers and assistive technology find elements. If your tests break because getByRole cannot find an element, that is an accessibility bug in your application, not a test problem.

2. getByText -- For Static Content

page.getByText('No results found')
page.getByText('Welcome back, Shane')
page.getByText(/total: \$\d+\.\d{2}/i)

3. getByLabel -- For Form Fields

page.getByLabel('Email address')
page.getByLabel('Password')
page.getByLabel(/date of birth/i)

4. getByPlaceholder -- When Labels Are Missing

page.getByPlaceholder('Search...')
page.getByPlaceholder('Enter your email')

5. getByTestId -- The Escape Hatch

page.getByTestId('user-avatar')
page.getByTestId('cart-count')

This requires adding data-testid attributes to your HTML. It is stable and reliable, but it couples your tests to test-specific markup. Use it when semantic locators genuinely do not work.

6. CSS Selectors -- Last Resort

page.locator('.legacy-widget > .inner-container')
page.locator('#app-root div.card:nth-child(3)')

CSS selectors are brittle. They break when a designer changes a class name, when someone wraps an element in a new div, or when the component order changes. I use them only for legacy applications where adding data-testid attributes is not an option.


Assertions and Auto-Waiting

Playwright assertions automatically retry until the condition is met or the timeout expires. You never write await page.waitForSelector(...) before an assertion.

// These all auto-wait
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
await expect(page.getByRole('alert')).toContainText('Saved successfully');
await expect(page.getByTestId('loading-spinner')).not.toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page).toHaveURL('/dashboard');

The default timeout is 5 seconds. You can adjust it per-assertion:

await expect(page.getByText('Processing complete')).toBeVisible({ timeout: 15000 });

Or globally in playwright.config.js:

module.exports = {
    expect: {
        timeout: 10000
    }
};

Page Object Model Pattern

For any test suite beyond a handful of tests, page objects are non-negotiable. They centralize your locators and actions so that when the UI changes, you update one file instead of fifty.

Create tests/pages/LoginPage.js:

var { expect } = require('@playwright/test');

function LoginPage(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');
    this.rememberMe = page.getByRole('checkbox', { name: 'Remember me' });
}

LoginPage.prototype.goto = async function() {
    await this.page.goto('/login');
};

LoginPage.prototype.login = async function(email, password) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
};

LoginPage.prototype.loginAndVerify = async function(email, password) {
    await this.login(email, password);
    await expect(this.page).toHaveURL(/.*dashboard/);
};

LoginPage.prototype.expectError = async function(message) {
    await expect(this.errorMessage).toContainText(message);
};

module.exports = { LoginPage: LoginPage };

Create tests/pages/DashboardPage.js:

var { expect } = require('@playwright/test');

function DashboardPage(page) {
    this.page = page;
    this.welcomeHeading = page.getByRole('heading', { name: /welcome/i });
    this.userMenu = page.getByRole('button', { name: 'User menu' });
    this.logoutLink = page.getByRole('menuitem', { name: 'Log out' });
    this.searchInput = page.getByRole('searchbox');
    this.notificationBadge = page.getByTestId('notification-count');
}

DashboardPage.prototype.expectLoaded = async function() {
    await expect(this.welcomeHeading).toBeVisible();
};

DashboardPage.prototype.search = async function(query) {
    await this.searchInput.fill(query);
    await this.searchInput.press('Enter');
};

DashboardPage.prototype.logout = async function() {
    await this.userMenu.click();
    await this.logoutLink.click();
};

DashboardPage.prototype.getNotificationCount = async function() {
    var text = await this.notificationBadge.textContent();
    return parseInt(text, 10);
};

module.exports = { DashboardPage: DashboardPage };

Use them in tests:

var { test, expect } = require('@playwright/test');
var { LoginPage } = require('./pages/LoginPage');
var { DashboardPage } = require('./pages/DashboardPage');

test.describe('Authentication', function() {
    test('successful login redirects to dashboard', async function({ page }) {
        var loginPage = new LoginPage(page);
        var dashboardPage = new DashboardPage(page);

        await loginPage.goto();
        await loginPage.loginAndVerify('[email protected]', 'securepassword');
        await dashboardPage.expectLoaded();
    });

    test('invalid credentials show error message', async function({ page }) {
        var loginPage = new LoginPage(page);

        await loginPage.goto();
        await loginPage.login('[email protected]', 'wrongpassword');
        await loginPage.expectError('Invalid email or password');
    });
});

Handling Forms and User Input

test('contact form submission', async function({ page }) {
    await page.goto('/contact');

    // Text inputs
    await page.getByLabel('Full Name').fill('Shane Mitchell');
    await page.getByLabel('Email').fill('[email protected]');

    // Textarea
    await page.getByLabel('Message').fill('I have a question about your API.');

    // Select dropdown
    await page.getByLabel('Subject').selectOption('technical-support');

    // Checkbox
    await page.getByRole('checkbox', { name: 'Subscribe to newsletter' }).check();

    // Radio button
    await page.getByRole('radio', { name: 'Phone' }).check();

    // File upload
    await page.getByLabel('Attachment').setInputFiles('tests/fixtures/document.pdf');

    // Submit
    await page.getByRole('button', { name: 'Send Message' }).click();

    // Verify success
    await expect(page.getByText('Thank you for your message')).toBeVisible();
});

Keyboard Input

// Type character by character (triggers keydown/keyup events)
await page.getByLabel('Search').pressSequentially('playwright testing', { delay: 50 });

// Keyboard shortcuts
await page.keyboard.press('Control+a');
await page.keyboard.press('Control+c');

// Tab between fields
await page.getByLabel('First Name').press('Tab');

Testing Navigation and Routing

test('breadcrumb navigation works', async function({ page }) {
    await page.goto('/articles/category/nodejs');

    // Click breadcrumb
    await page.getByRole('link', { name: 'Articles' }).click();
    await expect(page).toHaveURL('/articles');

    // Browser back button
    await page.goBack();
    await expect(page).toHaveURL('/articles/category/nodejs');

    // Browser forward button
    await page.goForward();
    await expect(page).toHaveURL('/articles');
});

test('404 page shows for invalid routes', async function({ page }) {
    var response = await page.goto('/this-page-does-not-exist');
    expect(response.status()).toBe(404);
    await expect(page.getByRole('heading', { name: 'Page Not Found' })).toBeVisible();
});

API Mocking with Route Handlers

This is one of Playwright's killer features. You can intercept network requests and return mock data without touching your backend.

test('displays user profile from API', async function({ page }) {
    // Mock the API response
    await page.route('**/api/users/me', async function(route) {
        await route.fulfill({
            status: 200,
            contentType: 'application/json',
            body: JSON.stringify({
                id: 1,
                name: 'Shane Mitchell',
                email: '[email protected]',
                role: 'admin'
            })
        });
    });

    await page.goto('/profile');
    await expect(page.getByText('Shane Mitchell')).toBeVisible();
    await expect(page.getByText('admin')).toBeVisible();
});

test('handles API errors gracefully', async function({ page }) {
    await page.route('**/api/users/me', async function(route) {
        await route.fulfill({
            status: 500,
            contentType: 'application/json',
            body: JSON.stringify({ error: 'Internal Server Error' })
        });
    });

    await page.goto('/profile');
    await expect(page.getByText('Something went wrong')).toBeVisible();
    await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

test('shows loading state while API is slow', async function({ page }) {
    await page.route('**/api/articles', async function(route) {
        // Delay response by 3 seconds
        await new Promise(function(resolve) { setTimeout(resolve, 3000); });
        await route.fulfill({
            status: 200,
            contentType: 'application/json',
            body: JSON.stringify({ articles: [] })
        });
    });

    await page.goto('/articles');
    await expect(page.getByTestId('loading-spinner')).toBeVisible();
    await expect(page.getByTestId('loading-spinner')).not.toBeVisible({ timeout: 10000 });
});

Modifying Real Responses

You can also intercept a real API call, modify the response, and pass it through:

test('injects feature flag into API response', async function({ page }) {
    await page.route('**/api/config', async function(route) {
        var response = await route.fetch();
        var json = await response.json();
        json.featureFlags.newDashboard = true;
        await route.fulfill({ response: response, body: JSON.stringify(json) });
    });

    await page.goto('/dashboard');
    await expect(page.getByTestId('new-dashboard-widget')).toBeVisible();
});

Authentication State Reuse with storageState

Logging in before every single test is slow and wasteful. Playwright lets you authenticate once and reuse that session state across tests.

Create tests/auth.setup.js:

var { test: setup } = require('@playwright/test');

var authFile = 'tests/.auth/user.json';

setup('authenticate', async function({ page }) {
    await page.goto('/login');
    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Password').fill('testpassword123');
    await page.getByRole('button', { name: 'Sign In' }).click();

    // Wait for redirect to confirm login succeeded
    await page.waitForURL('**/dashboard');

    // Save signed-in state
    await page.context().storageState({ path: authFile });
});

module.exports = { authFile: authFile };

Configure it in playwright.config.js:

module.exports = {
    projects: [
        {
            name: 'setup',
            testMatch: /.*\.setup\.js/
        },
        {
            name: 'chromium',
            use: {
                storageState: 'tests/.auth/user.json'
            },
            dependencies: ['setup']
        }
    ]
};

Now every test in the chromium project starts already logged in. The login only happens once per test run.

Add tests/.auth/ to your .gitignore -- these files contain session tokens.


Visual Comparison Testing

Playwright can capture screenshots and compare them against baselines. This catches unintended visual regressions.

test('dashboard matches visual snapshot', async function({ page }) {
    await page.goto('/dashboard');
    await expect(page).toHaveScreenshot('dashboard.png');
});

test('button hover state matches snapshot', async function({ page }) {
    await page.goto('/');
    var button = page.getByRole('button', { name: 'Get Started' });
    await button.hover();
    await expect(button).toHaveScreenshot('get-started-hover.png');
});

The first run creates baseline images in a __snapshots__ directory. Subsequent runs compare against them. To update baselines after intentional UI changes:

npx playwright test --update-snapshots

Set a pixel difference threshold for tests that involve dynamic content:

await expect(page).toHaveScreenshot('chart.png', { maxDiffPixelRatio: 0.05 });

Running Tests in Parallel

Playwright runs test files in parallel by default, using worker processes. Tests within a single file run sequentially unless you opt in to parallel mode.

var { test } = require('@playwright/test');

// Run tests in this file in parallel
test.describe.configure({ mode: 'parallel' });

test('test one', async function({ page }) {
    // ...
});

test('test two', async function({ page }) {
    // ...
});

Control parallelism in playwright.config.js:

module.exports = {
    // Number of parallel workers
    workers: process.env.CI ? 2 : undefined,

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

    // Limit failures before stopping
    maxFailures: process.env.CI ? 10 : undefined
};

On CI, I limit workers to 2. Locally, Playwright auto-detects CPU cores and uses half of them.


Debugging with Trace Viewer and Headed Mode

Headed Mode

Watch the browser as tests run:

npx playwright test --headed

Debug Mode

Step through tests one action at a time with the Playwright Inspector:

npx playwright test --debug

This opens a headed browser and pauses before each action. You can step forward, inspect locators, and evaluate expressions.

Trace Viewer

This is the best debugging tool Playwright offers. Enable it in your config:

module.exports = {
    use: {
        trace: 'on-first-retry'
    }
};

With on-first-retry, traces are only collected when a test fails and is retried. This keeps test runs fast but gives you full diagnostic data for failures.

After a failure, open the trace:

npx playwright show-trace test-results/homepage-test/trace.zip

The trace viewer shows:

  • Every action performed, with timing
  • A screenshot before and after each action
  • The DOM snapshot at each step
  • Network requests and responses
  • Console logs and errors

It is like having a DVR for your test. I have debugged intermittent CI failures in minutes with the trace viewer that would have taken hours with console logs.


CI/CD Integration

GitHub Actions

Create .github/workflows/e2e.yml:

name: E2E Tests
on:
  push:
    branches: [main, master]
  pull_request:
    branches: [main, master]

jobs:
  e2e-tests:
    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: Start application
        run: npm start &
        env:
          NODE_ENV: test
          PORT: 8080

      - name: Wait for application
        run: npx wait-on http://localhost:8080 --timeout 30000

      - name: Run E2E tests
        run: npx playwright test

      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

      - name: Upload traces
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-traces
          path: test-results/
          retention-days: 7

Azure Pipelines

Create azure-pipelines-e2e.yml:

trigger:
  branches:
    include:
      - main
      - master

pool:
  vmImage: 'ubuntu-latest'

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: '20.x'
    displayName: 'Install Node.js'

  - script: npm ci
    displayName: 'Install dependencies'

  - script: npx playwright install --with-deps
    displayName: 'Install Playwright browsers'

  - script: |
      npm start &
      npx wait-on http://localhost:8080 --timeout 30000
    displayName: 'Start application'
    env:
      NODE_ENV: test
      PORT: 8080

  - script: npx playwright test
    displayName: 'Run E2E tests'

  - task: PublishTestResults@2
    condition: succeededOrFailed()
    inputs:
      testResultsFormat: 'JUnit'
      testResultsFiles: 'test-results/results.xml'
    displayName: 'Publish test results'

  - publish: playwright-report
    artifact: playwright-report
    condition: succeededOrFailed()
    displayName: 'Publish HTML report'

For the JUnit reporter in Azure Pipelines, add it to your config:

module.exports = {
    reporter: [
        ['html'],
        ['junit', { outputFile: 'test-results/results.xml' }]
    ]
};

Test Configuration: playwright.config.js

Here is a production-ready configuration:

var { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
    testDir: './tests',
    testMatch: '**/*.spec.js',

    // Fail the build on CI if test.only is left in the code
    forbidOnly: !!process.env.CI,

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

    // Parallel workers
    workers: process.env.CI ? 2 : undefined,

    // Reporter configuration
    reporter: process.env.CI
        ? [['html', { open: 'never' }], ['junit', { outputFile: 'test-results/results.xml' }]]
        : [['html', { open: 'on-failure' }]],

    // Global settings
    use: {
        baseURL: process.env.BASE_URL || 'http://localhost:8080',
        trace: 'on-first-retry',
        screenshot: 'only-on-failure',
        video: 'retain-on-failure',
        actionTimeout: 10000,
        navigationTimeout: 15000
    },

    // Browser projects
    projects: [
        // Setup project for authentication
        {
            name: 'setup',
            testMatch: /.*\.setup\.js/
        },

        // Desktop browsers
        {
            name: 'chromium',
            use: {
                ...devices['Desktop Chrome'],
                storageState: 'tests/.auth/user.json'
            },
            dependencies: ['setup']
        },
        {
            name: 'firefox',
            use: {
                ...devices['Desktop Firefox'],
                storageState: 'tests/.auth/user.json'
            },
            dependencies: ['setup']
        },
        {
            name: 'webkit',
            use: {
                ...devices['Desktop Safari'],
                storageState: 'tests/.auth/user.json'
            },
            dependencies: ['setup']
        },

        // Mobile viewports
        {
            name: 'mobile-chrome',
            use: {
                ...devices['Pixel 5'],
                storageState: 'tests/.auth/user.json'
            },
            dependencies: ['setup']
        },
        {
            name: 'mobile-safari',
            use: {
                ...devices['iPhone 13'],
                storageState: 'tests/.auth/user.json'
            },
            dependencies: ['setup']
        }
    ],

    // Start the dev server before running tests
    webServer: {
        command: 'npm start',
        url: 'http://localhost:8080',
        reuseExistingServer: !process.env.CI,
        timeout: 30000,
        env: {
            NODE_ENV: 'test',
            PORT: '8080'
        }
    }
});

The webServer option is worth calling out. Instead of manually starting your app before tests and using wait-on, Playwright can start and stop the server for you. Locally, it reuses a running server. On CI, it starts a fresh one.


Complete Working Example

Let me put it all together. Here is a full test suite for an Express.js application that covers login, navigation, form submission, and API mocking.

Project Structure

tests/
  auth.setup.js
  pages/
    LoginPage.js
    DashboardPage.js
    ArticlesPage.js
  specs/
    auth.spec.js
    dashboard.spec.js
    articles.spec.js
  fixtures/
    articles.json
  .auth/
    user.json        (generated, gitignored)
playwright.config.js

tests/fixtures/articles.json

{
    "articles": [
        {
            "id": "art-001",
            "title": "Getting Started with Node.js Streams",
            "category": "nodejs",
            "synopsis": "A practical guide to readable, writable, and transform streams.",
            "publishDate": "2025-11-15"
        },
        {
            "id": "art-002",
            "title": "PostgreSQL Full-Text Search in Node.js",
            "category": "databases",
            "synopsis": "Build fast, relevant search without Elasticsearch.",
            "publishDate": "2025-12-01"
        },
        {
            "id": "art-003",
            "title": "Docker Multi-Stage Builds for Node.js",
            "category": "devops",
            "synopsis": "Shrink your production images by 80% with multi-stage builds.",
            "publishDate": "2026-01-10"
        }
    ]
}

tests/pages/ArticlesPage.js

var { expect } = require('@playwright/test');

function ArticlesPage(page) {
    this.page = page;
    this.heading = page.getByRole('heading', { name: 'Articles' });
    this.searchInput = page.getByRole('searchbox', { name: 'Search articles' });
    this.articleCards = page.getByTestId('article-card');
    this.categoryFilter = page.getByLabel('Category');
    this.noResultsMessage = page.getByText('No articles found');
    this.loadingSpinner = page.getByTestId('loading-spinner');
}

ArticlesPage.prototype.goto = async function() {
    await this.page.goto('/articles');
    await expect(this.heading).toBeVisible();
};

ArticlesPage.prototype.search = async function(query) {
    await this.searchInput.fill(query);
    await this.searchInput.press('Enter');
};

ArticlesPage.prototype.filterByCategory = async function(category) {
    await this.categoryFilter.selectOption(category);
};

ArticlesPage.prototype.getArticleCount = async function() {
    return await this.articleCards.count();
};

ArticlesPage.prototype.clickArticle = async function(title) {
    await this.page.getByRole('link', { name: title }).click();
};

ArticlesPage.prototype.expectArticleVisible = async function(title) {
    await expect(this.page.getByRole('link', { name: title })).toBeVisible();
};

ArticlesPage.prototype.expectArticleNotVisible = async function(title) {
    await expect(this.page.getByRole('link', { name: title })).not.toBeVisible();
};

module.exports = { ArticlesPage: ArticlesPage };

tests/auth.setup.js

var { test: setup, expect } = require('@playwright/test');

var authFile = 'tests/.auth/user.json';

setup('authenticate as test user', async function({ page }) {
    await page.goto('/login');
    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Password').fill('testpassword123');
    await page.getByRole('button', { name: 'Sign In' }).click();
    await page.waitForURL('**/dashboard');
    await page.context().storageState({ path: authFile });
});

tests/specs/auth.spec.js

var { test, expect } = require('@playwright/test');
var { LoginPage } = require('../pages/LoginPage');

// Auth tests run WITHOUT storageState (unauthenticated)
test.use({ storageState: { cookies: [], origins: [] } });

test.describe('Authentication', function() {
    var loginPage;

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

    test('shows login form', async function({ page }) {
        await expect(loginPage.emailInput).toBeVisible();
        await expect(loginPage.passwordInput).toBeVisible();
        await expect(loginPage.submitButton).toBeVisible();
    });

    test('successful login redirects to dashboard', async function({ page }) {
        await loginPage.loginAndVerify('[email protected]', 'testpassword123');
    });

    test('shows error for invalid credentials', async function({ page }) {
        await loginPage.login('[email protected]', 'wrongpassword');
        await loginPage.expectError('Invalid email or password');
    });

    test('shows error for empty email', async function({ page }) {
        await loginPage.login('', 'somepassword');
        await loginPage.expectError('Email is required');
    });

    test('remember me checkbox persists session', async function({ page }) {
        await loginPage.rememberMe.check();
        await loginPage.login('[email protected]', 'testpassword123');
        await expect(page).toHaveURL(/.*dashboard/);

        // Verify cookie has extended expiry
        var cookies = await page.context().cookies();
        var sessionCookie = cookies.find(function(c) { return c.name === 'session'; });
        expect(sessionCookie).toBeTruthy();
    });
});

tests/specs/dashboard.spec.js

var { test, expect } = require('@playwright/test');
var { DashboardPage } = require('../pages/DashboardPage');

test.describe('Dashboard', function() {
    var dashboardPage;

    test.beforeEach(async function({ page }) {
        dashboardPage = new DashboardPage(page);
        await page.goto('/dashboard');
    });

    test('shows welcome heading', async function({ page }) {
        await dashboardPage.expectLoaded();
    });

    test('search filters results', async function({ page }) {
        await dashboardPage.search('nodejs');
        await expect(page).toHaveURL(/.*search=nodejs/);
    });

    test('logout redirects to login', async function({ page }) {
        await dashboardPage.logout();
        await expect(page).toHaveURL(/.*login/);
    });
});

tests/specs/articles.spec.js

var { test, expect } = require('@playwright/test');
var { ArticlesPage } = require('../pages/ArticlesPage');
var fs = require('fs');
var path = require('path');

var mockArticles = JSON.parse(
    fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'articles.json'), 'utf8')
);

test.describe('Articles', function() {
    test.beforeEach(async function({ page }) {
        // Mock the articles API
        await page.route('**/api/articles*', async function(route) {
            var url = new URL(route.request().url());
            var category = url.searchParams.get('category');
            var search = url.searchParams.get('q');
            var filtered = mockArticles.articles;

            if (category) {
                filtered = filtered.filter(function(a) {
                    return a.category === category;
                });
            }

            if (search) {
                var q = search.toLowerCase();
                filtered = filtered.filter(function(a) {
                    return a.title.toLowerCase().indexOf(q) !== -1 ||
                           a.synopsis.toLowerCase().indexOf(q) !== -1;
                });
            }

            await route.fulfill({
                status: 200,
                contentType: 'application/json',
                body: JSON.stringify({ articles: filtered })
            });
        });
    });

    test('displays all articles', async function({ page }) {
        var articlesPage = new ArticlesPage(page);
        await articlesPage.goto();

        var count = await articlesPage.getArticleCount();
        expect(count).toBe(3);
    });

    test('search filters articles', async function({ page }) {
        var articlesPage = new ArticlesPage(page);
        await articlesPage.goto();
        await articlesPage.search('Docker');

        var count = await articlesPage.getArticleCount();
        expect(count).toBe(1);
        await articlesPage.expectArticleVisible('Docker Multi-Stage Builds for Node.js');
    });

    test('category filter works', async function({ page }) {
        var articlesPage = new ArticlesPage(page);
        await articlesPage.goto();
        await articlesPage.filterByCategory('databases');

        var count = await articlesPage.getArticleCount();
        expect(count).toBe(1);
        await articlesPage.expectArticleVisible('PostgreSQL Full-Text Search in Node.js');
    });

    test('no results shows message', async function({ page }) {
        var articlesPage = new ArticlesPage(page);
        await articlesPage.goto();
        await articlesPage.search('xyznonexistent');

        await expect(articlesPage.noResultsMessage).toBeVisible();
    });

    test('clicking article navigates to detail page', async function({ page }) {
        var articlesPage = new ArticlesPage(page);
        await articlesPage.goto();
        await articlesPage.clickArticle('Getting Started with Node.js Streams');

        await expect(page).toHaveURL(/.*articles.*streams/i);
        await expect(page.getByRole('heading', { name: 'Getting Started with Node.js Streams' })).toBeVisible();
    });

    test('handles API failure gracefully', async function({ page }) {
        // Override the mock for this test
        await page.route('**/api/articles*', async function(route) {
            await route.fulfill({
                status: 500,
                contentType: 'application/json',
                body: JSON.stringify({ error: 'Database connection failed' })
            });
        });

        await page.goto('/articles');
        await expect(page.getByText(/something went wrong/i)).toBeVisible();
    });
});

playwright.config.js (Complete)

var { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
    testDir: './tests',
    testMatch: '**/*.spec.js',
    forbidOnly: !!process.env.CI,
    retries: process.env.CI ? 2 : 0,
    workers: process.env.CI ? 2 : undefined,
    reporter: process.env.CI
        ? [['html', { open: 'never' }], ['junit', { outputFile: 'test-results/results.xml' }]]
        : [['html', { open: 'on-failure' }]],
    use: {
        baseURL: process.env.BASE_URL || 'http://localhost:8080',
        trace: 'on-first-retry',
        screenshot: 'only-on-failure',
        video: 'retain-on-failure',
        actionTimeout: 10000,
        navigationTimeout: 15000
    },
    projects: [
        {
            name: 'setup',
            testMatch: /.*\.setup\.js/
        },
        {
            name: 'chromium',
            use: {
                ...devices['Desktop Chrome'],
                storageState: 'tests/.auth/user.json'
            },
            dependencies: ['setup']
        },
        {
            name: 'firefox',
            use: {
                ...devices['Desktop Firefox'],
                storageState: 'tests/.auth/user.json'
            },
            dependencies: ['setup']
        },
        {
            name: 'webkit',
            use: {
                ...devices['Desktop Safari'],
                storageState: 'tests/.auth/user.json'
            },
            dependencies: ['setup']
        }
    ],
    webServer: {
        command: 'npm start',
        url: 'http://localhost:8080',
        reuseExistingServer: !process.env.CI,
        timeout: 30000,
        env: {
            NODE_ENV: 'test',
            PORT: '8080'
        }
    }
});

Run the full suite:

npx playwright test

Expected output:

Running 16 tests using 4 workers

  setup
    ✓ authenticate as test user (2.1s)

  chromium
    Authentication
      ✓ shows login form (890ms)
      ✓ successful login redirects to dashboard (1.5s)
      ✓ shows error for invalid credentials (1.2s)
      ✓ shows error for empty email (980ms)
      ✓ remember me checkbox persists session (1.6s)
    Dashboard
      ✓ shows welcome heading (720ms)
      ✓ search filters results (950ms)
      ✓ logout redirects to login (1.1s)
    Articles
      ✓ displays all articles (650ms)
      ✓ search filters articles (880ms)
      ✓ category filter works (790ms)
      ✓ no results shows message (680ms)
      ✓ clicking article navigates to detail page (1.0s)
      ✓ handles API failure gracefully (520ms)

  16 passed (12.4s)

Common Issues and Troubleshooting

1. "Locator resolved to hidden element"

Error: locator.click: Error: locator resolved to 1 element(s), but none of them are visible

This means Playwright found the element in the DOM but it is hidden via CSS (display: none, visibility: hidden, opacity: 0, or off-screen). Common causes:

  • The element is inside a collapsed accordion or closed modal
  • A loading overlay is covering the element
  • The element has not animated into view yet

Fix: Wait for the element to be visible first, or check if there is an overlay you need to dismiss:

await page.getByTestId('modal-overlay').waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Submit' }).click();

2. "Timeout 30000ms exceeded" on Navigation

Error: page.goto: Timeout 30000ms exceeded.
Call log:
  - navigating to "http://localhost:8080/dashboard"
  - waiting for load event

This happens when your server is not running, is too slow to respond, or the URL is wrong. On CI, this is usually because the server has not finished starting.

Fix: Make sure you are waiting for the server to be ready. If you are using the webServer config option, increase the timeout. If starting manually, use wait-on:

npm start &
npx wait-on http://localhost:8080 --timeout 60000

3. "strict mode violation" -- Multiple Elements Match

Error: locator.click: Error: strict mode violation: getByRole('button', { name: 'Delete' }) resolved to 5 elements

Playwright is strict by default. If a locator matches multiple elements, it refuses to interact with any of them.

Fix: Make your locator more specific:

// Instead of this:
page.getByRole('button', { name: 'Delete' })

// Be specific about which Delete button:
page.getByRole('row', { name: 'John Doe' }).getByRole('button', { name: 'Delete' })

// Or use .first(), .last(), .nth() if order is meaningful:
page.getByRole('button', { name: 'Delete' }).first()

4. Tests Pass Locally but Fail on CI

This is almost always a timing issue. Your local machine is faster than the CI runner, so actions that complete instantly on your laptop take longer on a shared CI agent.

Fix: Never use hard-coded timeouts. Always assert on visible state changes:

// Bad: fragile timing assumption
await page.waitForTimeout(2000);
await page.getByText('Data loaded').click();

// Good: wait for the actual condition
await expect(page.getByText('Data loaded')).toBeVisible();
await page.getByText('Data loaded').click();

Also enable retries on CI (retries: 2) and traces (trace: 'on-first-retry') so you can diagnose failures.

5. "Browser closed unexpectedly" in Docker / CI

Error: browserType.launch: Browser closed unexpectedly.
Process was terminated.

This is a system dependency issue. Chromium needs specific shared libraries installed on Linux.

Fix:

npx playwright install --with-deps

Or if you are building a Docker image:

RUN npx playwright install --with-deps chromium

6. Visual Snapshot Diffs Due to Font Rendering

Screenshots differ between macOS and Linux because fonts render differently.

Fix: Run visual comparison tests in only one environment (typically CI), and update baselines from that environment:

{
    name: 'visual-tests',
    use: { ...devices['Desktop Chrome'] },
    testMatch: '**/*.visual.spec.js',
    // Only run on CI to ensure consistent font rendering
}

Best Practices

  • Use semantic locators first. getByRole, getByLabel, and getByText make your tests resilient to refactors and enforce good accessibility. If you cannot find an element with a semantic locator, fix the HTML before reaching for CSS selectors.

  • Keep tests independent. Every test should be able to run on its own, in any order, without depending on state from a previous test. Use test.beforeEach for setup, not sequential test execution. The one exception is authentication via storageState, which is explicitly designed for shared setup.

  • Mock external services, not your own app. Use page.route() to mock third-party APIs, payment gateways, and email services. Do not mock your own application endpoints unless you are specifically testing error handling or edge cases. The point of E2E testing is to exercise the real stack.

  • Put timeouts in your config, not your tests. Hard-coded timeouts in individual tests are a maintenance nightmare. Set actionTimeout, navigationTimeout, and expect.timeout in playwright.config.js. Override per-test only when you have a specific reason.

  • Run cross-browser tests on CI, not locally. Running three browsers locally slows you down. Use --project=chromium during development and let CI run the full matrix. Most bugs are not browser-specific.

  • Use the webServer config option. Let Playwright manage your dev server lifecycle. It starts the server before tests and stops it after. This eliminates an entire class of "forgot to start the server" failures.

  • Store test data in fixtures. Hard-coded test data scattered across spec files leads to inconsistencies. Put your mock data in tests/fixtures/ and import it. When the data shape changes, you update one file.

  • Use trace viewer for CI failures. Enable trace: 'on-first-retry' in CI. When a test fails, download the trace artifact and open it in the trace viewer. You will solve the problem in minutes instead of guessing and pushing fix attempts.

  • Test user flows, not implementation details. Write tests like a user story: "The user logs in, navigates to articles, searches for Node.js, and clicks the first result." Do not test internal state, component props, or store contents. That is what unit tests are for.

  • Keep your test suite fast. A slow test suite does not get run. Parallelize aggressively, mock slow external services, reuse authentication state, and skip visual tests in your default run. On a well-structured Express.js app, your E2E suite should finish in under 60 seconds.


References

Powered by Contentful