WordPress Plugin Testing with Docker and PHPUnit
Setting up PHPUnit testing for WordPress is a multi-hour obstacle course, and most of it has nothing to do with writing tests. You install WordPress, you install Composer, you install the WordPress test suite, you wrangle SVN, you configure the database, you debug why Xdebug won’t load, and somewhere in hour three, you’re staring at a failing test, wondering if you even installed it right.
Then you commit to CI, and it fails anyway because the environment isn’t the same as your laptop.
This was my experience more than once. If you’ve written a plugin or theme and want to test it properly, but the setup friction has stopped you – this article is about ending that cycle. If you haven’t tested your WordPress code yet and want a frictionless entry point, this works for you, too.
I built a Docker image that provides a fully functional WordPress testing environment with one command and guarantees your local tests run exactly like your CI tests. No setup steps. No environment drift. One image, same results everywhere. I’ve also published a scaffold repository (wp-phpunit-scaffold) that shows a working example you can reference or clone and customise for your own plugin.
The Problem With Setup Friction
WordPress testing requires a specific stack:
- WordPress itself – you need the full codebase, not just headers
- Subversion – the WordPress test suite lives in an SVN repo
- MySQL – the test suite creates a temporary test database
- PHP with specific extensions – intl for i18n testing, Xdebug for coverage
- A working network path between them all – ports, permissions, environment variables
Do that once on your machine, and you’ve got a working setup. Do it again on a fresh machine? On your CI runner? In your team’s Docker setup? You’re not just installing tools – you’re installing friction.
The second problem is sneakier: parity. Your laptop is running PHP 8.2, your CI is running 8.4. Your MySQL is old, production MySQL is new. Your tests pass locally but fail in CI because the environments have quietly diverged, and you only find out when the CI run turns red. You spend hours hunting an environment bug, not a code bug.
The Solution: A Pre-Built Image
This Docker image packages everything: WordPress (latest), PHP 7.4 through 8.4, Apache, MySQL client, SVN, Xdebug, and the intl extension. One docker pull and you’ve got a guaranteed test environment that works the same on your machine, your CI runner, and your team’s laptops. No installation steps. No “wait, did you install SVN?” No environment variable hunting.
What’s in the Image and Why It Matters
WordPress and PHP
The image is built on the official WordPress Docker image and supports PHP versions 7.4 through 8.4. Pick the version that matches your production environment or your compatibility target. That means WordPress core is loaded for each test run, so your code runs against real WordPress functions instead of stubs you wrote yourself. You’re validating real behaviour – hooks, filters, the database layer.
Subversion
The WordPress test suite lives in an SVN repository. PHPUnit doesn’t come bundled with WordPress tests – you download them separately via SVN. Without SVN installed, you can’t bootstrap the test suite at all. It’s a hidden dependency that repeatedly trips up first-timers. The image installs it in the base layer, so you never think about it.
MySQL Client
The test suite needs to talk to MySQL. It creates a temporary test database, runs your tests against it, then tears it down. You need the mysql command-line tool available for that to work. It’s not the MySQL server itself – that usually runs in a separate container – but the client that talks to it. Missing it means silent failures during test bootstrap.
Intl Extension
If your plugin or theme does any internationalization (translation strings, locale-aware formatting, that kind of thing), you need PHP’s intl extension. It’s not installed by default on many systems. The image includes it so your i18n tests work locally the same way they work in production – no surprises on deploy.
Xdebug for Code Coverage
Xdebug lets PHPUnit measure code coverage – which lines of your code actually ran during tests. That highlights gaps in your test suite (lines that never ran) and lets CI systems (GitHub, GitLab, etc.) enforce minimum coverage thresholds. The image configures Xdebug in coverage mode and ready to go. No hunting for php.ini, no wrestling with config files.
Getting the Image
Pull the image from Docker Hub:
docker pull pattonwebz/phpunit-wordpress:latest
Available tags:
latest– Latest build (PHP 8.4)8.4,8.3,8.2,8.1,8.0,7.4– Specific PHP versionsmain– Latest from main branch
The image is built for both AMD64 and ARM64 architectures, so it works on your MacBook M1 and your Linux CI runner. For a working example of how to use this image, you can reference or clone wp-phpunit-scaffold, which provides a complete Docker Compose setup, helper scripts, and a GitHub Actions workflow.
Local Workflow: Docker Compose
Here’s a practical setup using Docker Compose. Create this file as docker-compose.yml in your plugin root:
services:
wordpress-test:
image: pattonwebz/phpunit-wordpress:8.4
ports:
- "8080:80"
environment:
- WORDPRESS_DB_HOST=db
- WORDPRESS_DB_USER=wordpress
- WORDPRESS_DB_PASSWORD=wordpress
- WORDPRESS_DB_NAME=wordpress
- DB_HOST=db
- DB_USER=wordpress
- DB_PASSWORD=wordpress
- DB_NAME=wordpress
volumes:
- ./your-plugin:/var/www/html/wp-content/plugins/your-plugin
depends_on:
- db
db:
image: mysql:8.0
environment:
- MYSQL_DATABASE=wordpress
- MYSQL_USER=wordpress
- MYSQL_PASSWORD=wordpress
- MYSQL_ROOT_PASSWORD=password
volumes:
- db_data:/var/lib/mysql
volumes:
db_data:
Change the PHP version tag (8.4) to match your target PHP version (7.4, 8.0, 8.1, 8.2, 8.3, or 8.4).
Prerequisites:
- Docker Desktop is installed and running on your machine
- Composer installed locally (the image doesn’t run Composer; you manage dependencies on your machine)
- An existing plugin directory or a new one you’re starting from scratch
Startup:
docker compose up -d
That starts two containers: the test WordPress environment and MySQL. Your plugin is mounted as a volume, so changes on your laptop are instantly visible inside the container. The database container creates a persistent volume so data survives container restarts.
Install the WordPress test suite (one-time setup):
docker compose exec wordpress-test \
bash -c "bash /var/www/html/wp-content/plugins/your-plugin/tests/scripts/install-wp-tests.sh wordpress_test root password db"
This downloads the WordPress test library via SVN and sets it up in the container. You only need to run this once per environment.
To run tests:
docker compose exec wordpress-test \
bash -c "cd /var/www/html/wp-content/plugins/your-plugin && vendor/bin/phpunit"
Tests run inside the container in the exact environment you’ll test in CI. No surprises.
A Working Plugin Example
Here’s a minimal but complete plugin structure:
your-plugin/
├── your-plugin.php # Main plugin file
├── phpunit.xml.dist # PHPUnit config
├── composer.json # Dependencies (if any)
└── tests/
├── bootstrap.php # Test bootstrap
└── class-plugin-test.php # Test cases
The main plugin file (your-plugin.php):
<?php
/**
* Plugin Name: Your Plugin
* Description: A test plugin
* Version: 1.0.0
*/
namespace YourPlugin;
class Plugin {
public static function activate() {
// Plugin activation logic
}
public static function get_user_role( $user_id ) {
$user = get_userdata( $user_id );
return $user ? $user->roles[0] : null;
}
public static function fetch_external_data() {
$response = wp_remote_get( 'https://api.example.com/data' );
if ( is_wp_error( $response ) ) {
return false;
}
return true;
}
}
register_activation_hook( __FILE__, [ Plugin::class, 'activate' ] );
The phpunit.xml.dist configuration:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
beStrictAboutOutputDuringTests="true"
failOnWarning="true"
>
<testsuites>
<testsuite name="Plugin Tests">
<directory suffix="-test.php">tests/</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">.</directory>
</include>
<exclude>
<directory suffix="-test.php">tests/</directory>
<directory>vendor/</directory>
</exclude>
</coverage>
<report>
<clover outputFile="coverage.xml"/>
</report>
</phpunit>
The bootstrap file (tests/bootstrap.php) is the critical piece. It sets up the WordPress test environment. Here’s the complete version:
<?php
// Determine the tests directory
$_tests_dir = getenv( 'WP_TESTS_DIR' );
if ( ! $_tests_dir ) {
$_tests_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/wordpress-tests-lib';
}
// Give access to tests_add_filter() function
require_once $_tests_dir . '/includes/functions.php';
// Load the plugin so tests can access its classes
tests_add_filter( 'muplugins_loaded', function() {
require_once dirname( __DIR__ ) . '/your-plugin.php';
} );
// Start up the WP testing environment
require_once $_tests_dir . '/includes/bootstrap.php';
The install-wp-tests.sh script creates the test configuration file automatically in the test suite directory. The bootstrap simply points to that directory, then the WordPress test suite bootstrap handles the rest.
The actual test class (tests/class-plugin-test.php). Notice the progression: pure unit test, WordPress integration test, and graceful error handling:
<?php
namespace YourPlugin\Tests;
use YourPlugin\Plugin;
class PluginTest extends \WP_UnitTestCase {
// Test 1: Pure PHP: proves PHPUnit is running
public function test_plugin_loaded() {
$this->assertTrue( defined( 'ABSPATH' ) );
}
// Test 2: WordPress function: proves bootstrap loaded WP correctly
public function test_get_user_role_with_valid_user() {
$user_id = $this->factory->user->create( [ 'role' => 'editor' ] );
$role = Plugin::get_user_role( $user_id );
$this->assertEquals( 'editor', $role );
}
// Test 3: Non-existent user returns null: confirms graceful handling of missing users
public function test_get_user_role_with_nonexistent_user() {
$role = Plugin::get_user_role( 99999 );
$this->assertNull( $role );
}
}
Run the tests:
docker compose exec wordpress-test \
bash -c "cd /var/www/html/wp-content/plugins/your-plugin && vendor/bin/phpunit"
You’ll see:
PHPUnit 11.0.x by Sebastian Bergmann and contributors.
...
OK (3 tests, 3 assertions)
The Mocking Gotcha: Brain Monkey
Here’s where most WordPress developers hit a wall: you can’t easily mock WordPress functions in tests.
WordPress is built on global functions. get_post(), get_option(), wp_remote_get() – they’re everywhere. In a traditional unit test, you’d mock external dependencies to isolate the code you’re testing: test just your logic, not the frameworks it calls. But WordPress functions aren’t classes; they’re functions. You can’t just replace them.
Mocking (in a testing context) means replacing a real dependency with a fake one that returns predetermined values. It isolates your code so tests validate only your logic, not the external code it depends on. That’s where Brain Monkey comes in. It’s a mocking library built specifically for WordPress. It lets you stub and mock WordPress functions so your tests don’t depend on a real WordPress database or external APIs.
Install it via Composer:
composer require --dev brain/monkey
If your test class uses Brain Monkey mocks, add this import at the top of your test class file:
use Brain\Monkey;
Then add the setup and teardown boilerplate at the beginning of your test class:
protected function setUp(): void {
parent::setUp();
Monkey\setUp();
}
protected function tearDown(): void {
Monkey\tearDown();
parent::tearDown();
}
Now you can add a test method that mocks an external API call:
public function test_fetch_external_data_with_mock() {
Monkey\Functions\expect( 'wp_remote_get' )
->once()
->with( 'https://api.example.com/data' )
->andReturn( [
'response' => [ 'code' => 200 ],
'body' => json_encode( [ 'status' => 'ok' ] ),
] );
$result = Plugin::fetch_external_data();
$this->assertTrue( $result );
}
Key point: expect() must always be set up before the code under test runs. The expectation defines the mock, then your code calls it, and the assertion validates it was called correctly.
Brain Monkey is where it comes from: packagist.org/packages/brain/monkey. It’s a standalone package maintained by Giuseppe Mazzapica. It’s not part of WordPress or PHPUnit – it’s a bridge between them.
How it works: Brain Monkey takes over PHP’s function resolution at runtime. When you mock wp_remote_get(), Brain Monkey intercepts calls to it and returns your stub instead. That’s why you call Monkey\setUp() at the start of each test and Monkey\tearDown() at the end – it cleans up the function hijacking so tests don’t interfere with each other.
Without Brain Monkey, you’re stuck testing through real WordPress functions – which means real database hits, real HTTP calls, real slow tests. With it, you test just your code, not WordPress’s.
CI/CD: GitHub Actions
The same image works in GitHub Actions. Here’s a complete workflow that avoids the common pitfalls:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: wordpress_test
options: --health-cmd="mysqladmin ping -h 127.0.0.1 -u root -ppassword" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
tools: composer
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Run tests
run: |
docker run --rm \
--network host \
-v ${{ github.workspace }}:/var/www/html/wp-content/plugins/your-plugin \
-e DB_HOST=127.0.0.1 \
-e DB_NAME=wordpress_test \
-e DB_USER=root \
-e DB_PASSWORD=password \
-e XDEBUG_MODE=coverage \
pattonwebz/phpunit-wordpress:8.2 \
bash -c "cd /var/www/html/wp-content/plugins/your-plugin && bash tests/scripts/install-wp-tests.sh wordpress_test root password 127.0.0.1 && vendor/bin/phpunit"
- name: Upload coverage to Codecov (optional)
if: success()
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
continue-on-error: true
Key details that avoid silent CI failures:
- Health check on MySQL service: Without the health check options, the job starts before MySQL is ready, and tests fail with connection timeouts that appear to be test failures.
- DB_HOST set to 127.0.0.1, not localhost: PHP’s socket behaviour causes silent connection failures when you use localhost inside a container.
- Composer install step: Dependencies must be installed on the CI runner before the Docker image runs. This includes PHPUnit itself (via Composer) and any other dependencies your tests need.
- Test suite install inside Docker: The
install-wp-tests.shscript is called inside the container. This downloads the WordPress test library via SVN. It must run before PHPUnit. - XDEBUG_MODE=coverage: Required to generate the coverage.xml file. The Codecov upload step is optional – it requires a Codecov account and setup. Remove it if you don’t need external coverage tracking.
The container connects to the MySQL service on the GitHub Actions host network. Everything else is identical to your local tests. Same image, same commands, same results.
What’s Next
You’ve got a working test setup. Start here: run tests locally with Docker Compose. Write one test for a critical code path in your plugin – user registration, data validation, whatever matters most. Get that passing locally, then push to GitHub and watch CI run the same test in the same environment.
Once that’s working, expand incrementally. Each test you write documents how your code should behave. Future you and future contributors will thank you. Aim for at least 70% coverage on critical paths. Don’t chase 100%; focus on testing the behaviours that matter.
The environment is handled. Go write the test.