14+ years building on WordPress / Replies in under 5 hours
Development 7 min read · Updated July 2026

Testing the Unbreakable: A Practical Guide to Unit Testing WordPress with PHPUnit

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Testing the Unbreakable: A Practical Guide to Unit Testing WordPress with PHPUnit
TL;DR

True unit testing in WordPress requires mocking core functions like get_option() and add_action() — Brain Monkey does this without loading WordPress at all. Install PHPUnit 10+ and Brain Monkey 2+ via Composer, configure phpunit.xml to use vendor/autoload.php as the bootstrap, and use Functionswhen() to stub any WordPress function in your tests. For classes built with dependency injection, PHPUnit’s createMock() is cleaner than Brain Monkey. Add a GitHub Actions workflow to run vendor/bin/phpunit on every pull request across PHP 8.1–8.3 — no database server required.

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

  1. Install PHPUnit 10+ and Brain Monkey 2+ via Composer as dev dependencies
  2. Add PSR-4 autoload entries for src/ and tests/ in composer.json, then run composer dump-autoload
  3. Create phpunit.xml with vendor/autoload.php as bootstrap and ./tests as the test directory
  4. Mirror your src/ directory structure in tests/ — one test class per source class
  5. Call Brain\Monkey\setUp() in every test class’s setUp() method and Brain\Monkey\tearDown() in tearDown()
  6. Use Functions\when() to stub a WordPress function when you only care about its return value
  7. Use Functions\expect() when you also want to assert call count and arguments
  8. Use Actions\expectAdded() and Filters\expectAdded() to verify hook registration
  9. Prefer createMock() over Brain Monkey when testing classes built with dependency injection
  10. 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.

Frequently asked questions

Unit testing isolates a single class or function and tests it in memory, without touching a database, the file system, or WordPress core. Integration testing (including the WordPress test suite's WP_UnitTestCase) loads a real WordPress environment with a real database and tests how multiple components work together. Unit tests are faster (milliseconds per test) and more targeted — they pinpoint exactly which line of code broke. Integration tests are slower but catch problems that only appear when multiple components interact. For WordPress plugin development, Brain Monkey enables true unit testing by mocking WordPress's global functions so your class logic can be tested without loading WordPress at all.

No. Brain Monkey stubs WordPress functions (get_option, add_action, apply_filters, etc.) by defining them as mockable stubs before your code runs. This means your phpunit.xml only needs vendor/autoload.php as the bootstrap — no WordPress core files, no database connection, no wp-config.php. The trade-off is that Brain Monkey only helps with classes that call WordPress functions directly. If a class is purely business logic with no WordPress dependencies, PHPUnit alone is sufficient. If it needs WordPress's actual behavior (database writes, real hook execution), you need the WordPress test suite (WP_UnitTestCase) instead.

Brain Monkey is a PHP library built specifically for mocking WordPress functions and hooks in unit tests. WordPress relies heavily on global functions — get_option(), add_action(), apply_filters(), wp_insert_post() — which standard PHPUnit mocks cannot stub because they're plain functions, not methods on an object. Brain Monkey defines lightweight stubs for these functions and provides an API (Functionswhen, Functionsexpect, ActionsexpectAdded, FiltersexpectAdded) to control what they return and assert how they were called. It integrates with PHPUnit and Mockery and requires only Composer to install — no WordPress installation needed.

Use Brain Monkey's Functionswhen() or Functionsexpect(). First, call BrainMonkeysetUp() in your test's setUp() method and BrainMonkeytearDown() in tearDown(). Then inside a test method: Functionswhen('get_option')->justReturn(['user1', 'user2']) stubs the function to return a fixed value. Functionsexpect('get_option')->once()->with('my_key', [])->andReturn([]) adds an assertion that it was called exactly once with those arguments. The stubs only apply within the current test — tearDown() resets everything between tests.

Brain Monkey provides ActionsexpectAdded() and FiltersexpectAdded() for this. In your test, call ActionsexpectAdded('init')->once() before calling the method that registers your hooks. If your plugin never calls add_action('init', ...), the test fails with a message about the missing expectation. You can also chain ->with() to assert the callback and priority: ActionsexpectAdded('save_post')->once()->with([Plugin::class, 'save_handler'], 10, 2). This approach catches typos in hook names and missing registrations that are otherwise only discovered by loading a full WordPress environment.

Yes — if you're using Brain Monkey for unit tests (not WordPress's WP_UnitTestCase). Because Brain Monkey stubs WordPress functions without a database, your GitHub Actions workflow only needs PHP and Composer. A setup-php action installs the right PHP version, composer install pulls in PHPUnit and Brain Monkey, and vendor/bin/phpunit runs the full suite — no MySQL service, no WordPress core download, no test database setup. The workflow completes in roughly 30–60 seconds. If you need WP_UnitTestCase integration tests in CI, you'll need to add a MySQL service and a WordPress test bootstrap, which takes 3–4 additional workflow steps.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →