π 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:
| Tool | What it's for |
|---|---|
WP_UnitTestCase | Base class for integration tests; wraps each test in a DB transaction that rolls back |
WP_Ajax_UnitTestCase | Testing admin-ajax.php handlers |
WP_Test_REST_TestCase | Testing REST API routes and controllers |
| WP-CLI | Scaffolds test files and installs the test database |
| wp-env | Official Docker-based local WordPress for tests and E2E (from the Gutenberg project) |
| Brain Monkey | Mocks WordPress functions and hooks for driver-free unit tests |
Playwright / @wordpress/e2e-test-utils | Browser-level end-to-end tests, the current standard for WP core |
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
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:
- Create three published posts with a factory.
- Capture the widget's output with
ob_start()and assert each title appears. - Assert the
countsetting limits how many<li>items render. - Test
update(): a submitted count of99should be clamped to10, and0to1.
π‘ 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 data | Hand-writing SQL INSERT statements |
| Rely on the automatic transaction rollback | Manually deleting posts in tear_down() |
| Mock core with Brain Monkey for pure logic | Booting full WP to test one if statement |
| Assert on rendered output and status codes | Asserting 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 journeys | Recreating 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_shortcodeand 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
- WordPress Core Handbook β Automated Testing
- @wordpress/env (wp-env) documentation
- Brain Monkey β mocking WordPress functions
π 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.