Comprehensive WordPress E2E Testing with Playwright: From Setup to CI Integration
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.
Priya Sharma
Frontend engineer specializing in Gutenberg block development and modern JavaScript in WordPress. Advocates for testing and code quality in the WordPress ecosystem.