Back to Blog
Development Patterns

Comprehensive WordPress E2E Testing with Playwright: From Setup to CI Integration

Priya Sharma
53 min read

End-to-end testing for WordPress has long been an exercise in frustration. Selenium scripts that break on minor DOM changes, Cypress tests that choke on iframe-heavy editors, and fragile bash scripts duct-taped together with WP-CLI commands. Playwright changes that equation entirely. Built by Microsoft and designed for modern web applications, Playwright brings cross-browser testing, automatic waiting, and a powerful API that handles the quirks of WordPress surprisingly well.

This article walks through the entire process of setting up Playwright for WordPress projects, from initial configuration through CI pipeline integration. We will cover real testing scenarios, handle authentication edge cases, build visual regression suites, test across multiple WordPress and PHP versions, and debug the flaky tests that inevitably appear. Every code example here is production-tested and ready to adapt for your own projects.

Setting Up Playwright with @wordpress/e2e-test-utils-playwright

The WordPress core team maintains an official package called @wordpress/e2e-test-utils-playwright that provides utility functions specifically designed for testing WordPress sites. This package handles common operations like logging in, creating posts, moving through the admin, and interacting with the block editor. It saves you from writing hundreds of lines of boilerplate that every WordPress E2E test suite needs.

Installing the Dependencies

Start by initializing your project and installing the required packages:

mkdir wordpress-e2e-tests && cd wordpress-e2e-tests
npm init -y
npm install --save-dev @playwright/test @wordpress/e2e-test-utils-playwright
npx playwright install --with-deps chromium firefox webkit

The @wordpress/e2e-test-utils-playwright package depends on @playwright/test as a peer dependency, so you need both. The final command installs the actual browser binaries that Playwright drives during tests. Installing all three browsers gives you full cross-browser coverage, though you can start with just Chromium if disk space is a concern.

Creating the Playwright Configuration

Create a playwright.config.ts file at your project root:

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

const WP_BASE_URL = process.env.WP_BASE_URL || 'http://localhost:8889';

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html', { open: 'never' }],
    ['json', { outputFile: 'test-results/results.json' }],
  ],
  use: {
    baseURL: WP_BASE_URL,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
  },
  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'] },
    },
  ],
  webServer: {
    command: 'npm run wp-env start',
    url: WP_BASE_URL,
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
  },
});

Several configuration choices here deserve explanation. The fullyParallel option runs tests in parallel within each spec file, which dramatically reduces total test time for large suites. Setting workers: 1 in CI prevents resource contention on shared runners. The webServer block tells Playwright to start your WordPress environment automatically before tests run and to wait until the URL responds before proceeding.

The trace: 'on-first-retry' setting is particularly useful. Playwright traces capture a complete timeline of every action, network request, and DOM snapshot during a test run. They are invaluable for debugging failures, but they generate large files. Capturing them only on retries gives you diagnostic data exactly when you need it without bloating every successful run.

Writing Your First WordPress Test

Create the test directory and a basic test file:

mkdir -p tests/e2e

Now create tests/e2e/login.spec.ts:

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

const WP_ADMIN_USER = process.env.WP_ADMIN_USER || 'admin';
const WP_ADMIN_PASS = process.env.WP_ADMIN_PASS || 'password';

test.describe('WordPress Authentication', () => {
  test('should log in to wp-admin successfully', async ({ page }) => {
    await page.goto('/wp-login.php');
    await page.fill('#user_login', WP_ADMIN_USER);
    await page.fill('#user_pass', WP_ADMIN_PASS);
    await page.click('#wp-submit');
    await page.waitForURL('**/wp-admin/**');

    const adminBar = page.locator('#wpadminbar');
    await expect(adminBar).toBeVisible();
  });

  test('should reject invalid credentials', async ({ page }) => {
    await page.goto('/wp-login.php');
    await page.fill('#user_login', 'nonexistent');
    await page.fill('#user_pass', 'wrongpassword');
    await page.click('#wp-submit');

    const errorMessage = page.locator('#login_error');
    await expect(errorMessage).toBeVisible();
    await expect(errorMessage).toContainText('Error');
  });

  test('should redirect unauthenticated users from wp-admin', async ({ page }) => {
    await page.goto('/wp-admin/');
    await expect(page).toHaveURL(/wp-login.php/);
  });
});

Run the tests with npx playwright test. If your WordPress environment is already running, Playwright will connect to it and execute all three tests. If not, the webServer configuration will start it automatically.

Using the WordPress Test Utilities

The @wordpress/e2e-test-utils-playwright package provides helper classes that simplify common WordPress operations. Here is how to set up a test file that uses them:

import { test as base, expect } from '@playwright/test';
import { Admin, Editor, PageUtils, RequestUtils } from '@wordpress/e2e-test-utils-playwright';

const test = base.extend({
  admin: async ({ page, pageUtils }, use) => {
    await use(new Admin({ page, pageUtils }));
  },
  editor: async ({ page }, use) => {
    await use(new Editor({ page }));
  },
  pageUtils: async ({ page }, use) => {
    await use(new PageUtils({ page }));
  },
  requestUtils: async ({}, use) => {
    const requestUtils = await RequestUtils.setup({
      baseURL: process.env.WP_BASE_URL || 'http://localhost:8889',
      user: {
        username: 'admin',
        password: 'password',
      },
    });
    await use(requestUtils);
  },
});

test('should create a new post via the block editor', async ({ admin, editor, page }) => {
  await admin.visitAdminPage('post-new.php');
  
  // Dismiss the welcome guide if it appears
  const welcomeGuide = page.locator('.edit-post-welcome-guide');
  if (await welcomeGuide.isVisible({ timeout: 2000 }).catch(() => false)) {
    await page.click('button[aria-label="Close"]');
  }

  await editor.canvas.locator('role=textbox[name="Add title"i]').fill('Automated Test Post');
  
  // Add a paragraph block
  await editor.canvas.locator('role=textbox[name="Add title"i]').press('Enter');
  await page.keyboard.type('This paragraph was created by a Playwright test.');

  // Publish the post
  await page.click('role=button[name="Publish"i]');
  await page.click('role=button[name="Publish"i]'); // Confirm

  await expect(page.locator('.components-snackbar')).toContainText('published');
});

export { test, expect };

The RequestUtils class is especially powerful. It communicates directly with the WordPress REST API, bypassing the browser entirely. This lets you set up test fixtures (create posts, users, terms, media) much faster than clicking through the UI. Use it in beforeAll hooks to prepare data, then use browser-based tests to verify the UI behavior.

Configuring wp-env or Docker Test Environments

Reliable E2E tests require a consistent, reproducible WordPress environment. Two primary approaches exist: the official @wordpress/env package and custom Docker configurations. Each has trade-offs.

Using @wordpress/env

The @wordpress/env package (commonly called wp-env) provides a zero-configuration Docker-based WordPress environment. Install it globally or as a dev dependency:

npm install --save-dev @wordpress/env

Create a .wp-env.json file in your project root:

{
  "core": "WordPress/WordPress#6.4",
  "phpVersion": "8.1",
  "plugins": [
    ".",
    "https://downloads.wordpress.org/plugin/woocommerce.8.4.0.zip",
    "https://downloads.wordpress.org/plugin/advanced-custom-fields.6.2.4.zip"
  ],
  "themes": [
    "https://downloads.wordpress.org/theme/twentytwentythree.1.3.zip"
  ],
  "mappings": {
    "wp-content/mu-plugins/test-setup.php": "./tests/fixtures/test-setup.php"
  },
  "config": {
    "WP_DEBUG": true,
    "WP_DEBUG_LOG": true,
    "SCRIPT_DEBUG": true,
    "SAVEQUERIES": true,
    "WP_ENVIRONMENT_TYPE": "testing"
  },
  "env": {
    "tests": {
      "core": "WordPress/WordPress#6.4",
      "phpVersion": "8.1",
      "config": {
        "WP_DEBUG": true,
        "WP_TESTS_DOMAIN": "localhost:8889"
      }
    }
  }
}

The mappings key is critical for test environments. It lets you inject a must-use plugin that configures WordPress specifically for testing. Create tests/fixtures/test-setup.php:

<?php
/**
 * MU-Plugin: Test environment configuration.
 * Loaded automatically by WordPress in the test environment.
 */

// Disable update checks during tests to prevent external HTTP requests.
add_filter('pre_site_transient_update_core', function() {
    return (object) ['updates' => [], 'last_checked' => time()];
});

add_filter('pre_site_transient_update_plugins', function() {
    return (object) ['response' => [], 'last_checked' => time()];
});

add_filter('pre_site_transient_update_themes', function() {
    return (object) ['response' => [], 'last_checked' => time()];
});

// Disable emails during tests.
add_filter('pre_wp_mail', '__return_false');

// Set a predictable timezone.
add_action('init', function() {
    update_option('timezone_string', 'UTC');
    update_option('date_format', 'Y-m-d');
    update_option('time_format', 'H:i');
});

// Disable AJAX heartbeat to reduce noise in test logs.
add_action('init', function() {
    wp_deregister_script('heartbeat');
}, 1);

Start the environment and verify it works:

npx wp-env start
npx wp-env run cli wp option get siteurl
# Should output: http://localhost:8889

Custom Docker Configuration

For more control, especially when testing plugins or themes against specific server configurations, a custom Docker setup is preferable. Create a docker-compose.test.yml:

version: '3.8'

services:
  wordpress:
    image: wordpress:6.4-php8.1-apache
    ports:
      - '8889:80'
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wp_test
      WORDPRESS_DB_PASSWORD: wp_test_pass
      WORDPRESS_DB_NAME: wp_test
      WORDPRESS_DEBUG: '1'
      WORDPRESS_TABLE_PREFIX: wptests_
    volumes:
      - ./:/var/www/html/wp-content/plugins/my-plugin
      - ./tests/fixtures/test-setup.php:/var/www/html/wp-content/mu-plugins/test-setup.php
      - wp-data:/var/www/html
    depends_on:
      db:
        condition: service_healthy

  db:
    image: mariadb:10.11
    environment:
      MYSQL_ROOT_PASSWORD: root_pass
      MYSQL_DATABASE: wp_test
      MYSQL_USER: wp_test
      MYSQL_PASSWORD: wp_test_pass
    healthcheck:
      test: ['CMD', 'healthcheck.sh', '--su-mysql', '--connect', '--innodb_initialized']
      interval: 5s
      timeout: 10s
      retries: 10
    tmpfs:
      - /var/lib/mysql

  setup:
    image: wordpress:cli-2.9-php8.1
    depends_on:
      wordpress:
        condition: service_started
    volumes:
      - wp-data:/var/www/html
    command: >
      sh -c '
        sleep 10 &&
        wp core install
          --url=http://localhost:8889
          --title="E2E Test Site"
          --admin_user=admin
          --admin_password=password
          [email protected]
          --skip-email &&
        wp rewrite structure "/%postname%/" &&
        wp plugin activate my-plugin &&
        wp option update permalink_structure "/%postname%/"
      '

volumes:
  wp-data:

Using tmpfs for the MariaDB data directory is a key optimization. It stores the database entirely in memory, which makes queries faster and ensures a clean state on every restart. The trade-off is that all data disappears when the container stops, but for testing that is exactly what you want.

Add convenience scripts to your package.json:

{
  "scripts": {
    "env:start": "docker compose -f docker-compose.test.yml up -d --wait",
    "env:stop": "docker compose -f docker-compose.test.yml down -v",
    "env:reset": "npm run env:stop && npm run env:start",
    "test:e2e": "npx playwright test",
    "test:e2e:ui": "npx playwright test --ui",
    "test:e2e:debug": "PWDEBUG=1 npx playwright test",
    "test:e2e:report": "npx playwright show-report"
  }
}

Testing Common WordPress Scenarios

With the environment configured, let’s build tests for the most common WordPress workflows. These tests form the backbone of any WordPress E2E suite.

Testing the Post Editor

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

test.describe('Post Editor', () => {
  test.beforeEach(async ({ admin }) => {
    await admin.visitAdminPage('post-new.php');
  });

  test('should create a post with multiple block types', async ({ page, editor }) => {
    // Title
    const titleField = editor.canvas.locator('role=textbox[name="Add title"i]');
    await titleField.fill('Multi-Block Test Post');
    await titleField.press('Enter');

    // Paragraph
    await page.keyboard.type('Opening paragraph with some text content.');
    await page.keyboard.press('Enter');

    // Heading block via slash command
    await page.keyboard.type('/heading');
    await page.locator('role=option[name="Heading"i]').first().click();
    await page.keyboard.type('Section One');
    await page.keyboard.press('Enter');

    // List block
    await page.keyboard.type('/list');
    await page.locator('role=option[name="List"i]').first().click();
    await page.keyboard.type('First item');
    await page.keyboard.press('Enter');
    await page.keyboard.type('Second item');
    await page.keyboard.press('Enter');
    await page.keyboard.type('Third item');

    // Verify block count
    const blocks = editor.canvas.locator('[data-type]');
    const blockCount = await blocks.count();
    expect(blockCount).toBeGreaterThanOrEqual(4);

    // Publish
    await page.click('role=button[name="Publish"i]');
    await page.click('.editor-post-publish-panel role=button[name="Publish"i]');
    
    const snackbar = page.locator('.components-snackbar__content');
    await expect(snackbar).toContainText('published');

    // Verify on the frontend
    const viewLink = page.locator('.components-snackbar__content a');
    const postUrl = await viewLink.getAttribute('href');
    await page.goto(postUrl);
    
    await expect(page.locator('h1, .entry-title')).toContainText('Multi-Block Test Post');
    await expect(page.locator('.entry-content')).toContainText('Opening paragraph');
    await expect(page.locator('.entry-content h2')).toContainText('Section One');
  });

  test('should save and restore draft content', async ({ page, editor }) => {
    const titleField = editor.canvas.locator('role=textbox[name="Add title"i]');
    await titleField.fill('Draft Persistence Test');
    await titleField.press('Enter');
    await page.keyboard.type('Draft content that should persist.');

    // Save draft
    await page.click('role=button[name="Save draft"i]');
    await page.waitForSelector('.editor-post-saved-state.is-saved', { timeout: 10000 });

    // Reload the page
    await page.reload();

    // Verify content persisted
    const title = editor.canvas.locator('role=textbox[name="Add title"i]');
    await expect(title).toHaveText('Draft Persistence Test');
    
    const content = editor.canvas.locator('[data-type="core/paragraph"]').first();
    await expect(content).toContainText('Draft content that should persist.');
  });

  test('should handle scheduling a post for future publication', async ({ page, editor }) => {
    const titleField = editor.canvas.locator('role=textbox[name="Add title"i]');
    await titleField.fill('Scheduled Post Test');

    await page.click('role=button[name="Publish"i]');
    
    // Click on the date to edit it
    const dateButton = page.locator('.editor-post-publish-panel .editor-post-schedule__toggle');
    await dateButton.click();

    // Set a future date (next month)
    const nextMonthButton = page.locator('button[aria-label="View next month"]');
    await nextMonthButton.click();
    
    const dayButton = page.locator('.calendar__day:not(.calendar__day--other-month)').first();
    await dayButton.click();

    // The publish button should now say "Schedule"
    const scheduleButton = page.locator('.editor-post-publish-panel role=button[name="Schedule"i]');
    await expect(scheduleButton).toBeVisible();
    await scheduleButton.click();

    await expect(page.locator('.components-snackbar')).toContainText('scheduled');
  });
});

Testing Media Uploads

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

test.describe('Media Uploads', () => {
  test('should upload an image through the media library', async ({ admin, page }) => {
    await admin.visitAdminPage('media-new.php');

    const fileInput = page.locator('#async-upload');
    const testImage = path.resolve(__dirname, '../fixtures/test-image.jpg');
    
    await fileInput.setInputFiles(testImage);
    await page.click('#html-upload');

    await page.waitForURL('**/upload.php**');
    
    const mediaItem = page.locator('.attachment').first();
    await expect(mediaItem).toBeVisible();
  });

  test('should insert an image into a post via the block editor', async ({ admin, page, editor }) => {
    await admin.visitAdminPage('post-new.php');
    
    const titleField = editor.canvas.locator('role=textbox[name="Add title"i]');
    await titleField.fill('Post With Image');
    await titleField.press('Enter');

    // Insert image block via slash command
    await page.keyboard.type('/image');
    await page.locator('role=option[name="Image"i]').first().click();

    // Upload file
    const fileInput = page.locator('input[type="file"][accept*="image"]');
    const testImage = path.resolve(__dirname, '../fixtures/test-image.jpg');
    await fileInput.setInputFiles(testImage);

    // Wait for upload to complete
    const imageBlock = editor.canvas.locator('[data-type="core/image"] img');
    await expect(imageBlock).toBeVisible({ timeout: 30000 });
    await expect(imageBlock).toHaveAttribute('src', /uploads/);

    // Add alt text
    const altTextInput = page.locator('.block-editor-image__alt-text input, textarea[aria-label="Alternative text"]');
    await altTextInput.fill('Test image alt text');

    // Publish and verify
    await page.click('role=button[name="Publish"i]');
    await page.click('.editor-post-publish-panel role=button[name="Publish"i]');

    const viewLink = page.locator('.components-snackbar__content a');
    const postUrl = await viewLink.getAttribute('href');
    await page.goto(postUrl);

    const frontendImage = page.locator('.entry-content img');
    await expect(frontendImage).toBeVisible();
    await expect(frontendImage).toHaveAttribute('alt', 'Test image alt text');
  });

  test('should handle drag and drop uploads', async ({ admin, page, editor }) => {
    await admin.visitAdminPage('post-new.php');

    const titleField = editor.canvas.locator('role=textbox[name="Add title"i]');
    await titleField.fill('Drag Drop Upload Test');

    const testImage = path.resolve(__dirname, '../fixtures/test-image.jpg');
    const buffer = require('fs').readFileSync(testImage);

    const dataTransfer = await page.evaluateHandle((data) => {
      const dt = new DataTransfer();
      const file = new File([new Uint8Array(data)], 'test-image.jpg', { type: 'image/jpeg' });
      dt.items.add(file);
      return dt;
    }, [...buffer]);

    const editorCanvas = editor.canvas.locator('.is-root-container');
    await editorCanvas.dispatchEvent('drop', { dataTransfer });

    const imageBlock = editor.canvas.locator('[data-type="core/image"] img');
    await expect(imageBlock).toBeVisible({ timeout: 30000 });
  });
});

Testing WooCommerce Checkout

WooCommerce testing requires additional setup to ensure products exist and the cart functions correctly:

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

test.describe('WooCommerce Checkout', () => {
  test.beforeAll(async ({ request }) => {
    // Create a test product via WooCommerce REST API
    const response = await request.post('/wp-json/wc/v3/products', {
      headers: {
        Authorization: `Basic ${Buffer.from('admin:password').toString('base64')}`,
      },
      data: {
        name: 'E2E Test Product',
        type: 'simple',
        regular_price: '29.99',
        description: 'A product created for E2E testing.',
        short_description: 'Test product.',
        manage_stock: true,
        stock_quantity: 100,
      },
    });
    expect(response.ok()).toBeTruthy();
  });

  test('should complete a full checkout flow', async ({ page }) => {
    // Add product to cart
    await page.goto('/shop/');
    const addToCart = page.locator('a.add_to_cart_button').first();
    await addToCart.click();
    await expect(addToCart).toHaveClass(/added/);

    // Go to checkout
    await page.goto('/checkout/');

    // Fill billing details
    await page.fill('#billing_first_name', 'Test');
    await page.fill('#billing_last_name', 'Customer');
    await page.fill('#billing_address_1', '123 Test Street');
    await page.fill('#billing_city', 'Test City');
    await page.selectOption('#billing_state', 'CA');
    await page.fill('#billing_postcode', '90210');
    await page.fill('#billing_phone', '555-0100');
    await page.fill('#billing_email', '[email protected]');

    // Select payment method (Cash on Delivery for testing)
    const codPayment = page.locator('#payment_method_cod');
    if (await codPayment.isVisible()) {
      await codPayment.check();
    }

    // Place order
    await page.click('#place_order');

    // Verify order confirmation
    await page.waitForURL('**/order-received/**', { timeout: 30000 });
    await expect(page.locator('.woocommerce-order-received')).toBeVisible();
    await expect(page.locator('.woocommerce-thankyou-order-received')).toContainText('Thank you');
  });

  test('should validate required checkout fields', async ({ page }) => {
    await page.goto('/shop/');
    await page.locator('a.add_to_cart_button').first().click();
    await page.goto('/checkout/');

    // Clear required fields and submit
    await page.fill('#billing_first_name', '');
    await page.fill('#billing_last_name', '');
    await page.click('#place_order');

    // Check for validation errors
    const errors = page.locator('.woocommerce-error');
    await expect(errors).toBeVisible();
    await expect(errors).toContainText('required');
  });

  test('should apply a coupon code', async ({ page, request }) => {
    // Create coupon via API
    await request.post('/wp-json/wc/v3/coupons', {
      headers: {
        Authorization: `Basic ${Buffer.from('admin:password').toString('base64')}`,
      },
      data: {
        code: 'TEST10OFF',
        discount_type: 'percent',
        amount: '10',
      },
    });

    await page.goto('/shop/');
    await page.locator('a.add_to_cart_button').first().click();
    await page.goto('/cart/');

    await page.fill('#coupon_code', 'TEST10OFF');
    await page.click('button[name="apply_coupon"]');

    await expect(page.locator('.cart-discount')).toBeVisible();
    await expect(page.locator('.cart-discount')).toContainText('TEST10OFF');
  });
});

Testing Custom Gutenberg Blocks

Custom Gutenberg blocks present unique testing challenges. You need to verify both the editor experience (how the block behaves while editing) and the frontend rendering (what visitors see). Let’s build a complete test suite for a hypothetical “Pricing Table” custom block.

Testing Block Registration and Insertion

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

test.describe('Pricing Table Block', () => {
  test.beforeEach(async ({ admin }) => {
    await admin.visitAdminPage('post-new.php');
  });

  test('should appear in the block inserter', async ({ page }) => {
    // Open the block inserter
    await page.click('role=button[name="Toggle block inserter"i]');
    
    // Search for the block
    const searchInput = page.locator('role=searchbox[name="Search for blocks and patterns"i]');
    await searchInput.fill('Pricing Table');

    const blockOption = page.locator('.block-editor-inserter__panel-content role=option[name*="Pricing Table"i]');
    await expect(blockOption).toBeVisible();
    
    // Verify block metadata
    const blockDescription = page.locator('.block-editor-inserter__panel-content .block-editor-inserter__description');
    // Description should exist once the block is focused
    await blockOption.click();
  });

  test('should insert and configure a pricing table', async ({ page, editor }) => {
    // Insert via slash command
    const titleField = editor.canvas.locator('role=textbox[name="Add title"i]');
    await titleField.fill('Pricing Page');
    await titleField.press('Enter');

    await page.keyboard.type('/pricing-table');
    await page.locator('role=option[name*="Pricing Table"i]').first().click();

    // The block should render in the editor
    const pricingBlock = editor.canvas.locator('[data-type="my-plugin/pricing-table"]');
    await expect(pricingBlock).toBeVisible();

    // Configure via sidebar
    await pricingBlock.click();
    
    // Open block settings sidebar if needed
    const settingsButton = page.locator('role=button[name="Settings"i]');
    if (await settingsButton.getAttribute('aria-pressed') === 'false') {
      await settingsButton.click();
    }

    // Set plan name
    const planNameInput = page.locator('.components-panel__body input[aria-label="Plan name"]');
    await planNameInput.fill('Professional');

    // Set price
    const priceInput = page.locator('.components-panel__body input[aria-label="Price"]');
    await priceInput.fill('49');

    // Set billing period
    const billingSelect = page.locator('.components-panel__body select[aria-label="Billing period"]');
    await billingSelect.selectOption('monthly');

    // Toggle featured flag
    const featuredToggle = page.locator('.components-panel__body .components-toggle-control__label:has-text("Featured") ~ .components-form-toggle');
    await featuredToggle.click();

    // Verify the block preview updates
    const planTitle = pricingBlock.locator('.pricing-plan-name');
    await expect(planTitle).toContainText('Professional');

    const priceDisplay = pricingBlock.locator('.pricing-amount');
    await expect(priceDisplay).toContainText('49');
  });

  test('should render correctly on the frontend', async ({ page, editor, requestUtils }) => {
    // Create post with pricing block via REST API for speed
    const postContent = ``;

    const post = await requestUtils.createPost({
      title: 'Frontend Pricing Test',
      content: postContent,
      status: 'publish',
    });

    await page.goto(`/?p=${post.id}`);

    // Verify frontend rendering
    const pricingTable = page.locator('.wp-block-my-plugin-pricing-table');
    await expect(pricingTable).toBeVisible();

    await expect(pricingTable.locator('.plan-name')).toContainText('Starter');
    await expect(pricingTable.locator('.price')).toContainText('$19');
    await expect(pricingTable.locator('.billing-period')).toContainText('/month');

    // Verify all features are listed
    const features = pricingTable.locator('.feature-item');
    await expect(features).toHaveCount(3);
    await expect(features.nth(0)).toContainText('5 Projects');
    await expect(features.nth(1)).toContainText('10GB Storage');
    await expect(features.nth(2)).toContainText('Email Support');
  });

  test('should handle block transforms', async ({ page, editor }) => {
    const titleField = editor.canvas.locator('role=textbox[name="Add title"i]');
    await titleField.fill('Transform Test');
    await titleField.press('Enter');

    // Insert a group block and try to transform it
    await page.keyboard.type('/pricing-table');
    await page.locator('role=option[name*="Pricing Table"i]').first().click();

    const pricingBlock = editor.canvas.locator('[data-type="my-plugin/pricing-table"]');
    await pricingBlock.click();

    // Open block toolbar transform menu
    const transformButton = page.locator('.block-editor-block-switcher__toggle');
    await transformButton.click();

    // Verify available transforms
    const transformOptions = page.locator('.block-editor-block-switcher__popover .block-editor-block-types-list__item');
    const count = await transformOptions.count();
    expect(count).toBeGreaterThan(0);
  });

  test('should maintain data after saving and reloading', async ({ page, editor }) => {
    const titleField = editor.canvas.locator('role=textbox[name="Add title"i]');
    await titleField.fill('Persistence Test');
    await titleField.press('Enter');

    await page.keyboard.type('/pricing-table');
    await page.locator('role=option[name*="Pricing Table"i]').first().click();

    const pricingBlock = editor.canvas.locator('[data-type="my-plugin/pricing-table"]');
    await pricingBlock.click();

    // Configure the block
    const planNameInput = page.locator('.components-panel__body input[aria-label="Plan name"]');
    await planNameInput.fill('Enterprise');

    // Save draft
    await page.click('role=button[name="Save draft"i]');
    await page.waitForSelector('.editor-post-saved-state.is-saved');

    // Reload
    await page.reload();

    // Verify data persisted
    const reloadedBlock = editor.canvas.locator('[data-type="my-plugin/pricing-table"]');
    await expect(reloadedBlock).toBeVisible();

    const planDisplay = reloadedBlock.locator('.pricing-plan-name');
    await expect(planDisplay).toContainText('Enterprise');
  });
});

Testing Block Validation and Error Handling

test('should show validation error for missing required fields', async ({ page, editor }) => {
  const titleField = editor.canvas.locator('role=textbox[name="Add title"i]');
  await titleField.fill('Validation Test');
  await titleField.press('Enter');

  await page.keyboard.type('/pricing-table');
  await page.locator('role=option[name*="Pricing Table"i]').first().click();

  // Try to publish without setting required fields
  await page.click('role=button[name="Publish"i]');

  // The block should show a validation warning
  const warningNotice = page.locator('.block-editor-warning');
  // Or check for a custom validation message
  const validationMessage = editor.canvas.locator('.pricing-table-validation-error');
  
  // At minimum, the block should indicate incomplete state
  const pricingBlock = editor.canvas.locator('[data-type="my-plugin/pricing-table"]');
  const hasWarningClass = await pricingBlock.evaluate(
    el => el.classList.contains('has-warning') || el.querySelector('.validation-error') !== null
  );
  // Your assertion depends on how the block handles validation
});

test('should handle deprecated block versions gracefully', async ({ page, requestUtils }) => {
  // Insert content that uses an old block format
  const deprecatedContent = `

Basic

$9.99
`; const post = await requestUtils.createPost({ title: 'Deprecated Block Test', content: deprecatedContent, status: 'publish', }); // Open in editor - should migrate without errors await page.goto(`/wp-admin/post.php?post=${post.id}&action=edit`); // No block validation errors should appear const blockError = page.locator('.block-editor-block-list__block.has-error'); await expect(blockError).not.toBeVisible(); // The block should render with migrated attributes const pricingBlock = page.locator('[data-type="my-plugin/pricing-table"]'); await expect(pricingBlock).toBeVisible(); });

Visual Regression Testing with Screenshots

Visual regression testing catches unintended CSS changes, layout shifts, and rendering bugs that functional tests miss. Playwright has built-in screenshot comparison that works well for WordPress sites, though you need to account for dynamic content like dates, avatars, and ads.

Setting Up Screenshot Baselines

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

test.describe('Visual Regression', () => {
  test.beforeEach(async ({ page }) => {
    // Hide dynamic content that changes between runs
    await page.addStyleTag({
      content: `
        .widget-area .dynamic-date,
        .comment-date,
        #wpadminbar,
        .site-footer .copyright-year,
        iframe[src*="youtube"],
        iframe[src*="twitter"] {
          visibility: hidden !important;
        }
      `,
    });
  });

  test('homepage visual check', async ({ page }) => {
    await page.goto('/');
    await page.waitForLoadState('networkidle');

    // Wait for web fonts to load
    await page.evaluate(() => document.fonts.ready);

    // Full page screenshot
    await expect(page).toHaveScreenshot('homepage-full.png', {
      fullPage: true,
      maxDiffPixelRatio: 0.01,
      animations: 'disabled',
    });
  });

  test('single post visual check', async ({ page, requestUtils }) => {
    const post = await requestUtils.createPost({
      title: 'Visual Regression Test Post',
      content: '

A paragraph for visual testing.

', status: 'publish', }); await page.goto(`/?p=${post.id}`); await page.waitForLoadState('networkidle'); await page.evaluate(() => document.fonts.ready); await expect(page).toHaveScreenshot('single-post.png', { fullPage: true, maxDiffPixelRatio: 0.01, animations: 'disabled', }); }); test('block editor visual check', async ({ page, admin }) => { await admin.visitAdminPage('post-new.php'); // Dismiss any modals const closeButtons = page.locator('.components-modal__header button[aria-label="Close"]'); while (await closeButtons.first().isVisible({ timeout: 1000 }).catch(() => false)) { await closeButtons.first().click(); } await page.waitForLoadState('networkidle'); // Screenshot just the editor canvas area const editorArea = page.locator('.editor-styles-wrapper'); await expect(editorArea).toHaveScreenshot('block-editor-empty.png', { maxDiffPixelRatio: 0.02, animations: 'disabled', }); }); test('responsive visual checks', async ({ page, browser }) => { const viewports = [ { name: 'mobile', width: 375, height: 812 }, { name: 'tablet', width: 768, height: 1024 }, { name: 'desktop', width: 1440, height: 900 }, ]; for (const viewport of viewports) { const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, }); const viewportPage = await context.newPage(); await viewportPage.goto('/'); await viewportPage.waitForLoadState('networkidle'); await viewportPage.evaluate(() => document.fonts.ready); await expect(viewportPage).toHaveScreenshot(`homepage-${viewport.name}.png`, { fullPage: true, maxDiffPixelRatio: 0.01, animations: 'disabled', }); await context.close(); } }); });

Handling Dynamic Content in Screenshots

WordPress pages contain dynamic elements that change between test runs: timestamps, user avatars, Gravatar images, random post excerpts, and advertisement iframes. You have several strategies to deal with these.

Masking specific regions is the most surgical approach:

test('homepage with masked dynamic regions', async ({ page }) => {
  await page.goto('/');

  await expect(page).toHaveScreenshot('homepage-masked.png', {
    fullPage: true,
    mask: [
      page.locator('.sidebar .recent-posts'),
      page.locator('.comment-metadata time'),
      page.locator('.gravatar'),
      page.locator('.wp-block-latest-posts'),
    ],
    maxDiffPixelRatio: 0.01,
  });
});

For more control, you can freeze dynamic content before taking screenshots:

test('freeze dynamic content before screenshot', async ({ page }) => {
  await page.goto('/');

  // Replace all dates with a fixed string
  await page.evaluate(() => {
    document.querySelectorAll('time, .entry-date, .posted-on').forEach(el => {
      el.textContent = 'January 1, 2024';
    });

    // Replace Gravatar images with a placeholder
    document.querySelectorAll('img[src*="gravatar"]').forEach(img => {
      img.src = 'data:image/svg+xml,';
    });
  });

  await expect(page).toHaveScreenshot('homepage-frozen.png', {
    fullPage: true,
    maxDiffPixelRatio: 0.005,
  });
});

Update your baselines with npx playwright test --update-snapshots when intentional design changes are made. Store baseline images in version control so the entire team shares the same reference points.

Testing Across WordPress and PHP Versions

WordPress plugins and themes should work across multiple WordPress and PHP versions. A matrix testing strategy validates compatibility without requiring you to maintain dozens of separate test environments.

Parameterized Docker Environments

Create a script that accepts version parameters and builds the appropriate environment:

#!/bin/bash
# scripts/run-matrix-tests.sh

WP_VERSION="${1:-6.4}"
PHP_VERSION="${2:-8.1}"
PORT="${3:-8889}"

echo "Testing with WordPress ${WP_VERSION} on PHP ${PHP_VERSION}"

export WP_VERSION PHP_VERSION PORT

# Use envsubst or sed to parameterize docker-compose
cat > docker-compose.matrix.yml </dev/null
docker compose -f docker-compose.matrix.yml up -d --wait

# Wait for WordPress setup
sleep 15

docker compose -f docker-compose.matrix.yml exec -T wordpress bash -c "
  apt-get update -qq && apt-get install -qq -y less mariadb-client > /dev/null 2>&1
  curl -sO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
  chmod +x wp-cli.phar
  php wp-cli.phar core install 
    --url=http://localhost:${PORT} 
    --title='Matrix Test' 
    --admin_user=admin 
    --admin_password=password 
    [email protected] 
    --skip-email 
    --allow-root
  php wp-cli.phar plugin activate my-plugin --allow-root
"

# Run Playwright tests
WP_BASE_URL="http://localhost:${PORT}" npx playwright test

EXIT_CODE=$?

# Cleanup
docker compose -f docker-compose.matrix.yml down -v
rm docker-compose.matrix.yml

exit $EXIT_CODE

Matrix Configuration in wp-env

If you prefer wp-env, you can override versions using environment variables and a script:

// scripts/generate-wp-env.js
const fs = require('fs');

const wpVersion = process.env.WP_VERSION || '6.4';
const phpVersion = process.env.PHP_VERSION || '8.1';

const config = {
  core: `WordPress/WordPress#${wpVersion}`,
  phpVersion: phpVersion,
  plugins: ['.'],
  config: {
    WP_DEBUG: true,
    WP_DEBUG_LOG: true,
    SCRIPT_DEBUG: true,
  },
};

fs.writeFileSync('.wp-env.json', JSON.stringify(config, null, 2));
console.log(`Generated .wp-env.json for WP ${wpVersion} / PHP ${phpVersion}`);

The version matrix you choose depends on your support policy. A reasonable starting matrix for a plugin targeting the general WordPress ecosystem might look like this:

# Version matrix
# WP 6.2 + PHP 7.4 (minimum supported)
# WP 6.3 + PHP 8.0
# WP 6.4 + PHP 8.1 (current stable)
# WP 6.5-beta + PHP 8.2 (upcoming)
# WP 6.4 + PHP 8.3 (latest PHP)

Testing against the upcoming WordPress beta version catches breaking changes early, before they ship to millions of sites. The WordPress core team typically releases beta versions several weeks before a major release, giving plugin developers time to adapt.

GitHub Actions and GitLab CI Integration

Running E2E tests in CI ensures that every pull request is validated before merging. Both GitHub Actions and GitLab CI support Docker-based workflows that pair well with Playwright.

GitHub Actions Workflow

# .github/workflows/e2e-tests.yml
name: E2E Tests

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

concurrency:
  group: e2e-${{ github.ref }}
  cancel-in-progress: true

jobs:
  e2e-tests:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        wp-version: ['6.3', '6.4']
        php-version: ['8.0', '8.1']
        include:
          - wp-version: '6.2'
            php-version: '7.4'
          - wp-version: '6.5'
            php-version: '8.2'

    name: WP ${{ matrix.wp-version }} / PHP ${{ matrix.php-version }}

    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 chromium

      - name: Generate wp-env config
        run: node scripts/generate-wp-env.js
        env:
          WP_VERSION: ${{ matrix.wp-version }}
          PHP_VERSION: ${{ matrix.php-version }}

      - name: Start WordPress environment
        run: |
          npx wp-env start
          npx wp-env run cli wp option get siteurl

      - name: Run E2E tests
        run: npx playwright test --project=chromium
        env:
          WP_BASE_URL: http://localhost:8889

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report-wp${{ matrix.wp-version }}-php${{ matrix.php-version }}
          path: |
            playwright-report/
            test-results/
          retention-days: 7

      - name: Upload screenshots
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: screenshots-wp${{ matrix.wp-version }}-php${{ matrix.php-version }}
          path: test-results/**/*.png
          retention-days: 7

      - name: Stop environment
        if: always()
        run: npx wp-env stop

GitLab CI Pipeline

# .gitlab-ci.yml
stages:
  - build
  - test

variables:
  DOCKER_DRIVER: overlay2
  WP_BASE_URL: http://wordpress:80
  MYSQL_ROOT_PASSWORD: root_pass
  MYSQL_DATABASE: wp_test
  MYSQL_USER: wp_test
  MYSQL_PASSWORD: wp_test_pass

.e2e-test-template: &e2e-template
  stage: test
  image: mcr.microsoft.com/playwright:v1.40.0-jammy
  services:
    - name: mariadb:10.11
      alias: db
  before_script:
    - npm ci
    - npx playwright install chromium
  script:
    - node scripts/generate-wp-env.js
    - bash scripts/setup-wordpress-docker.sh
    - npx playwright test --project=chromium
  artifacts:
    when: on_failure
    paths:
      - playwright-report/
      - test-results/
    expire_in: 7 days
  retry:
    max: 1
    when: script_failure

e2e-wp64-php81:
  <<: *e2e-template
  variables:
    WP_VERSION: '6.4'
    PHP_VERSION: '8.1'

e2e-wp63-php80:
  <<: *e2e-template
  variables:
    WP_VERSION: '6.3'
    PHP_VERSION: '8.0'

e2e-wp62-php74:
  <<: *e2e-template
  variables:
    WP_VERSION: '6.2'
    PHP_VERSION: '7.4'

Optimizing CI Performance

E2E tests in CI are slow by nature. Several techniques can cut runtime significantly:

Cache Playwright browsers. Browser binaries are roughly 400MB. Downloading them on every run wastes time and bandwidth:

- name: Cache Playwright browsers
  uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      playwright-${{ runner.os }}-

Shard tests across multiple runners. Playwright supports sharding natively:

jobs:
  e2e-tests:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - name: Run sharded tests
        run: npx playwright test --shard=${{ matrix.shard }}/4

Use a pre-built WordPress Docker image. Instead of installing WordPress from scratch on every run, build a custom Docker image with WordPress already installed and push it to your container registry. This eliminates the 30-60 second setup overhead.

# Dockerfile.wp-test
FROM wordpress:6.4-php8.1-apache

RUN apt-get update && apt-get install -y less mariadb-client 
    && curl -sO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar 
    && chmod +x wp-cli.phar 
    && mv wp-cli.phar /usr/local/bin/wp 
    && rm -rf /var/lib/apt/lists/*

COPY tests/fixtures/test-setup.php /var/www/html/wp-content/mu-plugins/test-setup.php

Handling Authentication, Nonces, and AJAX in E2E Tests

WordPress relies heavily on nonces, cookies, and AJAX for security and dynamic functionality. E2E tests need to handle all three correctly, or they will fail with cryptic 403 errors and silent AJAX failures.

Persistent Authentication with Storage State

Logging in before every test wastes time. Playwright's storage state feature lets you authenticate once and reuse the session across tests:

// tests/e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const STORAGE_STATE_PATH = path.resolve(__dirname, '../.auth/admin.json');

setup('authenticate as admin', async ({ page }) => {
  await page.goto('/wp-login.php');
  await page.fill('#user_login', 'admin');
  await page.fill('#user_pass', 'password');
  await page.click('#wp-submit');
  await page.waitForURL('**/wp-admin/**');

  // Verify we are logged in
  await expect(page.locator('#wpadminbar')).toBeVisible();

  // Save the authenticated state
  await page.context().storageState({ path: STORAGE_STATE_PATH });
});

// In playwright.config.ts, add a setup project:
// {
//   name: 'setup',
//   testMatch: /auth.setup.ts/,
// },
// {
//   name: 'chromium',
//   use: {
//     ...devices['Desktop Chrome'],
//     storageState: 'tests/.auth/admin.json',
//   },
//   dependencies: ['setup'],
// },

Update your Playwright config to use this setup project:

// Add to playwright.config.ts projects array:
{
  name: 'setup',
  testMatch: /auth.setup.ts/,
},
{
  name: 'admin-tests',
  use: {
    ...devices['Desktop Chrome'],
    storageState: 'tests/.auth/admin.json',
  },
  dependencies: ['setup'],
},
{
  name: 'visitor-tests',
  use: {
    ...devices['Desktop Chrome'],
    // No storageState = unauthenticated visitor
  },
},

Testing AJAX Endpoints Directly

Sometimes you need to test WordPress AJAX endpoints without going through the UI. Playwright's request API makes this straightforward:

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

test.describe('AJAX Endpoints', () => {
  test('should handle heartbeat AJAX', async ({ page }) => {
    await page.goto('/wp-admin/');

    // Intercept the heartbeat AJAX request
    const heartbeatResponse = await page.evaluate(async () => {
      const formData = new FormData();
      formData.append('action', 'heartbeat');
      formData.append('_nonce', window.heartbeatSettings?.nonce || '');
      formData.append('interval', '15');

      const response = await fetch('/wp-admin/admin-ajax.php', {
        method: 'POST',
        body: formData,
        credentials: 'same-origin',
      });

      return {
        status: response.status,
        data: await response.json(),
      };
    });

    expect(heartbeatResponse.status).toBe(200);
  });

  test('should test custom AJAX action with nonce', async ({ page }) => {
    await page.goto('/wp-admin/admin.php?page=my-plugin');

    const result = await page.evaluate(async () => {
      // Get the nonce that WordPress embedded in the page
      const nonce = document.querySelector('#my_plugin_nonce')?.value
        || window.myPluginData?.nonce;

      const formData = new FormData();
      formData.append('action', 'my_plugin_save_settings');
      formData.append('_ajax_nonce', nonce);
      formData.append('setting_key', 'test_option');
      formData.append('setting_value', 'test_value');

      const response = await fetch('/wp-admin/admin-ajax.php', {
        method: 'POST',
        body: formData,
        credentials: 'same-origin',
      });

      return {
        status: response.status,
        body: await response.json(),
      };
    });

    expect(result.status).toBe(200);
    expect(result.body.success).toBe(true);
  });

  test('should reject requests with invalid nonce', async ({ page }) => {
    await page.goto('/wp-admin/');

    const result = await page.evaluate(async () => {
      const formData = new FormData();
      formData.append('action', 'my_plugin_save_settings');
      formData.append('_ajax_nonce', 'invalid_nonce_value');
      formData.append('setting_key', 'test_option');
      formData.append('setting_value', 'malicious_value');

      const response = await fetch('/wp-admin/admin-ajax.php', {
        method: 'POST',
        body: formData,
        credentials: 'same-origin',
      });

      return {
        status: response.status,
        body: await response.text(),
      };
    });

    // WordPress returns 403 or -1 for invalid nonces
    expect([403, 200]).toContain(result.status);
    if (result.status === 200) {
      expect(result.body).toContain('-1');
    }
  });
});

Intercepting and Mocking Network Requests

Playwright's route interception is powerful for isolating tests from external services:

test('should mock external API responses', async ({ page }) => {
  // Mock the WordPress.org API for plugin update checks
  await page.route('**/api.wordpress.org/**', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ plugins: {}, translations: [] }),
    });
  });

  // Mock Gravatar requests
  await page.route('**/gravatar.com/**', route => {
    route.fulfill({
      status: 200,
      contentType: 'image/png',
      body: Buffer.alloc(0),
    });
  });

  await page.goto('/wp-admin/plugins.php');
  // Page loads without making real external requests
});

test('should verify REST API calls from the block editor', async ({ page, admin }) => {
  const apiCalls = [];

  // Monitor all REST API calls
  page.on('request', request => {
    if (request.url().includes('/wp-json/')) {
      apiCalls.push({
        method: request.method(),
        url: request.url(),
        postData: request.postData(),
      });
    }
  });

  await admin.visitAdminPage('post-new.php');

  // Verify that the editor makes expected API calls
  const blockTypeRequests = apiCalls.filter(call =>
    call.url.includes('/wp/v2/block-types')
  );
  expect(blockTypeRequests.length).toBeGreaterThan(0);
});

Performance Budget Testing

E2E tests can validate performance requirements in addition to functionality. Playwright provides access to browser performance APIs that let you measure real user metrics and enforce budgets.

Measuring Core Web Vitals

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

test.describe('Performance Budgets', () => {
  test('homepage should meet performance budgets', async ({ page }) => {
    // Start collecting performance metrics
    await page.goto('/', { waitUntil: 'networkidle' });

    const performanceMetrics = await page.evaluate(() => {
      const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
      const paint = performance.getEntriesByType('paint');
      const lcp = new Promise(resolve => {
        new PerformanceObserver((list) => {
          const entries = list.getEntries();
          resolve(entries[entries.length - 1]);
        }).observe({ type: 'largest-contentful-paint', buffered: true });
        // Fallback timeout
        setTimeout(() => resolve(null), 5000);
      });

      const fcp = paint.find(entry => entry.name === 'first-contentful-paint');

      return {
        domContentLoaded: navigation.domContentLoadedEventEnd - navigation.startTime,
        fullyLoaded: navigation.loadEventEnd - navigation.startTime,
        ttfb: navigation.responseStart - navigation.startTime,
        fcp: fcp ? fcp.startTime : null,
        transferSize: navigation.transferSize,
        domInteractive: navigation.domInteractive - navigation.startTime,
      };
    });

    // Enforce budgets
    expect(performanceMetrics.ttfb).toBeLessThan(600); // 600ms TTFB
    expect(performanceMetrics.fcp).toBeLessThan(1800); // 1.8s FCP
    expect(performanceMetrics.domContentLoaded).toBeLessThan(3000); // 3s DOM ready
    expect(performanceMetrics.fullyLoaded).toBeLessThan(5000); // 5s full load

    console.log('Performance Metrics:', performanceMetrics);
  });

  test('should not exceed resource count budgets', async ({ page }) => {
    const resources = {
      scripts: 0,
      stylesheets: 0,
      images: 0,
      fonts: 0,
      totalSize: 0,
    };

    page.on('response', response => {
      const type = response.request().resourceType();
      const headers = response.headers();
      const contentLength = parseInt(headers['content-length'] || '0');

      if (type === 'script') resources.scripts++;
      if (type === 'stylesheet') resources.stylesheets++;
      if (type === 'image') resources.images++;
      if (type === 'font') resources.fonts++;
      resources.totalSize += contentLength;
    });

    await page.goto('/', { waitUntil: 'networkidle' });

    // Budget enforcement
    expect(resources.scripts).toBeLessThan(20);
    expect(resources.stylesheets).toBeLessThan(10);
    expect(resources.totalSize).toBeLessThan(2 * 1024 * 1024); // 2MB total

    console.log('Resource counts:', resources);
  });

  test('wp-admin should load within budget', async ({ page }) => {
    // Login first
    await page.goto('/wp-login.php');
    await page.fill('#user_login', 'admin');
    await page.fill('#user_pass', 'password');
    await page.click('#wp-submit');
    await page.waitForURL('**/wp-admin/**');

    // Now measure admin dashboard performance
    const startTime = Date.now();
    await page.goto('/wp-admin/', { waitUntil: 'networkidle' });
    const loadTime = Date.now() - startTime;

    expect(loadTime).toBeLessThan(8000); // 8 seconds for admin

    // Check for render-blocking resources
    const blockingResources = await page.evaluate(() => {
      const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
      return entries
        .filter(entry => entry.renderBlockingStatus === 'blocking')
        .map(entry => ({
          name: entry.name.split('/').pop(),
          duration: Math.round(entry.duration),
          size: entry.transferSize,
        }));
    });

    console.log('Render-blocking resources:', blockingResources);
    expect(blockingResources.length).toBeLessThan(15);
  });

  test('should measure Cumulative Layout Shift', async ({ page }) => {
    let clsValue = 0;

    // Set up CLS observer before navigation
    await page.addInitScript(() => {
      window.__clsEntries = [];
      const observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          if (!(entry as any).hadRecentInput) {
            window.__clsEntries.push(entry);
          }
        }
      });
      observer.observe({ type: 'layout-shift', buffered: true });
    });

    await page.goto('/');
    await page.waitForLoadState('networkidle');

    // Wait extra time for late-loading elements
    await page.waitForTimeout(3000);

    clsValue = await page.evaluate(() => {
      return (window as any).__clsEntries.reduce(
        (sum: number, entry: any) => sum + entry.value, 0
      );
    });

    expect(clsValue).toBeLessThan(0.1); // Google's "good" threshold
    console.log(`CLS: ${clsValue.toFixed(4)}`);
  });
});

Database Query Performance Testing

WordPress sites frequently suffer from excessive database queries. If you have the SAVEQUERIES constant enabled (as configured in our test environment), you can measure query counts:

test('should not exceed query count budget', async ({ page, request }) => {
  // Create a REST endpoint in your test MU-plugin that exposes query data
  // test-setup.php should include:
  // add_action('rest_api_init', function() {
  //   register_rest_route('test/v1', '/queries', [
  //     'methods' => 'GET',
  //     'callback' => function() {
  //       global $wpdb;
  //       return ['count' => count($wpdb->queries), 'queries' => $wpdb->queries];
  //     },
  //     'permission_callback' => '__return_true',
  //   ]);
  // });

  await page.goto('/');

  const queryData = await request.get('/wp-json/test/v1/queries');
  const { count, queries } = await queryData.json();

  expect(count).toBeLessThan(50); // Homepage should use fewer than 50 queries

  // Log slow queries (over 0.05 seconds)
  const slowQueries = queries.filter((q: any) => parseFloat(q[1]) > 0.05);
  if (slowQueries.length > 0) {
    console.warn('Slow queries detected:', slowQueries.map((q: any) => ({
      sql: q[0].substring(0, 100),
      time: q[1],
      caller: q[2],
    })));
  }
});

Debugging Flaky Tests and Best Practices

Flaky tests are the bane of E2E testing. A test that fails intermittently erodes trust in the entire test suite, leading teams to ignore failures and eventually abandon automated testing altogether. WordPress E2E tests are particularly prone to flakiness because of the CMS's reliance on AJAX, dynamic content loading, iframe-based editors, and timing-sensitive JavaScript initialization.

Common Sources of Flakiness in WordPress Tests

The block editor's asynchronous initialization. The Gutenberg editor loads dozens of JavaScript files, initializes multiple React applications, fetches block type data from the REST API, and renders an iframe-based canvas. All of this happens asynchronously. Tests that interact with the editor too early will fail because elements are not yet interactive.

The fix is to wait for specific indicators of readiness rather than arbitrary timeouts:

// Bad: arbitrary timeout
await page.waitForTimeout(3000);
await page.click('.editor-post-title__input');

// Good: wait for specific readiness indicator
await page.waitForSelector('.editor-canvas__iframe', { state: 'attached' });
await page.frameLocator('.editor-canvas__iframe').locator('.is-root-container').waitFor();
const titleField = editor.canvas.locator('role=textbox[name="Add title"i]');
await titleField.waitFor({ state: 'visible' });
await titleField.fill('My Post');

AJAX requests completing at unpredictable times. WordPress uses AJAX for auto-saving drafts, heartbeat pings, and inline editing. These background requests can interfere with tests by changing DOM state unexpectedly.

Wait for AJAX completion explicitly when your test depends on it:

// Wait for a specific AJAX request to complete
async function waitForAjax(page, actionName) {
  return page.waitForResponse(response =>
    response.url().includes('admin-ajax.php') &&
    response.request().postData()?.includes(`action=${actionName}`) &&
    response.status() === 200
  );
}

// Usage
const savePromise = waitForAjax(page, 'wp-autosave');
await page.keyboard.type('Triggering autosave...');
await savePromise; // Now we know the save completed

WordPress update notifications and admin notices. Admin notices (update available, plugin deactivated, etc.) can shift page layout and obscure buttons that tests try to click. The test MU-plugin we created earlier disables update checks, but other notices may appear.

// Add to test setup: dismiss all admin notices
test.beforeEach(async ({ page }) => {
  page.on('load', async () => {
    await page.evaluate(() => {
      // Remove all admin notices
      document.querySelectorAll('.notice, .update-nag, .updated, .error').forEach(el => {
        el.remove();
      });
    });
  });
});

Race conditions with the Customizer. The WordPress Customizer loads in an iframe and communicates with the parent frame via postMessage. Tests that switch between customizer panels too quickly will encounter stale references.

test('customizer panel navigation', async ({ page }) => {
  await page.goto('/wp-admin/customize.php');

  // Wait for customizer to fully initialize
  await page.waitForFunction(() => {
    return typeof window.wp?.customize !== 'undefined' &&
           document.querySelector('.wp-full-overlay.expanded') !== null;
  }, { timeout: 15000 });

  // Navigate to a panel
  await page.click('#accordion-section-title_tagline');
  
  // Wait for the section to be expanded before interacting
  const section = page.locator('#accordion-section-title_tagline');
  await expect(section).toHaveClass(/open/);
  
  // Now it is safe to interact with controls in this section
  const siteTitle = page.locator('#customize-control-blogname input');
  await expect(siteTitle).toBeVisible();
  await siteTitle.fill('Updated Site Title');

  // Wait for preview to refresh after the change
  const previewFrame = page.frameLocator('#customize-preview iframe');
  await previewFrame.locator('.site-title, .custom-logo-link').waitFor({ state: 'visible' });
});

Debugging Tools and Techniques

Playwright Inspector. Run tests with PWDEBUG=1 to open the Playwright Inspector, which lets you step through tests, inspect selectors, and see what the browser sees at each point. This is the single most useful debugging tool for flaky tests:

PWDEBUG=1 npx playwright test tests/e2e/editor.spec.ts --project=chromium

Trace Viewer. When a test fails in CI, the trace file contains a complete record of everything that happened. Download the trace artifact and open it:

npx playwright show-trace test-results/editor-should-create-post/trace.zip

The trace viewer shows a timeline with screenshots at every action, network requests, console logs, and the DOM state at each point. You can scrub through the timeline to find exactly when the test diverged from expected behavior.

Console and network logging. Capture browser console output and network failures to identify JavaScript errors that cause tests to fail:

test.beforeEach(async ({ page }) => {
  const errors: string[] = [];

  page.on('console', msg => {
    if (msg.type() === 'error') {
      errors.push(msg.text());
    }
  });

  page.on('pageerror', error => {
    errors.push(`Page error: ${error.message}`);
  });

  page.on('response', response => {
    if (response.status() >= 400) {
      errors.push(`HTTP ${response.status()}: ${response.url()}`);
    }
  });

  // Make errors available to tests
  (page as any).__testErrors = errors;
});

test.afterEach(async ({ page }, testInfo) => {
  const errors = (page as any).__testErrors || [];
  if (errors.length > 0 && testInfo.status === 'failed') {
    console.log('Browser errors during test:');
    errors.forEach(e => console.log(`  - ${e}`));
  }
});

Best Practices for Stable WordPress E2E Tests

Use data-testid attributes. WordPress core does not use data-testid attributes, but your custom themes and plugins should. These attributes survive CSS refactors and class name changes:

// In your theme/plugin PHP
<button data-testid="submit-ticket" class="btn btn-primary">
    <?php esc_html_e('Submit Ticket', 'my-plugin'); ?>
</button>
// In your tests
await page.click('[data-testid="submit-ticket"]');

Isolate test data. Each test should create its own data and clean up after itself. Use the REST API to create fixtures in beforeAll or beforeEach hooks, and delete them in afterAll or afterEach:

test.describe('Post operations', () => {
  let testPostId: number;

  test.beforeAll(async ({ requestUtils }) => {
    const post = await requestUtils.createPost({
      title: 'Test Fixture Post',
      content: 'Content for testing.',
      status: 'publish',
    });
    testPostId = post.id;
  });

  test.afterAll(async ({ requestUtils }) => {
    await requestUtils.deletePost(testPostId);
  });

  test('should display the test post', async ({ page }) => {
    await page.goto(`/?p=${testPostId}`);
    await expect(page.locator('.entry-title')).toContainText('Test Fixture Post');
  });
});

Prefer role-based selectors. Playwright's role-based selectors align with accessibility semantics and are more resilient than CSS selectors:

// Fragile: CSS class might change
await page.click('.editor-post-publish-button');

// Better: role-based selector
await page.click('role=button[name="Publish"i]');

// Also good: text-based selector
await page.click('button:has-text("Publish")');

Never use fixed timeouts for synchronization. Every page.waitForTimeout() call is a code smell. Replace them with explicit waits for observable state changes:

// Bad
await page.click('#publish');
await page.waitForTimeout(5000);
expect(await page.textContent('.notice')).toContain('published');

// Good
await page.click('#publish');
await expect(page.locator('.notice')).toContainText('published', { timeout: 10000 });

Run tests in isolation. Each test should be independent. Never rely on the side effects of a previous test. If test B requires a post that test A created, test B should create its own post. This ensures tests can run in any order and in parallel.

Tag tests for selective execution. Not every test needs to run on every commit. Use Playwright's tag system to categorize tests:

test('critical checkout flow @smoke', async ({ page }) => {
  // This test runs on every push
});

test('visual regression of all pages @visual', async ({ page }) => {
  // This test runs nightly
});

test('full WooCommerce order lifecycle @slow', async ({ page }) => {
  // This test runs before releases
});
# Run only smoke tests on push
npx playwright test --grep @smoke

# Run visual tests nightly
npx playwright test --grep @visual

# Run everything before release
npx playwright test

Use test.describe.configure for serial execution when needed. Some test sequences genuinely need to run in order (e.g., create, edit, delete). Mark these explicitly:

test.describe('Post lifecycle', () => {
  test.describe.configure({ mode: 'serial' });
  
  let postId: number;

  test('create a post', async ({ page }) => {
    // ... create post, store ID
    postId = extractedId;
  });

  test('edit the post', async ({ page }) => {
    await page.goto(`/wp-admin/post.php?post=${postId}&action=edit`);
    // ... edit
  });

  test('trash the post', async ({ page }) => {
    // ... delete
  });
});

A Complete Test Helper Library

As your test suite grows, you will accumulate common operations. Extract them into a helper library to keep tests focused on behavior rather than mechanics:

// tests/e2e/helpers/wordpress.ts
import { Page, expect } from '@playwright/test';

export class WordPressHelpers {
  constructor(private page: Page) {}

  async login(username: string = 'admin', password: string = 'password') {
    await this.page.goto('/wp-login.php');
    await this.page.fill('#user_login', username);
    await this.page.fill('#user_pass', password);
    await this.page.click('#wp-submit');
    await this.page.waitForURL('**/wp-admin/**');
    await expect(this.page.locator('#wpadminbar')).toBeVisible();
  }

  async dismissEditorWelcomeGuide() {
    try {
      const closeButton = this.page.locator('.edit-post-welcome-guide .components-modal__header button');
      await closeButton.click({ timeout: 2000 });
    } catch {
      // Guide not shown, continue
    }
  }

  async publishPost() {
    await this.page.click('role=button[name="Publish"i]');
    const publishPanel = this.page.locator('.editor-post-publish-panel');
    if (await publishPanel.isVisible({ timeout: 2000 }).catch(() => false)) {
      await publishPanel.locator('role=button[name="Publish"i]').click();
    }
    await expect(this.page.locator('.components-snackbar')).toContainText('published', { timeout: 10000 });
  }

  async getPublishedPostUrl(): Promise {
    const viewLink = this.page.locator('.components-snackbar__content a');
    return await viewLink.getAttribute('href') || '';
  }

  async waitForEditorReady() {
    // Wait for the editor iframe to load
    const canvas = this.page.locator('iframe[name="editor-canvas"]');
    if (await canvas.isVisible({ timeout: 5000 }).catch(() => false)) {
      const frame = this.page.frameLocator('iframe[name="editor-canvas"]');
      await frame.locator('.is-root-container').waitFor({ state: 'visible', timeout: 15000 });
    } else {
      // Fallback for non-iframe editor
      await this.page.locator('.editor-styles-wrapper').waitFor({ state: 'visible', timeout: 15000 });
    }
  }

  async clearAllNotices() {
    await this.page.evaluate(() => {
      document.querySelectorAll('.notice, .update-nag, .updated, .error, .components-snackbar').forEach(el => el.remove());
    });
  }

  async setScreenOptions(options: Record) {
    await this.page.click('#show-settings-link');
    for (const [key, value] of Object.entries(options)) {
      const checkbox = this.page.locator(`#${key}-hide`);
      if (value) {
        await checkbox.check();
      } else {
        await checkbox.uncheck();
      }
    }
    await this.page.click('#screen-options-apply');
  }
}

Using these helpers keeps your actual test files clean and readable:

import { test, expect } from '@playwright/test';
import { WordPressHelpers } from './helpers/wordpress';

test('complete post creation workflow', async ({ page }) => {
  const wp = new WordPressHelpers(page);
  
  await wp.login();
  await page.goto('/wp-admin/post-new.php');
  await wp.waitForEditorReady();
  await wp.dismissEditorWelcomeGuide();
  
  // ... test-specific logic
  
  await wp.publishPost();
  const url = await wp.getPublishedPostUrl();
  
  await page.goto(url);
  await expect(page.locator('.entry-title')).toBeVisible();
});

Monitoring Test Suite Health

Track flaky test rates over time. Playwright's JSON reporter outputs structured data that you can feed into a dashboard or simple script:

// scripts/analyze-test-results.ts
import fs from 'fs';

interface TestResult {
  title: string;
  status: 'passed' | 'failed' | 'timedOut' | 'skipped';
  retries: number;
  duration: number;
}

const results = JSON.parse(
  fs.readFileSync('test-results/results.json', 'utf-8')
);

const flakyTests = results.suites
  .flatMap((suite: any) => suite.specs)
  .filter((spec: any) =>
    spec.tests.some((t: any) =>
      t.results.length > 1 && t.results[t.results.length - 1].status === 'passed'
    )
  );

if (flakyTests.length > 0) {
  console.warn(`Found ${flakyTests.length} flaky tests:`);
  flakyTests.forEach((spec: any) => {
    console.warn(`  - ${spec.title} (${spec.file})`);
  });
  // Optionally fail the build if flaky rate exceeds threshold
  const flakyRate = flakyTests.length / results.suites.flatMap((s: any) => s.specs).length;
  if (flakyRate > 0.1) {
    console.error(`Flaky rate ${(flakyRate * 100).toFixed(1)}% exceeds 10% threshold`);
    process.exit(1);
  }
}

Putting It All Together: A Production-Ready Test Suite Structure

After covering all these individual aspects, here is how a well-organized WordPress E2E test suite looks in practice:

tests/
  e2e/
    auth.setup.ts                 # Authentication setup project
    fixtures.ts                    # Shared test fixtures and extensions
    helpers/
      wordpress.ts                 # WP-specific helper functions
      woocommerce.ts               # WooCommerce helpers
      blocks.ts                    # Block editor helpers
    specs/
      smoke/
        login.spec.ts              # Critical auth tests (@smoke)
        homepage.spec.ts           # Homepage loads correctly (@smoke)
      editor/
        post-creation.spec.ts      # Creating posts
        block-insertion.spec.ts    # Adding blocks
        media-uploads.spec.ts      # File uploads
        scheduling.spec.ts         # Scheduled posts
      blocks/
        pricing-table.spec.ts      # Custom block tests
        testimonial.spec.ts        # Another custom block
      woocommerce/
        checkout.spec.ts           # Checkout flow
        cart.spec.ts               # Cart operations
        coupons.spec.ts            # Coupon application
      visual/
        homepage.visual.spec.ts    # Visual regression (@visual)
        editor.visual.spec.ts      # Editor appearance (@visual)
      performance/
        budgets.spec.ts            # Performance budgets (@perf)
        queries.spec.ts            # DB query counts (@perf)
    fixtures/
      test-image.jpg               # Test media files
      test-document.pdf
      test-setup.php               # MU-plugin for test env
  .auth/
    admin.json                     # Stored auth state (gitignored)
playwright.config.ts
.wp-env.json

This structure separates concerns clearly. Smoke tests catch critical regressions fast. Feature-specific tests live in their own directories. Visual and performance tests run on separate schedules. Helper classes keep test files focused on the behavior under test rather than the mechanics of WordPress interaction.

The combination of Playwright's modern API, the WordPress test utilities package, and a well-structured CI pipeline gives you confidence that your WordPress site works correctly across browsers, devices, and WordPress versions. The initial setup takes effort, but the payoff is substantial: fewer production bugs, faster development cycles, and the ability to refactor code without fear of breaking things silently.

Start with a few smoke tests covering your most critical user flows. Add tests as you fix bugs (every bug fix should include a test that would have caught it). Over time, your test suite becomes a living specification of how your WordPress site should behave, and that specification runs automatically on every commit.

Share this article

Priya Sharma

Frontend engineer specializing in Gutenberg block development and modern JavaScript in WordPress. Advocates for testing and code quality in the WordPress ecosystem.