End-to-End Testing with Playwright

End-to-end tests are the ultimate confidence check โ€” they drive a real browser through a real application flow, from clicking buttons to reading results, exactly as a user would. Playwright is the modern E2E testing tool that supports Chromium, Firefox, and WebKit, runs tests in parallel, records videos on failure, and has a powerful auto-waiting API that makes tests less flaky than older tools. In this lesson you will install Playwright, write E2E tests for the MERN Blog’s register, login, and create post flows, and understand how to integrate them into a CI pipeline so every push is verified.

Installation and Configuration

cd mern-blog  # monorepo root
npm install --save-dev @playwright/test
npx playwright install  # download browser binaries (Chromium, Firefox, WebKit)
// playwright.config.js โ€” at monorepo root
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir:    './e2e',          // test files in /e2e folder
  timeout:    30 * 1000,        // 30s per test
  retries:    process.env.CI ? 2 : 0, // retry on CI, not locally
  reporter:   'html',           // generate HTML report

  use: {
    baseURL:       'http://localhost:5173', // React dev server
    screenshot:    'only-on-failure',
    video:         'retain-on-failure',
    trace:         'on-first-retry',
  },

  // Start dev servers before running tests
  webServer: [
    {
      command:    'npm run dev',       // start React (in client/)
      cwd:        './client',
      url:        'http://localhost:5173',
      reuseExistingServer: !process.env.CI,
    },
    {
      command:    'node index.js',     // start Express (in server/)
      cwd:        './server',
      url:        'http://localhost:5000/api/health',
      reuseExistingServer: !process.env.CI,
      env:        { NODE_ENV: 'test', MONGODB_URI: 'mongodb://localhost:27017/blogdb_test' },
    },
  ],

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
  ],
});
Note: Playwright’s webServer option starts your React and Express servers automatically before tests run and shuts them down after. In CI, always use a fresh test database โ€” set MONGODB_URI to a test database in the webServer env. Use Playwright’s beforeEach hooks to seed required data (like a test user) and afterEach to clean up, either via API calls or direct database access.
Tip: Playwright’s page.locator() uses accessible queries by default and auto-waits for elements to be visible and stable before interacting with them. You rarely need explicit page.waitForSelector() calls โ€” Playwright handles timing automatically. Use getByRole(), getByLabel(), and getByText() as your primary locators, mirroring what React Testing Library encourages for unit tests.
Warning: E2E tests are the most expensive tests to maintain. When UI text, routes, or flows change, E2E tests break. Write E2E tests only for your most critical user journeys โ€” the flows that, if broken, would immediately harm your users (registration, login, core content creation). Use integration tests for edge cases and error paths where E2E tests would be prohibitively slow or fragile.

E2E Tests โ€” Register and Login Flow

// e2e/auth.spec.js
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test('user can register a new account', async ({ page }) => {
    await page.goto('/register');

    // Fill in the registration form
    await page.getByLabel('Name').fill('E2E Test User');
    await page.getByLabel('Email').fill(`e2e-${Date.now()}@example.com`);
    await page.getByLabel('Password').fill('TestPass@1234');
    await page.getByLabel('Confirm Password').fill('TestPass@1234');

    // Submit the form
    await page.getByRole('button', { name: /create account/i }).click();

    // After registration โ€” should land on dashboard
    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.getByText(/E2E Test User/i)).toBeVisible();
  });

  test('user can log in with valid credentials', async ({ page }) => {
    // Seed a test user via API before the test
    await page.request.post('/api/auth/register', {
      data: { name: 'Login Test', email: 'login-e2e@example.com', password: 'TestPass@1234' },
    });

    await page.goto('/login');
    await page.getByLabel('Email').fill('login-e2e@example.com');
    await page.getByLabel('Password').fill('TestPass@1234');
    await page.getByRole('button', { name: /log in/i }).click();

    await expect(page).toHaveURL(/\/dashboard/);
  });

  test('shows error for invalid credentials', async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('nobody@example.com');
    await page.getByLabel('Password').fill('wrongpassword');
    await page.getByRole('button', { name: /log in/i }).click();

    await expect(page.getByText(/invalid email or password/i)).toBeVisible();
    await expect(page).toHaveURL(/\/login/); // stays on login page
  });

  test('protected route redirects to login when not authenticated', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page).toHaveURL(/\/login/);
  });
});

E2E Tests โ€” Create Post Flow

// e2e/posts.spec.js
import { test, expect } from '@playwright/test';

// Reusable login helper
const login = async (page, email = 'post-e2e@example.com', password = 'TestPass@1234') => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: /log in/i }).click();
  await expect(page).toHaveURL(/\/dashboard/);
};

test.describe('Post management', () => {
  test.beforeEach(async ({ page }) => {
    // Seed test user before each test
    await page.request.post('/api/auth/register', {
      data: { name: 'Post Author', email: 'post-e2e@example.com', password: 'TestPass@1234' },
    }).catch(() => {}); // ignore duplicate if already created
    await login(page);
  });

  test('authenticated user can create a post', async ({ page }) => {
    await page.goto('/posts/new');

    await page.getByLabel('Title').fill('My E2E Test Post');
    await page.getByLabel('Content').fill(
      'This is the body of my test post with enough content.'
    );
    await page.getByLabel('Publish immediately').check();
    await page.getByRole('button', { name: /publish post/i }).click();

    // Should redirect to the new post's detail page
    await expect(page).toHaveURL(/\/posts\//);
    await expect(page.getByRole('heading', { name: 'My E2E Test Post' })).toBeVisible();
  });

  test('displays validation error for missing title', async ({ page }) => {
    await page.goto('/posts/new');
    await page.getByLabel('Content').fill('Some content');
    await page.getByRole('button', { name: /publish/i }).click();

    await expect(page.getByText(/title is required/i)).toBeVisible();
    await expect(page).toHaveURL(/\/posts\/new/); // stays on create page
  });
});

CI Integration โ€” GitHub Actions

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  server-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: cd server && npm ci && npm test

  e2e-tests:
    runs-on: ubuntu-latest
    services:
      mongodb:
        image: mongo:7
        ports: ['27017:27017']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: cd server && npm ci
      - run: cd client && npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
        env:
          MONGODB_URI: mongodb://localhost:27017/blogdb_test
          JWT_SECRET: test-secret-for-ci
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Common Mistakes

Mistake 1 โ€” Writing too many E2E tests

โŒ Wrong โ€” testing every edge case in E2E:

// E2E tests for every validation error, empty state, and error message
// โ†’ Slow test suite (minutes per run), fragile, hard to maintain

โœ… Correct โ€” E2E tests cover the critical happy paths; integration tests cover edge cases.

Mistake 2 โ€” Hardcoding test data that causes conflicts

โŒ Wrong โ€” same email used in multiple tests:

await page.getByLabel('Email').fill('test@example.com'); // used in 5 tests
// Second test fails with "email already registered" from the first test's data

โœ… Correct โ€” use unique emails per test:

const email = `test-${Date.now()}@example.com`; // unique per test run โœ“

Mistake 3 โ€” Not taking advantage of Playwright’s auto-waiting

โŒ Wrong โ€” adding explicit waits that make tests slow:

await page.waitForTimeout(2000); // arbitrary 2-second sleep
await page.click('button');
await page.waitForTimeout(1000); // waiting for navigation

โœ… Correct โ€” use Playwright’s built-in wait assertions:

await page.getByRole('button').click(); // auto-waits for button to be ready
await expect(page).toHaveURL(/\/dashboard/); // auto-waits for navigation โœ“

Quick Reference

Task Playwright Code
Navigate to page await page.goto('/login')
Fill input by label await page.getByLabel('Email').fill('a@b.com')
Click button by name await page.getByRole('button', { name: /submit/i }).click()
Assert URL await expect(page).toHaveURL(/\/dashboard/)
Assert text visible await expect(page.getByText('Welcome')).toBeVisible()
Make API request await page.request.post('/api/...', { data: {...} })
Run tests npx playwright test
Run with UI npx playwright test --ui
Debug mode npx playwright test --debug

🧠 Test Yourself

Your E2E test for the create post flow fails intermittently in CI but always passes locally. The failure is a timeout on page.getByRole('button', { name: /publish/i }). What is the most likely cause and fix?