---
title: Testing WordPress Plugins with PHPUnit and GitHub Actions
description: Create a practical WordPress plugin test stack with isolated unit tests, WP_UnitTestCase integration tests, HTTP mocks and multi-version GitHub Actions.
url: https://moxseo.com/testing-wordpress-plugins-phpunit-github-actions
date_modified: 2026-09-11
author: Aditya Bhimrajka
language: en_US
---

## Key takeaways

- Automated testing for WordPress plugins requires a dual-tier strategy: ultra-fast isolated unit tests using Brain Monkey for pure domain logic, and database-backed integration tests using `WP_UnitTestCase`.
- `WP_UnitTestCase` automatically wraps every test method inside a MySQL transaction rollback (`START TRANSACTION` … `ROLLBACK`), guaranteeing a pristine database state without manual table truncation.
- Mocking external HTTP API calls using the `pre_http_request` filter prevents flaky tests, eliminates third-party rate limits, and accelerates CI test suites by up to **15x**.
- Leveraging WordPress Core Test Factories (`$this->factory->post->create()`, `$this->factory->user->create()`) generates realistic mock fixtures with proper capability trees and taxonomy bindings.
- Configuring a multi-version GitHub Actions CI/CD matrix validates plugin compatibility across PHP 8.1–8.4 and WordPress 6.4–6.6 on every pull request.
- Mounting the MySQL test database on a Linux `tmpfs` RAM disk eliminates disk I/O bottlenecks and cuts local test suite execution time by **70%**.

In the fast-moving WordPress software ecosystem, releasing untested custom plugins or commercial extensions is an invitation to catastrophic production downtime. A minor update that introduces a fatal PHP 8.3 type error, an unescaped SQL query, or a broken REST API permission check can instantly bring down high-concurrency WooCommerce stores, damage customer trust, and trigger thousands of support tickets.

Despite this, a vast majority of WordPress plugin developers still rely on manual browser clicking and ad-hoc `var_dump()` debugging. The perceived difficulty of configuring the official WordPress test suite, setting up test database bootstrap environments, and mocking global WordPress core functions often deters engineering teams from adopting professional test-driven development (TDD).

In this comprehensive, hands-on engineering masterclass, we will demystify automated testing in WordPress. We will analyze the architectural differences between unit and integration testing, build an isolated Dockerized test environment with PHPUnit 10+, construct comprehensive test suites for REST APIs, custom database tables, and Action Scheduler background jobs, mock external third-party HTTP requests, and configure an enterprise GitHub Actions CI/CD matrix enforcing 85%+ code coverage.

![Architecture diagram showing PHPUnit test suite execution, WP_UnitTestCase transaction rollback lifecycle, and GitHub Actions multi-version CI test matrix.](https://wpstack.online/wp-content/uploads/2026/08/wordpress-phpunit-automated-testing-architecture-1024x683.webp)Image Source: AI-generated visual by Wpstack

## The Testing Pyramid: Unit vs Integration vs End-to-End

A mature WordPress testing architecture comprises three distinct layers, each addressing a specific scope of code correctness, isolation, and execution velocity:

| Testing Tier | Scope & Methodology | Execution Speed | Framework / Tooling |
| --- | --- | --- | --- |
| **1. Pure Unit Tests** | Tests isolated PHP classes; mocks all WordPress functions (`add_action`, `get_option`) in runtime memory without booting WordPress Core. | **Ultra-Fast** (1,000 tests in < 0.5s) | PHPUnit 10 + Brain Monkey / Mockery |
| **2. WordPress Integration Tests** | Boots WordPress Core in memory; interacts with a real MySQL test database utilizing transactional rollbacks for each test. | **Moderate** (100 tests in ~2.5s) | PHPUnit 10 + `WP_UnitTestCase` + wp-tests-lib |
| **3. End-to-End (E2E) Browser Tests** | Drives a real headless Chromium browser; simulates user clicks, Gutenberg block interactions, and checkout funnels. | **Slow** (10 tests in ~30s) | Playwright / Cypress + `@wordpress/e2e-test-utils` |

While Unit Tests provide instant feedback during local development and refactoring, **WordPress Integration Tests** are the foundational pillar for plugin reliability. They verify real interactions with `$wpdb`, hook execution order, user capability mapping, and REST API routing schemas.

## Configuring the Modern Testing Toolchain

To build a robust test pipeline, we require a modern Composer configuration that leverages `yoast/phpunit-polyfills` to bridge PHPUnit version discrepancies between modern PHPUnit 10+ and legacy WordPress test libraries.

### 1. Composer Configuration (`composer.json`)

Here is the production-grade `composer.json` for our custom plugin, establishing strict PSR-4 autoloading and development test dependencies:

```
{
  "name": "wpstack/enterprise-plugin",
  "description": "Enterprise WordPress Custom Plugin with Automated PHPUnit Test Suite",
  "type": "wordpress-plugin",
  "license": "GPL-2.0-or-later",
  "autoload": {
    "psr-4": {
      "WPStack\EnterprisePlugin\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "WPStack\EnterprisePlugin\Tests\": "tests/"
    }
  },
  "require": {
    "php": ">=8.1"
  },
  "require-dev": {
    "phpunit/phpunit": "^10.5",
    "wp-phpunit/wp-phpunit": "^6.6",
    "brain/monkey": "^2.6",
    "mockery/mockery": "^1.6",
    "yoast/phpunit-polyfills": "^3.0"
  },
  "scripts": {
    "test:unit": "phpunit --testsuite=unit --colors=always",
    "test:integration": "phpunit --testsuite=integration --colors=always",
    "test": "phpunit --colors=always",
    "test:coverage": "XDEBUG_MODE=coverage phpunit --coverage-html build/coverage --coverage-text"
  },
  "config": {
    "allow-plugins": {
      "dealerdirect/phpcodesniffer-composer-installer": true
    }
  }
}
```

### 2. PHPUnit Configuration (`phpunit.xml.dist`)

We configure `phpunit.xml.dist` to define separate test suites for Unit tests and Integration tests. This allows developers to run sub-second unit tests on file-save while running heavier database tests before committing code:

```
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
    bootstrap="tests/bootstrap.php"
    colors="true"
    cacheDirectory=".phpunit.cache"
    executionOrder="depends,defects"
    requireCoverageMetadata="false"
    beStrictAboutOutputDuringTests="true"
    failOnRisky="true"
    failOnWarning="true">
    
    <testsuites>
        <testsuite name="unit">
            <directory prefix="test-" suffix=".php">./tests/Unit</directory>
        </testsuite>
        <testsuite name="integration">
            <directory prefix="test-" suffix=".php">./tests/Integration</directory>
        </testsuite>
    </testsuites>

    <source>
        <include>
            <directory suffix=".php">./src</directory>
        </include>
    </source>

    <php>
        <env name="WP_TESTS_DIR" value="/tmp/wordpress-tests-lib"/>
        <env name="WP_CORE_DIR" value="/tmp/wordpress"/>
        <env name="WP_TESTS_MULTISITE" value="0"/>
    </php>
</phpunit>
```

### 3. Dual-Tier Test Bootstrap (`tests/bootstrap.php`)

The bootstrap script must dynamically detect whether the test runner is executing isolated Unit tests or WordPress Core Integration tests, loading the official WordPress test harness only when required:

```
<?php
declare(strict_types=1);

// Load Composer Autoloader
require_once dirname(__DIR__) . '/vendor/autoload.php';

// If executing unit tests only, do not load WordPress Core
if (getenv('WP_TESTS_DIR') === false && !defined('WP_TESTS_LOAD_CORE')) {
    return;
}

$_tests_dir = getenv('WP_TESTS_DIR') ?: rtrim(sys_get_temp_dir(), '/\') . '/wordpress-tests-lib';

if (!file_exists($_tests_dir . '/includes/functions.php')) {
    echo "Could not find {$_tests_dir}/includes/functions.php, please run bin/install-wp-tests.sh" . PHP_EOL;
    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(): void {
    require dirname(__DIR__) . '/enterprise-plugin.php';
}
tests_add_filter('muplugins_loaded', '_manually_load_plugin');

// Start up the WP testing environment.
require_once $_tests_dir . '/includes/bootstrap.php';
```

## Writing Pure Unit Tests with Brain Monkey

Brain Monkey is a dedicated mocking library created specifically for testing WordPress code in complete isolation. It allows you to mock hooks (`add_action`, `apply_filters`), options (`get_option`, `update_option`), and arbitrary WordPress procedural functions without loading a single byte of WordPress Core.

### The Domain Service Class Under Test

Consider an enterprise License Management Service that checks API transient caches, computes validity periods, and applies security filters:

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginServices;

class LicenseValidatorService {
    private const TRANSIENT_KEY = 'wpstack_license_cache';
    private const CACHE_TTL = 86400; // 24 hours

    public function __construct(
        private readonly string $licenseKey,
        private readonly string $apiUrl
    ) {}

    /**
     * Validate license status and return expiry timestamp.
     *
     * @return array{valid: bool, expires: int, status: string}
     */
    public function validate(): array {
        if (empty($this->licenseKey)) {
            return [
                'valid'   => false,
                'expires' => 0,
                'status'  => 'missing_key',
            ];
        }

        // Check cached transient
        $cached = get_transient(self::TRANSIENT_KEY);
        if (is_array($cached) && isset($cached['valid'])) {
            return $cached;
        }

        // Evaluate license structure
        $status = str_starts_with($this->licenseKey, 'WPS-ENT-') ? 'active' : 'invalid';
        $expires = $status === 'active' ? time() + (365 * 86400) : 0;

        $result = [
            'valid'   => $status === 'active',
            'expires' => $expires,
            'status'  => $status,
        ];

        // Apply custom filter hook
        $filtered = apply_filters('wpstack_license_validation_result', $result, $this->licenseKey);

        set_transient(self::TRANSIENT_KEY, $filtered, self::CACHE_TTL);

        return $filtered;
    }
}
```

### The Brain Monkey Unit Test Suite

We test all logical branches of `LicenseValidatorService` in sub-millisecond execution time by setting expectations with Brain Monkey:

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginTestsUnit;

use PHPUnitFrameworkTestCase;
use BrainMonkey;
use BrainMonkeyFunctions;
use BrainMonkeyFilters;
use WPStackEnterprisePluginServicesLicenseValidatorService;

class Test_LicenseValidatorService extends TestCase {
    protected function setUp(): void {
        parent::setUp();
        MonkeysetUp();
    }

    protected function tearDown(): void {
        MonkeytearDown();
        parent::tearDown();
    }

    public function test_empty_license_returns_missing_key_immediately(): void {
        $validator = new LicenseValidatorService('', 'https://api.wpstack.online');
        $result = $validator->validate();

        $this->assertFalse($result['valid']);
        $this->assertSame('missing_key', $result['status']);
        $this->assertSame(0, $result['expires']);
    }

    public function test_cached_transient_bypasses_calculation(): void {
        $cachedData = [
            'valid'   => true,
            'expires' => 1799999999,
            'status'  => 'active',
        ];

        Functionsexpect('get_transient')
            ->once()
            ->with('wpstack_license_cache')
            ->andReturn($cachedData);

        // Expect set_transient to NEVER be called because cache is warm
        Functionsexpect('set_transient')->never();

        $validator = new LicenseValidatorService('WPS-ENT-12345', 'https://api.wpstack.online');
        $result = $validator->validate();

        $this->assertSame($cachedData, $result);
    }

    public function test_valid_enterprise_key_generates_and_stores_transient(): void {
        Functionsexpect('get_transient')
            ->once()
            ->with('wpstack_license_cache')
            ->andReturn(false);

        FiltersexpectApplied('wpstack_license_validation_result')
            ->once()
            ->andReturnUsing(static fn($result) => $result);

        Functionsexpect('set_transient')
            ->once()
            ->with('wpstack_license_cache', Mockery::type('array'), 86400)
            ->andReturn(true);

        $validator = new LicenseValidatorService('WPS-ENT-PRO-8899', 'https://api.wpstack.online');
        $result = $validator->validate();

        $this->assertTrue($result['valid']);
        $this->assertSame('active', $result['status']);
        $this->assertGreaterThan(time(), $result['expires']);
    }
}
```

## WordPress Integration Testing with `WP_UnitTestCase`

When testing code that interacts with the database, fires actions, modifies options, or registers Custom Post Types, isolated mocking is insufficient. We need to verify that queries execute cleanly against MySQL and respect table constraints.

`WP_UnitTestCase` is the core test case class provided by WordPress. It extends PHPUnit’s `TestCase` and injects vital testing infrastructure:

- **Automatic MySQL Transaction Isolation:** Every test starts a transaction before `setUp()` and executes `ROLLBACK` during `tearDown()`. Your test data is never permanently written to disk, keeping subsequent tests fast and unpolluted.
- **Core Fixture Factories:** Instantiate mock posts, users, terms, comments, and attachments with a single function call.
- **Action & Filter Assertion Helpers:** Assert that specific hooks were fired with expected parameters.

### Using WordPress Factories for Fixture Generation

WordPress factories generate valid database rows populated with deterministic default values, eliminating verbose SQL setup scripts:

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginTestsIntegration;

use WP_UnitTestCase;

class Test_PostRepositoryIntegration extends WP_UnitTestCase {
    public function test_custom_query_filters_by_meta_and_author(): void {
        // 1. Create an author user fixture
        $authorId = $this->factory->user->create([
            'role'         => 'author',
            'user_email'   => 'lead_dev@wpstack.online',
            'display_name' => 'Senior Engineer',
        ]);

        // 2. Create 5 test posts assigned to this author
        $postIds = $this->factory->post->create_many(5, [
            'post_author' => $authorId,
            'post_status' => 'publish',
            'post_type'   => 'wpstack_report',
        ]);

        // 3. Attach metadata to 3 of the 5 posts
        update_post_meta($postIds[0], '_wpstack_priority', 'high');
        update_post_meta($postIds[1], '_wpstack_priority', 'high');
        update_post_meta($postIds[2], '_wpstack_priority', 'low');

        // 4. Execute repository query
        $query = new WP_Query([
            'post_type'      => 'wpstack_report',
            'author'         => $authorId,
            'posts_per_page' => -1,
            'meta_query'     => [
                [
                    'key'     => '_wpstack_priority',
                    'value'   => 'high',
                    'compare' => '=',
                ],
            ],
        ]);

        $this->assertSame(2, $query->found_posts);
        $foundIds = wp_list_pluck($query->posts, 'ID');
        $this->assertContains($postIds[0], $foundIds);
        $this->assertContains($postIds[1], $foundIds);
        $this->assertNotContains($postIds[2], $foundIds);
    }
}
```

## Testing Custom REST API Endpoints

WordPress REST API endpoints require rigorous testing to verify route registration, authentication guards, JSON payload sanitization, and response status codes. Testing REST endpoints via `WP_REST_Server` executes the entire REST routing lifecycle in memory without spinning up an external web server.

### The REST API Controller Under Test

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginRest;

use WP_REST_Controller;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;

class AuditLogsRestController extends WP_REST_Controller {
    public function __construct() {
        $this->namespace = 'wpstack/v1';
        $this->rest_base = 'audit-logs';
    }

    public function register_routes(): void {
        register_rest_route($this->namespace, '/' . $this->rest_base, [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [$this, 'get_items'],
                'permission_callback' => [$this, 'get_items_permissions_check'],
                'args'                => [
                    'limit' => [
                        'required'          => false,
                        'default'           => 10,
                        'type'              => 'integer',
                        'minimum'           => 1,
                        'maximum'           => 100,
                        'sanitize_callback' => 'absint',
                    ],
                ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [$this, 'create_item'],
                'permission_callback' => [$this, 'create_item_permissions_check'],
                'args'                => [
                    'event' => [
                        'required'          => true,
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                    'severity' => [
                        'required'          => true,
                        'type'              => 'string',
                        'enum'              => ['info', 'warning', 'critical'],
                    ],
                ],
            ],
        ]);
    }

    public function get_items_permissions_check(WP_REST_Request $request): bool|WP_Error {
        if (!current_user_can('manage_options')) {
            return new WP_Error(
                'rest_forbidden',
                'You do not have administrative permissions.',
                ['status' => 403]
            );
        }
        return true;
    }

    public function create_item_permissions_check(WP_REST_Request $request): bool|WP_Error {
        return $this->get_items_permissions_check($request);
    }

    public function get_items(WP_REST_Request $request): WP_REST_Response {
        $limit = (int) $request->get_param('limit');
        $logs = [
            ['id' => 1, 'event' => 'plugin_activated', 'severity' => 'info'],
            ['id' => 2, 'event' => 'failed_login_attempt', 'severity' => 'warning'],
        ];

        return new WP_REST_Response(array_slice($logs, 0, $limit), 200);
    }

    public function create_item(WP_REST_Request $request): WP_REST_Response {
        $event = (string) $request->get_param('event');
        $severity = (string) $request->get_param('severity');

        $createdRecord = [
            'id'        => 101,
            'event'     => $event,
            'severity'  => $severity,
            'timestamp' => time(),
        ];

        return new WP_REST_Response($createdRecord, 201);
    }
}
```

### The REST API Integration Test Suite

We test authorization barriers (unauthenticated vs subscriber vs admin) and request payloads using the in-memory `WP_REST_Server` instance:

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginTestsIntegration;

use WP_UnitTestCase;
use WP_REST_Request;
use WP_REST_Server;
use WPStackEnterprisePluginRestAuditLogsRestController;

class Test_AuditLogsRestController extends WP_UnitTestCase {
    private WP_REST_Server $server;
    private int $adminUserId;
    private int $subscriberUserId;

    public function setUp(): void {
        parent::setUp();

        global $wp_rest_server;
        $wp_rest_server = new WP_REST_Server();
        $this->server = $wp_rest_server;
        do_action('rest_api_init');

        $controller = new AuditLogsRestController();
        $controller->register_routes();

        $this->adminUserId = $this->factory->user->create(['role' => 'administrator']);
        $this->subscriberUserId = $this->factory->user->create(['role' => 'subscriber']);
    }

    public function tearDown(): void {
        global $wp_rest_server;
        $wp_rest_server = null;
        parent::tearDown();
    }

    public function test_get_logs_unauthenticated_returns_403(): void {
        wp_set_current_user(0); // Anonymous user

        $request = new WP_REST_Request('GET', '/wpstack/v1/audit-logs');
        $response = $this->server->dispatch($request);

        $this->assertSame(403, $response->get_status());
        $data = $response->get_data();
        $this->assertSame('rest_forbidden', $data['code']);
    }

    public function test_get_logs_subscriber_returns_403(): void {
        wp_set_current_user($this->subscriberUserId);

        $request = new WP_REST_Request('GET', '/wpstack/v1/audit-logs');
        $response = $this->server->dispatch($request);

        $this->assertSame(403, $response->get_status());
    }

    public function test_get_logs_administrator_returns_200_with_data(): void {
        wp_set_current_user($this->adminUserId);

        $request = new WP_REST_Request('GET', '/wpstack/v1/audit-logs');
        $request->set_param('limit', 1);
        $response = $this->server->dispatch($request);

        $this->assertSame(200, $response->get_status());
        $data = $response->get_data();
        $this->assertIsArray($data);
        $this->assertCount(1, $data);
        $this->assertSame('plugin_activated', $data[0]['event']);
    }

    public function test_create_log_invalid_enum_returns_400(): void {
        wp_set_current_user($this->adminUserId);

        $request = new WP_REST_Request('POST', '/wpstack/v1/audit-logs');
        $request->set_param('event', 'system_reboot');
        $request->set_param('severity', 'catastrophic'); // Invalid enum value

        $response = $this->server->dispatch($request);

        $this->assertSame(400, $response->get_status());
        $data = $response->get_data();
        $this->assertSame('rest_invalid_param', $data['code']);
    }

    public function test_create_log_valid_payload_returns_201(): void {
        wp_set_current_user($this->adminUserId);

        $request = new WP_REST_Request('POST', '/wpstack/v1/audit-logs');
        $request->set_param('event', 'security_firewall_triggered');
        $request->set_param('severity', 'critical');

        $response = $this->server->dispatch($request);

        $this->assertSame(201, $response->get_status());
        $data = $response->get_data();
        $this->assertSame('security_firewall_triggered', $data['event']);
        $this->assertSame('critical', $data['severity']);
        $this->assertArrayHasKey('timestamp', $data);
    }
}
```

## Mocking Third-Party HTTP Requests with `pre_http_request`

When a custom plugin connects to third-party webhooks, payment gateways, or AI inference APIs (such as OpenAI or Stripe), tests must never execute real network requests over the internet. Real HTTP requests introduce network latency, cause rate limit bans, and fail whenever third-party services experience outages.

WordPress provides an elegant, built-in mechanism to intercept all outgoing `wp_remote_get()` and `wp_remote_post()` calls: the `pre_http_request` filter hook. If this filter returns an array containing response headers and body, WordPress short-circuits the HTTP transport layer immediately.

### Reusable HTTP Mock Registry Helper

We encapsulate HTTP mocking inside a reusable helper class that can simulate successful responses, server errors, and network timeouts:

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginTestsHelpers;

class HttpMockRegistry {
    /** @var array<string, array{response: array{code: int, message: string}, body: string, headers: array}> */
    private static array $mockRoutes = [];

    public static function register(): void {
        add_filter('pre_http_request', [self::class, 'interceptRequest'], 10, 3);
    }

    public static function reset(): void {
        self::$mockRoutes = [];
        remove_filter('pre_http_request', [self::class, 'interceptRequest'], 10);
    }

    /**
     * Mock a specific URL response.
     *
     * @param string $urlPattern Exact URL or regex pattern
     * @param int $statusCode HTTP status code (e.g. 200, 400, 500)
     * @param array $bodyData Data to encode as JSON
     */
    public static function mockUrl(string $urlPattern, int $statusCode = 200, array $bodyData = []): void {
        self::$mockRoutes[$urlPattern] = [
            'response' => [
                'code'    => $statusCode,
                'message' => $statusCode === 200 ? 'OK' : 'Error',
            ],
            'body'     => json_encode($bodyData, JSON_THROW_ON_ERROR),
            'headers'  => ['content-type' => 'application/json'],
        ];
    }

    /**
     * Intercept outgoing WordPress HTTP request.
     *
     * @param false|array|WP_Error $preempt
     * @param array $args
     * @param string $url
     * @return false|array
     */
    public static function interceptRequest($preempt, array $args, string $url) {
        foreach (self::$mockRoutes as $pattern => $mockResponse) {
            if ($url === $pattern || @preg_match($pattern, $url)) {
                return $mockResponse;
            }
        }

        // Return a WP_Error to block any unexpected live internet requests during testing
        return new WP_Error('blocked_network_call', "Unmocked live HTTP request attempted to: {$url}");
    }
}
```

### Testing an External API Client Service

Now we verify our payment notification webhook service with complete isolation and zero network latency:

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginTestsIntegration;

use WP_UnitTestCase;
use WPStackEnterprisePluginTestsHelpersHttpMockRegistry;
use WPStackEnterprisePluginServicesStripeWebhookDispatcher;

class Test_StripeWebhookDispatcher extends WP_UnitTestCase {
    public function setUp(): void {
        parent::setUp();
        HttpMockRegistry::register();
    }

    public function tearDown(): void {
        HttpMockRegistry::reset();
        parent::tearDown();
    }

    public function test_successful_webhook_dispatch(): void {
        HttpMockRegistry::mockUrl('https://api.stripe.com/v1/webhook_endpoints', 200, [
            'id'     => 'we_123456',
            'status' => 'enabled',
        ]);

        $dispatcher = new StripeWebhookDispatcher('sk_test_mock_key');
        $response = $dispatcher->registerEndpoint('https://wpstack.online/wp-json/wpstack/v1/stripe-listener');

        $this->assertTrue($response['success']);
        $this->assertSame('we_123456', $response['endpoint_id']);
    }

    public function test_api_rate_limit_triggers_retry_exception(): void {
        HttpMockRegistry::mockUrl('https://api.stripe.com/v1/webhook_endpoints', 429, [
            'error' => [
                'type'    => 'rate_limit_error',
                'message' => 'Too many requests.',
            ],
        ]);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('Stripe API Rate Limit Exceeded (HTTP 429)');

        $dispatcher = new StripeWebhookDispatcher('sk_test_mock_key');
        $dispatcher->registerEndpoint('https://wpstack.online/wp-json/wpstack/v1/stripe-listener');
    }
}
```

## Testing Action Scheduler Background Workers

Enterprise plugins rely heavily on [custom plugin development](https://wpstack.online/custom-plugin-development/) featuring background queuing engines like Action Scheduler. Testing asynchronous queues requires validating both job enqueuing and programmatic queue execution.

### The Background Queue Service Under Test

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginQueue;

class InvoiceBatchProcessor {
    public const HOOK_NAME = 'wpstack_process_invoice_batch';
    public const GROUP_NAME = 'wpstack_invoices';

    public function register(): void {
        add_action(self::HOOK_NAME, [$this, 'processBatch'], 10, 1);
    }

    public function scheduleBatch(int $batchId, int $delaySeconds = 0): int {
        return (int) as_schedule_single_action(
            time() + $delaySeconds,
            self::HOOK_NAME,
            ['batch_id' => $batchId],
            self::GROUP_NAME
        );
    }

    /**
     * Worker method executed when Action Scheduler runs.
     *
     * @param int $batchId
     */
    public function processBatch(int $batchId): void {
        // Mark batch as processed in options or custom table
        update_option("wpstack_invoice_batch_status_{$batchId}", 'completed');
    }
}
```

### The Action Scheduler Integration Test Suite

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginTestsIntegration;

use WP_UnitTestCase;
use ActionScheduler;
use WPStackEnterprisePluginQueueInvoiceBatchProcessor;

class Test_InvoiceBatchProcessor extends WP_UnitTestCase {
    private InvoiceBatchProcessor $processor;

    public function setUp(): void {
        parent::setUp();
        $this->processor = new InvoiceBatchProcessor();
        $this->processor->register();
    }

    public function test_schedule_batch_creates_pending_action(): void {
        $batchId = 402;
        $actionId = $this->processor->scheduleBatch($batchId, 300);

        $this->assertGreaterThan(0, $actionId);

        // Verify action is present in Action Scheduler registry
        $scheduled = as_has_scheduled_action(
            InvoiceBatchProcessor::HOOK_NAME,
            ['batch_id' => $batchId],
            InvoiceBatchProcessor::GROUP_NAME
        );

        $this->assertTrue($scheduled);
    }

    public function test_executing_scheduled_action_updates_database_state(): void {
        $batchId = 808;
        $this->processor->scheduleBatch($batchId, 0);

        // Verify initial state is empty
        $this->assertFalse(get_option("wpstack_invoice_batch_status_{$batchId}"));

        // Trigger Action Scheduler queue runner programmatically in test memory
        ActionScheduler::runner()->process_actions(InvoiceBatchProcessor::GROUP_NAME);

        // Assert that the worker executed and updated state
        $this->assertSame('completed', get_option("wpstack_invoice_batch_status_{$batchId}"));
    }
}
```

## Testing Redis Object Cache Invalidation and Transients

Caching bugs are among the most subtle and damaging production defects. If an entity is updated in the database but stale data remains in Redis or Memcached, users receive outdated prices or corrupted session states.

We test object cache invalidation by asserting against the WordPress Object Cache API (`wp_cache_get`, `wp_cache_set`, `wp_cache_delete`):

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginTestsIntegration;

use WP_UnitTestCase;
use WPStackEnterprisePluginRepositoriesCustomerRepository;

class Test_CustomerCacheInvalidation extends WP_UnitTestCase {
    private CustomerRepository $repository;

    public function setUp(): void {
        parent::setUp();
        $this->repository = new CustomerRepository();
        wp_cache_flush(); // Reset cache before test
    }

    public function test_updating_customer_invalidates_cached_entity(): void {
        $userId = $this->factory->user->create([
            'role'         => 'customer',
            'display_name' => 'Original Name',
        ]);

        // 1. Initial fetch warms the cache
        $customer = $this->repository->findById($userId);
        $this->assertSame('Original Name', $customer['name']);

        // Assert item exists in cache group
        $cachedItem = wp_cache_get((string)$userId, 'wpstack_customers');
        $this->assertNotEmpty($cachedItem);
        $this->assertSame('Original Name', $cachedItem['name']);

        // 2. Update customer record
        $this->repository->updateName($userId, 'Updated VIP Name');

        // Assert that updateName cleared the specific cache key
        $staleCache = wp_cache_get((string)$userId, 'wpstack_customers');
        $this->assertFalse($staleCache, 'Cache was not invalidated upon entity update!');

        // 3. Re-fetching retrieves fresh data from database and rewires cache
        $freshCustomer = $this->repository->findById($userId);
        $this->assertSame('Updated VIP Name', $freshCustomer['name']);
    }
}
```

## Automating CI/CD with GitHub Actions Matrix

To guarantee cross-version stability across all client environments, every pull request should automatically execute the entire test suite against a comprehensive compatibility matrix. We use GitHub Actions with a matrix covering PHP 8.1, 8.2, 8.3, and 8.4 against WordPress 6.4, 6.5, and 6.6.

### GitHub Actions Workflow (`.github/workflows/phpunit-matrix.yml`)

```
name: Automated PHPUnit Testing Matrix

on:
  push:
    branches: [ "main", "develop" ]
  pull_request:
    branches: [ "main" ]

jobs:
  phpunit:
    name: PHP ${{ matrix.php }} | WP ${{ matrix.wordpress }}
    runs-on: ubuntu-latest
    
    strategy:
      fail-fast: false
      matrix:
        php: [ '8.1', '8.2', '8.3', '8.4' ]
        wordpress: [ '6.4', '6.5', '6.6' ]
        include:
          - php: '8.3'
            wordpress: '6.6'
            coverage: true

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: wordpress_test
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

    steps:
      - name: Checkout Codebase
        uses: actions/checkout@v4

      - name: Setup PHP Environment
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: mbstring, xml, ctype, iconv, intl, pdo_mysql, pcntl, redis
          coverage: ${{ matrix.coverage && 'xdebug' || 'none' }}
          tools: composer:v2

      - name: Get Composer Cache Directory
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT

      - name: Cache Composer Dependencies
        uses: actions/cache@v4
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-

      - name: Install Composer Dependencies
        run: composer install --prefer-dist --no-progress --no-interaction

      - name: Install WordPress Test Environment
        run: |
          bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1:3306 ${{ matrix.wordpress }}

      - name: Run Unit Tests (Brain Monkey)
        run: composer test:unit

      - name: Run WordPress Integration Tests (WP_UnitTestCase)
        run: composer test:integration

      - name: Generate Code Coverage Report
        if: matrix.coverage == true
        run: composer test:coverage

      - name: Upload Coverage to Codecov
        if: matrix.coverage == true
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./build/coverage/clover.xml
          fail_ci_if_error: true
```

## Optimizing Test Execution Speed: tmpfs RAM Disk

Executing 500+ database integration tests against physical SSDs or slow cloud storage can cause severe I/O thrashing. In local development and CI pipelines, mounting MySQL data directories on a `tmpfs` RAM disk delivers instant transactional rollbacks and accelerates test execution by up to **70%**.

### Docker Compose Fast Test Stack (`docker-compose.test.yml`)

```
version: '3.8'

services:
  db:
    image: mariadb:10.11
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: wordpress_test
    # Mount MySQL datadir in volatile RAM memory
    tmpfs:
      - /var/lib/mysql:rw,noexec,nosuid,size=512m
    ports:
      - "33060:3306"
    command: >
      --innodb_flush_log_at_trx_commit=2
      --innodb_flush_method=O_DIRECT
      --sync_binlog=0
      --innodb_doublewrite=0

  app:
    build:
      context: .
      dockerfile: Dockerfile.test
    volumes:
      - .:/var/www/html
    depends_on:
      - db
    environment:
      WP_TESTS_DIR: /tmp/wordpress-tests-lib
      WP_CORE_DIR: /tmp/wordpress
      WP_DB_HOST: db:3306
      WP_DB_NAME: wordpress_test
      WP_DB_USER: root
      WP_DB_PASS: root
```

By setting `innodb_flush_log_at_trx_commit=2` and `innodb_doublewrite=0` in your testing database container, MySQL skips redundant disk fsyncs, processing thousands of transaction rollbacks in seconds.

## Troubleshooting Common WordPress PHPUnit Errors

When building automated test suites, engineering teams frequently encounter edge-case errors caused by global state leaks or hook pollution. Use this operational matrix to resolve issues quickly:

| Error Symptom | Root Cause | Resolution Strategy |
| --- | --- | --- |
| `Cannot modify header information - headers already sent` | PHP output (whitespace, echo, notice) was printed before test headers or REST API responses were dispatched. | Add `@runInSeparateProcess` annotation or wrap output blocks in `ob_start()` / `ob_end_clean()` inside the test method. |
| `Table 'wordpress_test.wp_custom' already exists` | A previous test failed to drop custom database tables or a schema migration ran outside a transactional rollback. | Explicitly drop custom tables inside `tearDown()` or register custom tables in `$wpdb->tables` array before tests run. |
| `Brain Monkey: Expectation failed for apply_filters` | A hook expectation was declared with `FiltersexpectApplied()` but the code path bypassed the filter hook. | Inspect conditional branches in your service class or verify hook registration inside `setUp()`. |
| `Test polluted global state across test methods` | A test modified a global variable (e.g. `$wp_query`, `$_POST`, `$current_user`) without restoring it in `tearDown()`. | Always call `wp_set_current_user(0)` and reset `$_POST = []` inside `tearDown()` to ensure total test isolation. |
| `Database lock wait timeout exceeded` | A test opened an uncommitted transaction or unclosed database cursor that blocked subsequent test methods. | Ensure all explicit `$wpdb->query('START TRANSACTION')` calls are balanced with matching `COMMIT` or `ROLLBACK` handlers. |
| `Class "YoastPHPUnitPolyfillsTestCasesTestCase" not found` | Missing Composer dependency required to bridge PHPUnit 9 and PHPUnit 10 test case inheritance trees. | Run `composer require --dev yoast/phpunit-polyfills` and include the Composer autoloader in `tests/bootstrap.php`. |

## Frequently Asked Questions

### What is the difference between Unit Tests and Integration Tests in WordPress?

Unit tests evaluate individual PHP functions and classes in complete isolation without loading WordPress Core or a database, using mocking tools like Brain Monkey. Integration tests boot WordPress Core in memory and test live interactions with the MySQL database, hooks, options, and REST APIs using `WP_UnitTestCase`.

### Why does `WP_UnitTestCase` use database transaction rollbacks?

`WP_UnitTestCase` wraps each test execution inside `START TRANSACTION` and issues a `ROLLBACK` during `tearDown()`. This guarantees that database modifications made during one test do not persist to disk or pollute subsequent test cases, maintaining high execution speed and test purity.

### How do I mock WordPress functions like `get_option()` in pure Unit Tests?

Use Brain Monkey’s `Functionsexpect(‘get_option’)->with(‘my_key’)->andReturn(‘mock_value’)`. This allows you to define expected inputs and return values for WordPress functions without loading WordPress Core files.

### How do I test custom database tables created with `dbDelta()`?

In your integration test `setUp()` method, execute your table installer function to create the schema in the MySQL test database. Because `WP_UnitTestCase` handles table schemas cleanly, test your CRUD operations, and drop custom tables in `tearDown()` if DDL statements bypass transactions.

### Why should I avoid live HTTP requests during automated testing?

Live network calls introduce flakiness due to network latency, trigger third-party API rate limits, and cause CI builds to fail during external service outages. Intercepting requests via the `pre_http_request` filter ensures deterministic, millisecond responses.

### How can I test WordPress REST API endpoints with authentication?

Instantiate a mock user using `$this->factory->user->create([‘role’ => ‘administrator’])`, authenticate using `wp_set_current_user($userId)`, and dispatch a `WP_REST_Request` directly to the `WP_REST_Server` instance in test memory.

### What is `yoast/phpunit-polyfills` and why is it required?

`yoast/phpunit-polyfills` provides compatibility wrappers across PHPUnit 8, 9, 10, and 11. It allows WordPress plugins to use modern assertion syntax and lifecycle methods (such as `setUp(): void`) across diverse PHP and PHPUnit environments.

### How do I speed up local PHPUnit test execution in WordPress?

Run pure unit tests with Brain Monkey for sub-second feedback during coding, mount your MySQL test database on a Linux `tmpfs` RAM disk, disable InnoDB doublewrite buffers in test containers, and use Paratest for parallel test execution.
