Why Unit Testing is Different in WordPress
Traditional unit tests assume your code is “pure” — it only manipulates data in memory, with no reliance on a database, HTTP calls, or global state. WordPress breaks that assumption immediately. Nearly every plugin or theme calls global functions like get_option(), wp_insert_post(), or apply_filters().
If your class calls get_option(), any test of that class becomes an integration test — it now requires a live WordPress database to pass. Integration tests are valuable but slow (seconds per test, not milliseconds), fragile when the database has unexpected data, and hard to run in CI without a database server. True unit testing isolates your business logic from WordPress’s global state by mocking those functions — replacing them with lightweight stubs that return whatever the test needs.
This guide covers mocking WordPress functions with Brain Monkey, testing hook registration, combining mocks with dependency injection, and wiring everything into GitHub Actions CI so your suite runs on every pull request.
Setting Up PHPUnit and Brain Monkey via Composer
Both tools install via Composer as dev dependencies. If your plugin doesn’t use Composer yet, the Composer for WordPress guide covers the initial setup — autoloading and namespacing your plugin classes is a prerequisite for a well-organized test suite.
In your plugin’s root directory:
composer require --dev phpunit/phpunit:^10 brain-monkey/brain-monkey:^2
Then add PSR-4 autoload entries for your source and test classes in composer.json:
{
"require-dev": {
"phpunit/phpunit": "^10",
"brain-monkey/brain-monkey": "^2"
},
"autoload": {
"psr-4": {
"MyPlugin\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"MyPlugin\\Tests\\": "tests/"
}
}
}
Run composer dump-autoload after editing. Plugin source classes live in src/; test classes mirror that path under tests/.
Configuring PHPUnit with phpunit.xml
Create a phpunit.xml file in your plugin root to define the bootstrap file, test directory, and source directory for coverage:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit Tests">
<directory>./tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>./src</directory>
</include>
</source>
</phpunit>
Because Brain Monkey stubs WordPress functions without loading WordPress, you only need vendor/autoload.php as the bootstrap — no database, no wp-tests-config.php, no full WordPress bootstrap. Run the suite with:
vendor/bin/phpunit
Writing Your First Unit Test with Brain Monkey
Here’s a typical WordPress plugin class that depends on get_option():
// src/UserValidator.php
namespace MyPlugin;
class UserValidator {
public function is_vip( int $user_id ): bool {
$vip_list = get_option( 'my_plugin_vips', [] );
return in_array( $user_id, $vip_list, true );
}
}
To test this without a database, we mock get_option() using Brain Monkey’s Functions\when() and Functions\expect():
// tests/UserValidatorTest.php
namespace MyPlugin\Tests;
use Brain\Monkey;
use Brain\Monkey\Functions;
use MyPlugin\UserValidator;
use PHPUnit\Framework\TestCase;
class UserValidatorTest extends TestCase {
protected function setUp(): void {
parent::setUp();
Monkey\setUp();
}
protected function tearDown(): void {
Monkey\tearDown();
parent::tearDown();
}
public function test_returns_true_for_vip_user(): void {
Functions\when( 'get_option' )->justReturn( [ 1, 5, 10 ] );
$validator = new UserValidator();
$this->assertTrue( $validator->is_vip( 5 ) );
}
public function test_returns_false_when_list_is_empty(): void {
Functions\when( 'get_option' )->justReturn( [] );
$validator = new UserValidator();
$this->assertFalse( $validator->is_vip( 5 ) );
}
public function test_passes_correct_option_name(): void {
Functions\expect( 'get_option' )
->once()
->with( 'my_plugin_vips', [] )
->andReturn( [ 1 ] );
$validator = new UserValidator();
$validator->is_vip( 1 );
}
}
Functions\when() stubs the function and controls what it returns. Functions\expect() also asserts call count and the exact arguments passed — if your code calls get_option() with the wrong key, the test fails immediately. Brain Monkey must be initialized in setUp() and torn down in tearDown(); this resets all stubs between tests so they don’t bleed into each other. Each test runs in under 10ms.
Mocking WordPress Hooks (Actions and Filters)
Brain Monkey stubs add_action(), add_filter(), and apply_filters() automatically when you call Monkey\setUp(). You can assert that your plugin registers hooks in the right place:
// src/Plugin.php
namespace MyPlugin;
class Plugin {
public function register_hooks(): void {
add_action( 'init', [ $this, 'register_cpt' ] );
add_filter( 'the_content', [ $this, 'add_schema_markup' ] );
}
}
// tests/PluginTest.php
namespace MyPlugin\Tests;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
use MyPlugin\Plugin;
use PHPUnit\Framework\TestCase;
class PluginTest extends TestCase {
protected function setUp(): void {
parent::setUp();
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
parent::tearDown();
}
public function test_registers_init_action(): void {
Actions\expectAdded( 'init' )->once();
$plugin = new Plugin();
$plugin->register_hooks();
}
public function test_registers_content_filter(): void {
Filters\expectAdded( 'the_content' )->once();
$plugin = new Plugin();
$plugin->register_hooks();
}
}
Actions\expectAdded() and Filters\expectAdded() will fail the test if the expected hook is never registered. This catches the common plugin bug of calling register_hooks() conditionally and forgetting a branch, or registering on the wrong hook name.
Dependency Injection Makes Mocking Cleaner
Brain Monkey mocks global WordPress functions, but when your class depends on another class — a repository, a service, an API client — PHPUnit’s built-in createMock() is the right tool. This is what dependency injection in WordPress development enables: instead of instantiating dependencies inside your class with new EmailService(), you accept them as constructor arguments. The test then passes in a mock.
// src/OrderProcessor.php
namespace MyPlugin;
class OrderProcessor {
public function __construct( private EmailService $mailer ) {}
public function process( int $order_id ): bool {
// ... business logic ...
return $this->mailer->send_confirmation( $order_id );
}
}
// tests/OrderProcessorTest.php
namespace MyPlugin\Tests;
use MyPlugin\EmailService;
use MyPlugin\OrderProcessor;
use PHPUnit\Framework\TestCase;
class OrderProcessorTest extends TestCase {
public function test_sends_confirmation_on_success(): void {
$mailer = $this->createMock( EmailService::class );
$mailer->expects( $this->once() )
->method( 'send_confirmation' )
->with( 42 )
->willReturn( true );
$processor = new OrderProcessor( $mailer );
$this->assertTrue( $processor->process( 42 ) );
}
}
No Brain Monkey needed — the mock is built from the EmailService class definition and enforces its contract automatically. Using DI throughout your plugin means most classes can be tested this way; reserve Brain Monkey for classes that call WordPress functions directly. Combining both tools covers virtually every testable case in a WordPress plugin.
Running Tests Automatically with GitHub Actions
Running PHPUnit locally before every commit is good practice; running it automatically on every pull request is better. Add a .github/workflows/phpunit.yml to your plugin repository:
name: PHPUnit Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: [ '8.1', '8.2', '8.3' ]
steps:
- uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
- name: Install dependencies
run: composer install --no-progress --prefer-dist
- name: Run PHPUnit
run: vendor/bin/phpunit
This matrix runs your suite against PHP 8.1, 8.2, and 8.3 in parallel. A PR cannot merge until all three pass. Because Brain Monkey requires no database or WordPress to load, the workflow installs dependencies and runs the full suite in under 60 seconds with no database setup step needed.
PHPUnit + Brain Monkey: Quick-Start Checklist
- Install PHPUnit 10+ and Brain Monkey 2+ via Composer as dev dependencies
- Add PSR-4 autoload entries for
src/andtests/incomposer.json, then runcomposer dump-autoload - Create
phpunit.xmlwithvendor/autoload.phpas bootstrap and./testsas the test directory - Mirror your
src/directory structure intests/— one test class per source class - Call
Brain\Monkey\setUp()in every test class’ssetUp()method andBrain\Monkey\tearDown()intearDown() - Use
Functions\when()to stub a WordPress function when you only care about its return value - Use
Functions\expect()when you also want to assert call count and arguments - Use
Actions\expectAdded()andFilters\expectAdded()to verify hook registration - Prefer
createMock()over Brain Monkey when testing classes built with dependency injection - Add a GitHub Actions workflow to run PHPUnit on every push and pull request across multiple PHP versions
A test suite that runs in under 60 seconds — no browser refreshes, no database resets — is a suite developers actually use. A plugin that ships with a passing test suite is also significantly easier for another developer, or your future self, to maintain and refactor safely. If you’re building custom REST API endpoints in your plugin, the WordPress custom REST API endpoints guide covers how to structure handler classes in a way that’s straightforward to unit test using exactly the techniques above.


