When we talk about adding “tests for a WordPress plugin”, we can talk about two different kind of tests:
- Unit tests: run in PHP without bootstrapping WordPress. They’re fast, and they force you to keep logic isolated.
- Integration tests: bootstrap WordPress and exercise real core behavior (database, hooks, factories). They’re slower, but they catch the stuff mocks can’t. WordPress itself often calls these “unit tests”, but once core is loaded you’re really testing integration.
In this article, I go step-by-step through setting up robust testing for a WordPress plugin—starting from a basic my-awesome-plugin example.
We’ll create a complete, repeatable workflow: using Composer for PHP dependency management, writing unit tests with Brain Monkey, and running integration tests in isolated Docker containers via wp-env.
The final project will have the following setup:
- A Composer setup with PSR-4 autoloading and test dependencies.
- Two PHPUnit configurations:
phpunit.unit.xml(unit tests, no WordPress)phpunit.xml.dist(integration tests, WordPress bootstrapped)- A minimal
wp-envconfig so integration tests run in clean Docker containers.
Unit vs Integration tests
WordPress plugin testing requires two distinct approaches: unit tests for isolated functionality and integration tests for WordPress-dependent features. This section covers how to set up both test types in a unified testing framework.
Unit tests:
- Fast execution (1-2 seconds for simple test suites)
- No WordPress dependencies
- Test isolated functions and classes
- Use PHPUnit’s
TestCasebase class - Perfect for development workflow
Integration tests:
- Slower execution (5-10+ seconds depending on complexity)
- Full WordPress test environment
- Test complete workflows and WordPress functionality
- Use WordPress’s
WP_UnitTestCasebase class - Essential for deployment validation
Create the test directory structure
Let’s start our setup by creating a project structure that splits unit and integration tests up front.
From your plugin directory:
cd wp-content/plugins/my-awesome-pluginThe following structure stays readable over time:
my-awesome-plugin/
├── .wp-env.json
├── composer.json
├── package.json
├── phpunit.xml.dist
├── phpunit.unit.xml
├── src/
│ └── Helpers.php
├── my-awesome-plugin.php
└── tests/
├── bootstrap.php
├── integration/
│ └── PluginActivationTest.php
└── unit/
├── bootstrap.php
└── HelperFunctionsTest.phpThe separation is less about ideology and more about avoiding accidental coupling: if a “unit” test needs WordPress, it belongs somewhere else.
The https://github.com/juanma-wp/demo-plugin-with-tests repo contains the full code of the project explained in this article
Set Composer for your plugin
From the plugin directory initialize composer :
composer init --name=my-vendor-name/my-package-name --no-interactionConfigure PSR-4 autoloading
Edit composer.json so your plugin classes autoload from src/:
{
"name": "my-vendor-name/my-package-name",
"autoload": {
"psr-4": {
"MyAwesome\\Plugin\\": "src/"
}
}
}The autoload part sets Composer’s PSR-4 autoloading to automatically load PHP classes. The namespace prefix MyAwesome\Plugin\ is mapped to the src/ directory, meaning any class under that namespace is resolved to a matching file path inside src/ without manual require statements. Including Composer’s vendor/autoload.php is enough to make all plugin classes available.
After adding or modifying the autoload section, you must regenerate Composer’s autoloader so the new namespace mapping is compiled into Composer’s internal metadata. This does not install or update dependencies; it only regenerates autoload files under vendor/, including:
vendor/autoload.php, the single entry point your plugin or test bootstrap includesvendor/composer/autoload_psr4.php, which contains the PSR-4 namespace-to-directory mappings (for example,MyAwesome\Plugin\→src/)vendor/composer/autoload_classmap.php, used when class maps are presentvendor/composer/autoload_files.php, for files that must be loaded eagerlyvendor/composer/autoload_real.php, which wires everything together at runtime
Run the following command to rebuild these mappings:
composer dump-autoloadInstall testing dependencies
Install the core set:
composer require --dev \
phpunit/phpunit:^9 \
yoast/phpunit-polyfills:^2 \
brain/monkey:^2These dependencies will be added to the composer.json configuration file
1. phpunit/phpunit
PHPUnit is the standard testing framework for PHP. It provides:
- The
phpunitCLI runner. - A structure for defining test classes and methods.
- A library of assertions like
$this->assertSame(),$this->assertTrue(), etc.
WordPress core and its testing suite are built on PHPUnit. Whether you’re writing fast unit tests or WordPress-bootstrapped integration tests, PHPUnit is the engine that executes them.
Example
use PHPUnit\Framework\TestCase;
class MathTest extends TestCase {
public function test_adds_numbers() {
$this->assertSame(4, 2 + 2);
}
}2. yoast/phpunit-polyfills
A compatibility layer from the Yoast team that provides polyfills for PHPUnit features, ensuring the same test code works across multiple PHP and PHPUnit versions.
WordPress supports a wide range of PHP versions. PHPUnit changes between versions — methods get renamed, deprecated, or removed. Without polyfills, you’d need conditional code for different test runners. This library smooths those differences automatically.
Example
Example:
In PHPUnit 9, assertIsInt() exists; in older versions, it doesn’t. With the polyfill:
$this->assertIsInt( 123 );…will work on all supported versions without changes.
3. brain/monkey
A mocking library built for WordPress. It lets you simulate (mock) WordPress core functions, actions, and filters without actually loading WordPress.
Pure unit tests should run without bootstrapping WordPress — no database, no core files — to keep them fast and isolated. Brain Monkey makes this possible by letting you mock core functions and set expectations for hooks.
Example
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
final class ExampleTest extends TestCase {
protected function setUp(): void { Monkey\setUp(); }
protected function tearDown(): void { Monkey\tearDown(); }
public function test_get_option_mocked() {
Functions\when( 'get_option' )->justReturn( 'mocked' );
$this->assertSame( 'mocked', get_option( 'my_setting' ) );
}
}Set up wp-env for integration tests
Integration tests need a real WordPress + database. I prefer wp-env because it standardizes the environment, and it’s easy to reproduce in CI/CD pipelines.
Install @wordpress/env
Local install (keeps the project self-contained):
npm install --save-dev @wordpress/env@wordpress/env is actively maintained and published on npm.
Add a .wp-env.json
Create .wp-env.json at the plugin root:
{
"plugins": ["."],
"env": {
"tests": {
"mappings": {
"wp-content/plugins/my-awesome-plugin": "."
}
}
}
}This ensures the plugin directory is mounted into the test container at the path you’ll use when running commands.
Add npm scripts
In package.json:
{
"name": "my-awesome-plugin",
"private": true,
"scripts": {
"wp-env": "wp-env",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vendor/bin/phpunit --configuration phpunit.unit.xml",
"test:integration": "wp-env run tests-cli --env-cwd='wp-content/plugins/my-awesome-plugin' vendor/bin/phpunit --configuration=phpunit.xml.dist"
},
"devDependencies": {
"@wordpress/env": "^10.36.0"
}
}A couple of notes:
wp-env run tests-cliruns inside the Docker container.--env-cwdis what makes relative paths behave like you expect (it runs PHPUnit from inside your plugin directory).
Configure PHPUnit
Integration config: phpunit.xml.dist
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
bootstrap="tests/bootstrap.php"
colors="true"
beStrictAboutOutputDuringTests="true"
cacheResult="false">
<testsuites>
<testsuite name="integration">
<directory>tests/integration</directory>
</testsuite>
</testsuites>
</phpunit>This is the configuration that expects WordPress to be available via the bootstrap.
Unit config: phpunit.unit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
bootstrap="tests/unit/bootstrap.php"
colors="true"
beStrictAboutOutputDuringTests="true"
cacheResult="false">
<testsuites>
<testsuite name="unit">
<directory>tests/unit</directory>
</testsuite>
</testsuites>
</phpunit>This one is intentionally boring: it should not know WordPress exists.
Create bootstrap files
To make PHPUnit run correctly, you need a bootstrap file for each test type—these are small PHP scripts that set up each environment before the tests run.
- Integration tests need WordPress loaded and your plugin active. The bootstrap here loads the WordPress testing suite and manually loads your plugin.
- Unit tests don’t care about WordPress—they just need to load your classes and perhaps some helper functions.
Integration bootstrap: tests/bootstrap.php
This file checks WP_TESTS_DIR first, then falls back to the common wp-env path.
<?php
/**
* Bootstrap file for integration tests
*
* This bootstrap loads the WordPress test suite which provides:
* - Full WordPress environment
* - Database setup/teardown
* - WordPress core functions
*/
// Path to WordPress tests directory
$_tests_dir = getenv( 'WP_TESTS_DIR' );
if ( ! $_tests_dir ) {
// Default to wp-env location
$_tests_dir = '/wordpress-phpunit';
}
if ( ! file_exists( $_tests_dir . '/includes/functions.php' ) ) {
echo "Could not find $_tests_dir/includes/functions.php\n";
echo "Run: npm install && npm run wp-env start\n";
exit( 1 );
}
// Give access to tests_add_filter() function
require_once $_tests_dir . '/includes/functions.php';
/**
* Manually load the plugin being tested
*/
function _manually_load_plugin() {
require dirname( dirname( __FILE__ ) ) . '/my-awesome-plugin.php';
}
tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' );
// Start up the WP testing environment
require $_tests_dir . '/includes/bootstrap.php';Unit bootstrap: tests/unit/bootstrap.php
<?php
$autoload_path = dirname( __DIR__, 2 ) . '/vendor/autoload.php';
if ( ! file_exists( $autoload_path ) ) {
throw new Exception( 'Composer autoloader not found. Run "composer install" first.' );
}
require $autoload_path;This is the entire point: unit tests should start in milliseconds and fail loudly if dependencies aren’t installed.
Add minimal plugin code to test
src/Helpers.php
Create src/Helpers.php and add the following code that uses the WordPress function (wp_strip_all_tags) so we can demonstrate the unit/integration split properly:
<?php
namespace MyAwesome\Plugin;
class Helpers {
public static function sanitize_custom_field( string $input ): string {
return wp_strip_all_tags( $input );
}
}my-awesome-plugin.php
Create my-awesome-plugin.php in the root of the project with the following plugin’s code:
<?php
/**
* Plugin Name: My Awesome Plugin
* Description: Minimal plugin showcasing unit + integration testing setup
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
require_once __DIR__ . '/vendor/autoload.php';The unit test
Let’s create tests/unit/HelperFunctionsTest.php where ee’ll use Brain Monkey to mock WordPress functions for fast, isolated unit tests.
<?php
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use MyAwesome\Plugin\Helpers;
final class HelperFunctionsTest extends TestCase {
protected function setUp(): void {
parent::setUp();
Monkey\setUp();
}
protected function tearDown(): void {
Monkey\tearDown();
parent::tearDown();
}
public function test_sanitize_custom_field_calls_wp_strip_all_tags(): void {
$input = '<b>Hello</b> World';
$expected = 'Hello World';
Functions\expect( 'wp_strip_all_tags' )
->once()
->with( $input )
->andReturn( $expected );
$this->assertSame( $expected, Helpers::sanitize_custom_field( $input ) );
}
public function test_can_mock_simple_wordpress_functions(): void {
Functions\when( 'get_option' )->justReturn( 'test_value' );
$this->assertSame( 'test_value', get_option( 'my_setting' ) );
}
}Functions\expect() is the pattern I reach for when I want the test to prove a WordPress function was called with the right arguments.
The integration test
Create tests/integration/PluginActivationTest.php:
<?php
final class PluginActivationTest extends WP_UnitTestCase {
public function test_plugin_is_loaded(): void {
$this->assertTrue( class_exists( 'MyAwesome\\Plugin\\Helpers' ) );
}
public function test_wordpress_functions_work(): void {
update_option( 'my_test_option', 'test_value' );
$this->assertSame( 'test_value', get_option( 'my_test_option' ) );
}
public function test_helper_uses_real_wp_strip_all_tags(): void {
$this->assertSame(
'Hello World',
MyAwesome\Plugin\Helpers::sanitize_custom_field( '<b>Hello</b> World' )
);
}
}The code above is an example integration test suite for your WordPress plugin. It ensures that:
- The plugin is properly loaded and the main helper class exists in the WordPress environment (
test_plugin_is_loaded). - WordPress core functions like
update_optionandget_optionwork as expected, verifying a real database roundtrip (test_wordpress_functions_work). - Your custom helper method relies on the real WordPress function
wp_strip_all_tags, validating that integration with core functions is working in a full WordPress stack (test_helper_uses_real_wp_strip_all_tags).
Running Tests
Run integration tests (requires WordPress test environment):
# Start the containers first
npm run wp-env start
# Run the integration tests
npm run test:integrationRun unit tests (fast, no WordPress dependencies):
npm run test:unitRun both test suites:
npm run testRun specific test files:
# Single integration test
vendor/bin/phpunit tests/integration/test-custom-post-type.php
# Single unit test
vendor/bin/phpunit --configuration phpunit.unit.xml tests/unit/test-helper-functions.phpThis setup provides a robust foundation for testing WordPress plugins with both fast unit tests for development and comprehensive integration tests for deployment validation.
The unit test runs in milliseconds using mocked functions, while the integration test takes longer but provides real WordPress environment testing. Both approaches are essential for comprehensive plugin testing.
CI with GitHub Actions (minimal baseline)
A simple .github/workflows/tests.yml that mirrors local commands:
name: Run Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
- name: Install dependencies
run: |
composer install
npm install
- name: Run unit tests
run: npm run test:unit
- name: Start wp-env
run: npx wp-env start
- name: Run integration tests
run: npm run test:integration
The GitHub Actions used:
actions/checkout– checks out your repositoryactions/setup-node– installs Node.jsshivammathur/setup-php– installs PHP with extensions
Conclusion
Setting up separate unit and integration testing gives you the best of both worlds: fast feedback during development and thorough testing before deployment. This dual-testing approach is what professional WordPress teams use to maintain code quality.
You now have a testing framework that works with any development environment and supports both fast unit tests and comprehensive integration tests.
Leave a Reply