Skip to main content

πŸ”Œ WordPress Testing Strategies

WordPress powers a huge share of the web, and its plugin-and-theme architecture creates testing challenges you won't meet in a plain PHP app. This lesson shows you the WordPress testing ecosystem β€” the integration suite built on PHPUnit, factories, hook testing, REST API tests, fast function mocking, and modern end-to-end flows β€” so your themes and plugins keep working across versions.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the layers of the WordPress testing ecosystem and when to use each
  • Scaffold and configure a plugin test suite with WP-CLI or wp-env
  • Write WP_UnitTestCase tests using factories to create posts, users, and terms
  • Test hooks (actions/filters), shortcodes, and REST API endpoints
  • Mock WordPress core functions with Brain Monkey for fast, isolated unit tests

Estimated Time: 50–65 minutes  β€’  Difficulty: Intermediate–Advanced

Hands-on: Write a test suite for a custom "Recent Posts" widget.

In This Lesson

Why WordPress Is Different

A plain PHP class is easy to test in isolation. WordPress code rarely is: it leans on global functions (get_post(), wp_insert_post()), a web of hooks (actions and filters), and a live database with dozens of core tables. Much of a plugin's job is to interact correctly with that environment.

πŸ’‘ A useful analogy: Testing WordPress is like quality-checking a piece of modular furniture. Each drawer (plugin, theme) must work on its own, but it also has to slot cleanly into the frame (core) and sit alongside the other pieces (other plugins). You test both the drawer alone and the assembled unit.

That split gives you two complementary strategies, and you need both:

  • Integration tests boot a real WordPress and a test database, so you exercise your code against genuine core behaviour. Accurate, but slower.
  • Isolated unit tests mock core functions so no database is needed. Blazing fast, but they only prove your logic, not that core behaves as you assumed.

The Testing Ecosystem

WordPress ships its own test framework β€” a set of PHPUnit base classes and helpers tailored to WP. Here are the pieces you'll meet:

ToolWhat it's for
WP_UnitTestCaseBase class for integration tests; wraps each test in a DB transaction that rolls back
WP_Ajax_UnitTestCaseTesting admin-ajax.php handlers
WP_Test_REST_TestCaseTesting REST API routes and controllers
WP-CLIScaffolds test files and installs the test database
wp-envOfficial Docker-based local WordPress for tests and E2E (from the Gutenberg project)
Brain MonkeyMocks WordPress functions and hooks for driver-free unit tests
Playwright / @wordpress/e2e-test-utilsBrowser-level end-to-end tests, the current standard for WP core
WordPress test layers by speed and realism Three stacked bands: fast isolated unit tests with Brain Monkey at the bottom, integration tests with WP_UnitTestCase in the middle, and end-to-end browser tests at the top. E2E β€” Playwright + wp-env real browser, whole app, slowest Integration β€” WP_UnitTestCase real WordPress + test database Unit β€” Brain Monkey (mocked core) no database, fastest, many tests
Figure 1 β€” The same pyramid you saw for plain PHP, mapped onto WordPress tools. Lean on the fast bottom layer; keep the slow top layer small.

Setting Up the Environment

The classic way: WP-CLI scaffolding

For a plugin, WP-CLI generates the test files and an installer script for the test database:

# From your plugin directory
cd wp-content/plugins/my-plugin

# Generate tests/, bootstrap.php and phpunit.xml.dist
wp scaffold plugin-tests my-plugin

# Install the WordPress test suite + a throwaway test database
bash bin/install-wp-tests.sh wordpress_test root '' localhost latest

⚠️ The test database is disposable

The installer creates a dedicated wordpress_test database and wipes it on every run. Never point it at your development or production database β€” it will erase the tables it manages.

The modern way: wp-env

Most teams today use wp-env, the official Docker-based environment. It spins up WordPress, MySQL, and a ready-to-run test setup with two commands β€” no manual SVN checkouts or database juggling:

# One-time install
npm install -g @wordpress/env

# Start WordPress (dev on :8888, tests on :8889)
wp-env start

# Run the plugin's PHPUnit suite inside the container
wp-env run tests-cli --env-cwd=wp-content/plugins/my-plugin \
  vendor/bin/phpunit
flowchart LR A[wp-env start] --> B[WordPress + MySQL in Docker] B --> C[Composer install] C --> D[vendor/bin/phpunit] D --> E{green?} E -->|yes| F[commit / CI] E -->|no| G[fix & rerun] G --> D

Writing Integration Tests

Integration tests extend WP_UnitTestCase. Its biggest gift: each test runs inside a database transaction that is rolled back automatically afterwards, so tests never leak data into each other.

<?php
// tests/test-posts.php
class Posts_Test extends WP_UnitTestCase
{
    public function test_a_published_post_is_retrievable(): void
    {
        $post_id = self::factory()->post->create([
            'post_title'   => 'Test Post',
            'post_content' => 'Hello world',
            'post_status'  => 'publish',
        ]);

        $post = get_post($post_id);

        $this->assertSame('Test Post', $post->post_title);
        $this->assertSame('publish', $post->post_status);
    }
}

πŸ“– Factories

A factory creates valid test data with one call, filling in sensible defaults for everything you don't specify. WordPress provides factories for posts, users, terms, comments, and more via self::factory() (the modern static form; the older $this->factory still works).

// A user with a role
$editor_id = self::factory()->user->create(['role' => 'editor']);

// Five posts authored by that user, in one call
$post_ids = self::factory()->post->create_many(5, ['post_author' => $editor_id]);

// A category term, then a comment on the first post
$cat_id     = self::factory()->term->create(['taxonomy' => 'category', 'name' => 'News']);
$comment_id = self::factory()->comment->create([
    'comment_post_ID' => $post_ids[0],
    'comment_content' => 'Nice article!',
]);

Testing Hooks & Shortcodes

Hooks are how plugins plug in, so "did my plugin register the right callback, and does that callback do the right thing?" is a core question to test.

A registered filter

public function test_plugin_registers_content_filter(): void
{
    $plugin = new My_Plugin();
    $plugin->init();

    // has_filter returns the priority (an int) when registered, or false
    $this->assertNotFalse(
        has_filter('the_content', [$plugin, 'filter_content'])
    );
}

public function test_content_filter_appends_a_notice(): void
{
    $plugin   = new My_Plugin();
    $filtered = $plugin->filter_content('Original.');

    $this->assertStringContainsString('Original.', $filtered);
    $this->assertStringContainsString('Read more', $filtered);
}

A shortcode

public function test_shortcode_is_registered_and_renders(): void
{
    (new My_Plugin())->init();

    $this->assertTrue(shortcode_exists('my_box'));

    // do_shortcode runs the registered callback and returns its output
    $html = do_shortcode('[my_box title="Hi"]');
    $this->assertStringContainsString('Hi', $html);
    $this->assertStringContainsString('class="my-box"', $html);
}

πŸ’‘ Test output, not implementation

Assert on what the shortcode produces (the HTML a visitor sees), not on the private helper methods that built it. That way you can refactor the internals freely and the test still guards the behaviour that matters.

Testing the REST API

If your plugin exposes REST routes, dispatch requests through a real WP_REST_Server and assert on the response. This exercises your route registration, permission callbacks, and controllers together.

<?php
class REST_Items_Test extends WP_Test_REST_TestCase
{
    protected WP_REST_Server $server;

    public function set_up(): void
    {
        parent::set_up();
        global $wp_rest_server;
        $this->server = $wp_rest_server = new WP_REST_Server();
        do_action('rest_api_init');
    }

    public function test_route_is_registered(): void
    {
        $routes = $this->server->get_routes();
        $this->assertArrayHasKey('/my-plugin/v1/items', $routes);
    }

    public function test_creating_an_item_requires_auth(): void
    {
        $request  = new WP_REST_Request('POST', '/my-plugin/v1/items');
        $request->set_body_params(['title' => 'New']);
        $response = $this->server->dispatch($request);

        // Anonymous request is rejected
        $this->assertSame(401, $response->get_status());
    }

    public function test_admin_can_create_an_item(): void
    {
        wp_set_current_user(
            self::factory()->user->create(['role' => 'administrator'])
        );

        $request = new WP_REST_Request('POST', '/my-plugin/v1/items');
        $request->set_body_params(['title' => 'New']);
        $response = $this->server->dispatch($request);

        $this->assertSame(201, $response->get_status());
        $this->assertSame('New', $response->get_data()['title']);
    }
}

⚠️ Modern fixture method names

Recent WordPress test suites use set_up() / tear_down() (snake_case) as the fixture hooks to avoid clashing with PHPUnit's own signature changes. If you're on an older suite you may still see setUp() β€” match whatever your installed version uses, and always call parent::set_up() first.

Fast Unit Tests with Brain Monkey

Booting WordPress for every test is slow. When you only want to test your logic β€” not core β€” Brain Monkey lets you mock WordPress functions and hooks so no database or WP install is needed. These tests extend plain PHPUnit, not WP_UnitTestCase.

composer require --dev brain/monkey
<?php
namespace Tests\Unit;

use Brain\Monkey;
use Brain\Monkey\Functions;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;

class Settings_Test extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Monkey\setUp();
    }

    protected function tearDown(): void
    {
        Monkey\tearDown();
        parent::tearDown();
    }

    #[Test]
    public function it_reads_the_stored_api_key(): void
    {
        // Stub a core function's return value
        Functions\when('get_option')
            ->justReturn(['api_key' => 'secret-123']);

        $settings = new \My_Plugin\Settings();

        $this->assertSame('secret-123', $settings->apiKey());
    }

    #[Test]
    public function init_registers_the_enqueue_hook(): void
    {
        // Expect a call, and assert its arguments
        Functions\expect('add_action')
            ->once()
            ->with('wp_enqueue_scripts', \Mockery::type('callable'));

        (new \My_Plugin\Assets())->init();
        // Brain Monkey verifies the expectation on tearDown()
    }
}

πŸ’‘ when() vs expect()

Use Functions\when() to stub a return value you don't care to verify, and Functions\expect() to assert a function was called (how often, with what). Reserve expect() for the interactions that are actually the point of the test β€” over-asserting makes tests brittle.

End-to-End with wp-env

For flows that span the browser, the database, and admin UI β€” "an editor logs in, creates a product, and sees it on the front end" β€” use end-to-end tests. WordPress core has standardised on Playwright driving a wp-env instance.

// tests/e2e/settings.spec.js
import { test, expect } from '@wordpress/e2e-test-utils-playwright';

test( 'admin can save plugin settings', async ( { admin, page } ) => {
    await admin.visitAdminPage( 'options-general.php', 'page=my-plugin' );

    await page.getByLabel( 'API Key' ).fill( 'new-value' );
    await page.getByRole( 'button', { name: 'Save Changes' } ).click();

    await expect( page.getByText( 'Settings saved.' ) ).toBeVisible();
    await expect( page.getByLabel( 'API Key' ) ).toHaveValue( 'new-value' );
} );

E2E tests are the slowest and most brittle, so keep them for a handful of critical journeys. Cover the details underneath with the faster integration and unit layers.

Hands-on Exercise

πŸ‹οΈ Test a Recent-Posts Widget

Objective: Write integration tests for a widget that renders a list of recent posts. Practise factories, output capture, and settings validation.

Here is the class under test (trimmed to the essentials):

<?php
class Recent_Posts_Widget extends WP_Widget
{
    public function widget($args, $instance): void
    {
        $count = ! empty($instance['count']) ? absint($instance['count']) : 5;
        $posts = get_posts(['posts_per_page' => $count, 'post_status' => 'publish']);

        echo '<ul class="recent-posts">';
        foreach ($posts as $post) {
            printf('<li><a href="%s">%s</a></li>',
                esc_url(get_permalink($post)), esc_html(get_the_title($post)));
        }
        echo '</ul>';
    }

    public function update($new, $old): array
    {
        return ['count' => min(10, max(1, absint($new['count'] ?? 5)))];
    }
}

Your task:

  1. Create three published posts with a factory.
  2. Capture the widget's output with ob_start() and assert each title appears.
  3. Assert the count setting limits how many <li> items render.
  4. Test update(): a submitted count of 99 should be clamped to 10, and 0 to 1.
πŸ’‘ Hint

widget() echoes rather than returns, so wrap the call in ob_start() / $out = ob_get_clean();. Count list items with substr_count($out, '<li>'). update() returns an array, so you can assert on it directly with no output buffering.

βœ… Sample solution
<?php
class Recent_Posts_Widget_Test extends WP_UnitTestCase
{
    private Recent_Posts_Widget $widget;

    public function set_up(): void
    {
        parent::set_up();
        $this->widget = new Recent_Posts_Widget();
    }

    public function test_it_renders_recent_post_titles(): void
    {
        self::factory()->post->create(['post_title' => 'Alpha', 'post_status' => 'publish']);
        self::factory()->post->create(['post_title' => 'Beta',  'post_status' => 'publish']);

        ob_start();
        $this->widget->widget([], ['count' => 5]);
        $out = ob_get_clean();

        $this->assertStringContainsString('Alpha', $out);
        $this->assertStringContainsString('Beta', $out);
    }

    public function test_count_setting_limits_items(): void
    {
        self::factory()->post->create_many(6, ['post_status' => 'publish']);

        ob_start();
        $this->widget->widget([], ['count' => 3]);
        $out = ob_get_clean();

        $this->assertSame(3, substr_count($out, '<li>'));
    }

    public function test_update_clamps_the_count(): void
    {
        $this->assertSame(10, $this->widget->update(['count' => 99], [])['count']);
        $this->assertSame(1,  $this->widget->update(['count' => 0],  [])['count']);
    }
}

Best Practices

βœ… Do❌ Avoid
Use factories for all test dataHand-writing SQL INSERT statements
Rely on the automatic transaction rollbackManually deleting posts in tear_down()
Mock core with Brain Monkey for pure logicBooting full WP to test one if statement
Assert on rendered output and status codesAsserting on private internals
Run the suite in CI against multiple WP/PHP versions"Works on my machine" as the only proof
Keep a few high-value E2E journeysRecreating every unit test as a slow E2E test

βœ… Test the matrix

WordPress plugins run on many combinations of WordPress and PHP versions. Configure your CI (GitHub Actions works well with wp-env) to run the suite across the versions you support, so an incompatibility surfaces before a user hits it.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • WordPress needs both integration tests (real WP + DB) and fast unit tests (mocked core).
  • WP_UnitTestCase rolls back the database after every test, keeping tests isolated.
  • Factories (self::factory()) create posts, users, terms, and comments in one line.
  • Test hooks with has_filter/do_shortcode and the REST API by dispatching real requests.
  • Brain Monkey mocks core functions for driver-free unit tests; wp-env + Playwright handle E2E.

🎯 Quick Quiz

Question 1: What does extending WP_UnitTestCase give you that plain TestCase does not?

Question 2: When would you reach for Brain Monkey instead of the WordPress test suite?

Question 3: A widget's widget() method echoes HTML. How do you capture it in a test?

πŸ“š Further Reading

πŸš€ What's Next?

You've mocked core functions here β€” next we go deeper into the mocking toolkit itself: the full family of test doubles and the dependency-injection design that makes them possible.

πŸŽ‰ Great work!

You can now test WordPress from a single filter callback all the way up to a full browser journey. Your plugins and themes are ready to survive the next core update.