WordPress Plugin Testing: Unit vs Integration

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-env config 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 TestCase base 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_UnitTestCase base 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-plugin

The 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.php

The 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-interaction

Configure 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 includes
  • vendor/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 present
  • vendor/composer/autoload_files.php, for files that must be loaded eagerly
  • vendor/composer/autoload_real.php, which wires everything together at runtime

Run the following command to rebuild these mappings:

composer dump-autoload

Install testing dependencies

Install the core set:

composer require --dev \
  phpunit/phpunit:^9 \
  yoast/phpunit-polyfills:^2 \
  brain/monkey:^2

These dependencies will be added to the composer.json configuration file

1. phpunit/phpunit

PHPUnit is the standard testing framework for PHP. It provides:

  • The phpunit CLI 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-cli runs inside the Docker container.
  • --env-cwd is 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_option and get_option work 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:integration

Run unit tests (fast, no WordPress dependencies):

npm run test:unit

Run both test suites:

npm run test

Run 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.php

This 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:

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

Navigation

About

Writing on the Wall is a newsletter for freelance writers seeking inspiration, advice, and support on their creative journey.

Discover more from JuanMa Codes

Subscribe now to keep reading and get access to the full archive.

Continue reading