Testing WordPress plugins: practical tips and trade-offs
When you're shipping a plugin to production, you're making a bet on its reliability. Testing WordPress plugins isn't just about catching bugs before release—it's about building confidence that your code won't break a client's site or conflict with other plugins. This guide walks through the testing strategies we use across real projects, from unit tests to end-to-end validation, and the trade-offs you'll encounter when deciding where to invest your time.
Why plugin testing matters differently than application testing
Plugins run in an unpredictable environment. Your code executes alongside potentially dozens of other plugins, custom themes, and user modifications. A function that works in isolation can fail when WordPress core, a competing plugin, or custom code modifies the same hook or database table. Testing WordPress plugins means validating not just your logic, but your integration with WordPress APIs, hooks, filters, and database state.
The cost of a failing plugin is high: lost client trust, emergency support tickets, and reputation damage. The cost of over-testing is high too: slower development cycles, brittle tests that break on minor changes, and time spent on edge cases that rarely occur in production.
PHPUnit with the WP test suite — setting up the foundation
PHPUnit is your starting point. WordPress ships with a test suite designed for core development, and we can scaffold it for plugins using the WP-CLI tool.
wp scaffold plugin-tests my-pluginThis command creates a full test infrastructure: PHPUnit configuration, a bootstrap file that loads WordPress core, test case classes, and GitHub Actions workflow files. You get immediate CI integration without manual setup.
Here's what a basic test looks like:
<?php
class MyPluginTest extends WP_UnitTestCase {
public function test_plugin_activation() {
my_plugin_activate();
$this->assertTrue( get_option( 'my_plugin_activated' ) );
}
public function test_sanitize_setting() {
$input = '<script>alert("xss")</script>';
$output = my_plugin_sanitize_input( $input );
$this->assertStringNotContainsString( '<script>', $output );
}
}The WP_UnitTestCase class extends PHPUnit's base case and gives you helper methods like $this->factory->post->create() to create test data. Each test runs in isolation; WordPress rolls back database changes after every test so no state leaks between runs.
Trade-off: Unit tests are fast and cheap to write, but they don't validate how your code behaves when WordPress hooks or other plugins modify behavior. A passing unit test doesn't guarantee production safety. You need additional layers.
Integration tests — validating hooks and database interactions
Integration tests exercise your plugin alongside WordPress itself. They confirm that your hooks fire at the right time, that filters work as expected, and that database queries don't corrupt data when other code modifies the same tables.
Consider a plugin that syncs post metadata to an external API whenever a post is updated:
<?php
class PostSyncIntegrationTest extends WP_UnitTestCase {
private $api_mock;
public function set_up() {
parent::set_up();
$this->api_mock = $this->getMockBuilder( 'External_API' )
->setMethods( [ 'send_metadata' ] )
->getMock();
}
public function test_post_update_triggers_sync() {
$post_id = $this->factory->post->create( [
'post_title' => 'Test Post',
'post_type' => 'custom_post_type',
] );
$this->api_mock
->expects( $this->once() )
->method( 'send_metadata' )
->with( $post_id );
wp_update_post( [ 'ID' => $post_id, 'post_title' => 'Updated' ] );
// Verify the hook fired and API was called
$this->assertTrue( true ); // Mock expectation validates this
}
public function test_sync_fails_gracefully() {
$this->api_mock
->method( 'send_metadata' )
->willThrowException( new Exception( 'Network error' ) );
// Post should still be updated even if sync fails
$post_id = $this->factory->post->create();
wp_update_post( [ 'ID' => $post_id, 'post_title' => 'Updated' ] );
$post = get_post( $post_id );
$this->assertEquals( 'Updated', $post->post_title );
}
}These tests mock external dependencies but run real WordPress code. They catch scenarios where hooks don't fire, filters strip essential data, or database locks cause race conditions.
Trade-off: Integration tests are slower and more complex to set up, but they're far more valuable than unit tests for plugin work. The setup overhead is real—you're loading WordPress, creating test data, and managing mock objects—but it's worth the investment for core functionality.
End-to-end tests — Playwright and @wordpress/e2e-test-utils-playwright
E2E tests run through a real browser, clicking through your plugin's UI exactly as a user would. These are the ultimate safety net: they catch JavaScript errors, race conditions, UI breaking changes, and workflows that work in isolation but fail under real network conditions.
The WordPress project now maintains @wordpress/e2e-test-utils-playwright, which provides utilities specifically for testing in WordPress environments:
import { test, expect } = require( '@playwright/test' );
import { Admin } = require( '@wordpress/e2e-test-utils-playwright' );
test.describe( 'My Plugin Settings', () => {
let admin;
test.beforeEach( async ( { browser } ) => {
admin = new Admin( { page: await browser.newPage() } );
await admin.login();
} );
test( 'Admin can save plugin settings', async ( { page } ) => {
await page.goto( '/wp-admin/options-general.php?page=my-plugin' );
await page.fill( 'input[name="api_key"]', 'test-key-123' );
await page.click( 'button[type="submit"]' );
await expect( page.locator( '.notice-success' ) )
.toContainText( 'Settings saved' );
} );
test( 'Settings persist after page reload', async ( { page } ) => {
await page.goto( '/wp-admin/options-general.php?page=my-plugin' );
await page.fill( 'input[name="api_key"]', 'test-key-123' );
await page.click( 'button[type="submit"]' );
await page.reload();
const apiKeyValue = await page.inputValue( 'input[name="api_key"]' );
expect( apiKeyValue ).toBe( 'test-key-123' );
} );
} );Playwright is fast and reliable compared to older tools like Selenium. It has great debugging built in—you can run tests with --debug and step through interactions. The @wordpress/e2e-test-utils-playwright package handles WordPress-specific tasks like logging in with a consistent interface.
Trade-off: E2E tests are expensive. They're slow (often 10–30 seconds per test), require a running WordPress instance, and are brittle if your UI changes. Don't use them to test business logic—that's what unit and integration tests are for. Use E2E tests only for critical user workflows: settings save, form submission, post publish, integration with core WordPress UI.
CI — running tests across PHP and WordPress versions
Your plugin needs to work across multiple PHP versions (typically 7.4 through 8.2 currently) and WordPress versions (usually the last 3–4 releases). GitHub Actions makes this manageable with a matrix strategy:
name: Tests
on: [ push, pull_request ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: [ '7.4', '8.0', '8.1', '8.2' ]
wordpress: [ '6.2', '6.3', '6.4' ]
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mysql
- name: Install dependencies
run: composer install
- name: Setup WordPress test environment
run: bash bin/install-wp-tests.sh wordpress_test root password 127.0.0.1 ${{ matrix.wordpress }}
- name: Run PHPUnit tests
run: composer run test
- name: Run E2E tests
uses: coactions/setup-wordpress@main
with:
wordpress-version: ${{ matrix.wordpress }}
- name: Run Playwright tests
run: npm run test:e2eThis workflow runs your entire test suite against 12 combinations of PHP and WordPress versions. A single incompatibility is caught immediately, before it reaches a user's site.
Trade-off: CI multiplies your test execution time. With 12 matrix combinations and a mix of unit, integration, and E2E tests, you might wait 20+ minutes for a full run. Mitigate this by: running only PHP unit tests on every PR, running full E2E tests on merge to main, and using matrix conditions to skip irrelevant combinations (for example, PHP 8.2 features don't need testing against WordPress 6.0).
Common mistakes
Over-testing trivial code: Don't write tests for simple getters, WordPress wrappers, or one-line helpers. Test the logic that's likely to break: data validation, external API calls, state changes, and security-critical operations.
Testing implementation instead of behavior: Tests that verify exact method calls or internal state changes are brittle. If you refactor your code and the behavior stays the same, your tests shouldn't break. Test the observable outcome, not the path taken to get there.
Skipping E2E tests for UI-heavy plugins: If your plugin has a custom dashboard, form-heavy settings, or admin UI, E2E tests are non-negotiable. A button moving two pixels or a validation error disappearing is not caught by unit tests.
Not mocking external APIs: Tests that hit real external services are flaky and slow. Mock HTTP calls using libraries like php-http/mock-client or Playwright's network interception. Real integration testing happens in staging.
Ignoring WordPress database isolation: The WP test suite rolls back database changes after each test, but only if you use the factory methods. Direct SQL queries or code that expects data to persist across tests will cause subtle failures.
Key takeaways
- Start with unit tests for business logic and security-critical code; they're fast and cheap to write.
- Add integration tests for WordPress hooks, filters, and database interactions; they validate real-world plugin behavior.
- Use E2E tests for critical user workflows only—settings pages, form submission, workflows that touch the UI. Don't use them for business logic.
- Set up CI to run tests across multiple PHP and WordPress versions. Version incompatibilities are caught before release, not by users.
- Test behavior and outcomes, not implementation details. Brittle tests slow down refactoring and create false confidence.
- Mock external dependencies and third-party APIs. Keep tests reliable and fast by avoiding network calls and external state.
- Balance test coverage with development velocity. 80% coverage with realistic, maintainable tests beats 100% coverage with fragile tests that slow down the team.






Comments
Loading comments…