Automated testing in WordPress development has shifted from an optional extra to a baseline requirement for production systems. For years, WordPress engineering teams relied on manual verification, refreshing browser screens and clicking administrative forms after modifying plugin logic. That approach breaks down as soon as multiple custom post types, REST endpoints, and complex business logic interact. A small regression in an unvisited taxonomy term or filter callback can remain unnoticed until customer orders fail or database tables corrupt.
Modern testing strategies divide quality assurance into distinct layers. At the foundation, unit tests execute in milliseconds without loading the WordPress runtime or database. Higher up, integration tests verify interactions with the WordPress database schema, while end-to-end tests exercise browser flows. Building a maintainable testing suite in 2026 requires understanding this separation and configuring tools such as PHPUnit 10 and 11, Brain Monkey, Mockery, wp-browser, and wp-env.
Unit testing fundamentals and isolation principles
A true unit test isolates a single function or class method from all external dependencies. In WordPress development, isolation presents unique challenges because standard codebase conventions rely on global functions like get_post(), get_option(), and apply_filters(). When a developer calls these functions inside business logic, executing that method in a standard PHP runtime causes fatal errors if the WordPress core files are not loaded.
Attempting to run unit tests by bootstrapping the entire WordPress database environment for every test case ruins the feedback loop. When a test suite takes ten minutes to run, developers stop running tests before committing code. Pure unit tests must run within seconds. To achieve this speed, test suites must isolate business logic entirely from the database and core globals using test doubles and function mocks.
Writing testable WordPress code begins with software architecture. Classes should decouple domain logic from WordPress APIs. Instead of embedding calls to update_post_meta() directly inside pricing calculations, pass domain values into pure methods and return calculated results. When interaction with WordPress APIs is necessary, isolate those calls so testing frameworks can simulate their behavior without booting WordPress.
Brain Monkey for hook and filter assertions
Brain Monkey is the standard testing utility for unit testing WordPress code in complete isolation. Developed specifically to mock WordPress functions, actions, and filters without loading the WordPress environment, Brain Monkey enables sub-second test execution.
Brain Monkey intercepts calls to add_action(), add_filter(), do_action(), and apply_filters(). This enables tests to assert that an action hook was attached to a specific callback with the expected priority and argument count, or that a custom filter modified a dataset as expected.
<?php
declare(strict_types=1);
namespace WPPoland\Tests\Unit;
use Brain\Monkey;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use PHPUnit\Framework\TestCase;
use WPPoland\Billing\InvoiceManager;
final class InvoiceManagerTest extends TestCase {
protected function setUp(): void {
parent::setUp();
Monkey\setUp();
}
protected function tearDown(): void {
Monkey\tearDown();
parent::tearDown();
}
public function test_invoice_creation_triggers_expected_action(): void {
Actions\expectDone('wppoland_invoice_generated')
->once()
->with(1042, 599.00);
Functions\expect('get_option')
->with('currency_code', 'PLN')
->andReturn('EUR');
$manager = new InvoiceManager();
$invoiceId = $manager->createInvoice(1042, 599.00);
$this->assertSame(1042, $invoiceId);
}
public function test_filter_modifies_invoice_total(): void {
Filters\expectApplied('wppoland_invoice_discount')
->once()
->with(500.00, 1042)
->andReturn(450.00);
$manager = new InvoiceManager();
$finalAmount = $manager->applyDiscounts(500.00, 1042);
$this->assertSame(450.00, $finalAmount);
}
}In this test, neither WordPress nor a database exists. Brain Monkey handles the hook lifecycle cleanly. Setting up Monkey\setUp() in setUp() and Monkey\tearDown() in tearDown() resets the mock state between tests, preventing test pollution.
Mockery for complex object dependencies
Brain Monkey integrates with Mockery to provide object mocking alongside function mocking. In enterprise WordPress plugins, classes interact with database repositories, HTTP clients, and external payment gateways. Mockery allows developers to define test doubles that mimic complex interfaces and verify method invocations.
<?php
declare(strict_types=1);
namespace WPPoland\Tests\Unit;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;
use WPPoland\Gateways\PaymentClientInterface;
use WPPoland\Orders\OrderProcessor;
final class OrderProcessorTest extends TestCase {
use MockeryPHPUnitIntegration;
public function test_order_processes_payment_successfully(): void {
$paymentClient = Mockery::mock(PaymentClientInterface::class);
$paymentClient->shouldReceive('charge')
->once()
->with(120.50, 'EUR')
->andReturn((object) ['status' => 'success', 'transaction_id' => 'tx_9981']);
$processor = new OrderProcessor($paymentClient);
$result = $processor->process(101, 120.50, 'EUR');
$this->assertTrue($result->isSuccessful());
$this->assertSame('tx_9981', $result->getTransactionId());
}
}By leveraging MockeryPHPUnitIntegration, Mockery assertions automatically integrate into the PHPUnit test counter, ensuring unfulfilled expectations trigger test failures.
Integration testing with WP_UnitTestCase
While unit tests prove isolated logic, WordPress code ultimately runs inside WordPress. Integration tests verify how custom post types, taxonomy queries, user capabilities, and database options interact with the core database engine.
The official WordPress integration testing suite provides WP_UnitTestCase, which extends PHPUnit’s base test case. WP_UnitTestCase boots a complete WordPress environment and connects to a test database. Crucially, it provides automated database state management and custom factories for creating test data.
<?php
declare(strict_types=1);
namespace WPPoland\Tests\Integration;
use WP_UnitTestCase;
use WP_Query;
final class CustomQueryTest extends WP_UnitTestCase {
public function test_query_returns_published_enterprise_projects(): void {
$postId = $this->factory->post->create([
'post_type' => 'portfolio',
'post_status' => 'publish',
'post_title' => 'Enterprise Migration',
]);
$draftId = $this->factory->post->create([
'post_type' => 'portfolio',
'post_status' => 'draft',
'post_title' => 'Draft Migration',
]);
$query = new WP_Query([
'post_type' => 'portfolio',
'post_status' => 'publish',
'posts_per_page' => 10,
]);
$postIds = wp_list_pluck($query->posts, 'ID');
$this->assertContains($postId, $postIds);
$this->assertNotContains($draftId, $postIds);
}
}The $this->factory helper simplifies generating posts, terms, users, and comments. Each entity receives valid default properties, eliminating repetitive boilerplate database seeding.
Database transaction rollbacks in tests
Integration tests that execute write operations against a real database can slow down over time if tables must be wiped and migrated between each test method. In early WordPress testing configurations, the test suite executed DROP TABLE and rebuilt tables, causing multi-minute test execution times.
WP_UnitTestCase solves this problem through database transaction management. Before executing a test method, the test runner starts a MySQL transaction. The test executes its queries, creating posts, options, or meta records. During the test tear-down phase, the runner executes a transaction ROLLBACK rather than deleting records individually.
This rollback mechanism ensures that:
- Tests remain isolated from changes made by preceding tests.
- Database execution speeds stay fast because data is discarded in memory without full table rebuilds.
- Primary key auto-increment values continue advancing, mimicking real-world database behavior.
When testing code that explicitly calls wp_commit() or interacts with tables using MySQL storage engines that do not support transactions (such as legacy MyISAM tables), rollback management must be handled carefully. On modern MySQL 8.0 and MariaDB 10.11 installations running InnoDB, transaction rollbacks provide reliable and fast integration testing.
Local test environments with wp-env
Setting up a local testing environment used to require manual bash scripts, local MySQL installations, and subversion checkouts of WordPress develop branches. The official @wordpress/env tool (wp-env) standardizes this setup using Docker.
A configuration file named .wp-env.json in the plugin root defines the environment:
{
"core": "WordPress/WordPress#master",
"plugins": [ "." ],
"config": {
"WP_DEBUG": true,
"SCRIPT_DEBUG": true
},
"env": {
"tests": {
"config": {
"WP_DEBUG": true
}
}
}
}Running wp-env start launches Docker containers for the web server and MySQL database. To execute integration tests inside the dedicated test container, run:
npx wp-env run tests-cli vendor/bin/phpunitwp-env creates a separate database instance specifically for testing, preventing integration tests from modifying your local development database.
Automated browser and acceptance testing with wp-browser
Unit and integration tests run on the command line, but certain features require testing the browser interface. Verifying that a Gutenberg block renders controls in the editor sidebar or that a custom checkout form validates credit cards on the client side requires functional acceptance testing.
wp-browser brings Codeception testing to WordPress. It provides test modules tailored to WordPress interfaces:
- WPDb: Directly inspects and seeds the test database without loading PHPUnit factories.
- WPLoader: Bootstraps the WordPress runtime within Codeception functional tests.
- WPWebDriver: Automates Chrome or Firefox using Selenium or Chromedriver to test real user interactions.
<?php
// A functional acceptance test in wp-browser
$I = new AcceptanceTester($scenario);
$I->wantTo('verify admin settings save correctly');
$I->loginAsAdmin();
$I->amOnAdminPage('admin.php?page=wppoland-settings');
$I->fillField('#api_key', 'live_secret_key_12345');
$I->click('Save Changes');
$I->see('Settings saved successfully', '.notice-success');
$I->seeInDatabase('wp_options', [
'option_name' => 'wppoland_api_key',
'option_value' => 'live_secret_key_12345',
]);Acceptance tests verify the full stack, providing high confidence before production deployments.
Testing Gutenberg blocks with Jest and Playwright
Modern WordPress development involves both PHP backend code and JavaScript React interfaces. Testing Gutenberg custom blocks requires JavaScript testing tools.
The WordPress development community uses @wordpress/scripts to standardize JavaScript test configurations. Backend logic inside blocks is tested using Jest, while user interactions in the block editor are tested using Playwright.
Unit testing block attributes and transforms with Jest
Jest executes unit tests against React components and pure JavaScript utilities. Block attributes, deprecations, and data transforms should be tested with Jest.
import { cleanInvoiceAttributes } from '../src/utils';
describe('Invoice block attribute sanitization', () => {
it('sets default tax rate when invalid rate is provided', () => {
const input = { taxRate: -5, clientName: 'WPPoland' };
const result = cleanInvoiceAttributes(input);
expect(result.taxRate).toBe(23);
expect(result.clientName).toBe('WPPoland');
});
it('preserves valid vat rates', () => {
const input = { taxRate: 8, clientName: 'WPPoland' };
const result = cleanInvoiceAttributes(input);
expect(result.taxRate).toBe(8);
});
});End-to-end block testing with Playwright
Playwright controls a headless browser instance, loads the Gutenberg editor, inserts custom blocks, and asserts that DOM attributes and block controls operate as intended.
import { test, expect } from '@wordpress/e2e-test-utils-playwright';
test.describe('Invoice summary block', () => {
test.beforeEach(async ({ admin }) => {
await admin.createNewPost();
});
test('inserts block and updates total in sidebar', async ({ editor, page }) => {
await editor.insertBlock({ name: 'wppoland/invoice-summary' });
const block = page.locator('[data-type="wppoland/invoice-summary"]');
await expect(block).toBeVisible();
await page.fill('input[aria-label="Invoice total"]', '1250');
await expect(block.locator('.invoice-formatted-amount')).toHaveText('1,250.00 EUR');
});
});Playwright tests catch visual regressions and interface breakages that unit tests cannot detect.
Continuous integration with GitHub Actions on PHP 8.2 and 8.3
Automated tests provide value only when executed consistently across all development branches and pull requests. A continuous integration (CI) pipeline guarantees that no code is merged without passing tests on supported PHP versions.
A modern GitHub Actions workflow tests against PHP 8.2 and PHP 8.3 using MySQL 8.0 services.
name: Test Suite
on:
push:
branches: [ master, main ]
pull_request:
branches: [ master, main ]
jobs:
unit-tests:
name: PHPUnit on PHP ${{ matrix.php }}
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.2', '8.3']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
tools: composer:v2
- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress
- name: Run unit test suite
run: vendor/bin/phpunit --testsuite=unit
integration-tests:
name: Integration Tests on PHP ${{ matrix.php }}
runs-on: ubuntu-latest
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
strategy:
matrix:
php: ['8.2', '8.3']
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mysqli, pdo_mysql
coverage: none
tools: composer:v2
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run integration tests
env:
WP_DB_HOST: 127.0.0.1
WP_DB_NAME: wordpress_test
WP_DB_USER: root
WP_DB_PASS: root
run: vendor/bin/phpunit --testsuite=integrationThis workflow separates unit tests from integration tests. The unit test job runs concurrently without waiting for MySQL initialization, giving immediate feedback within thirty seconds of pushing a commit.
Test suite architecture and directory structure
A clean project structure separates unit, integration, and functional test files. Storing all tests in a flat directory creates confusion regarding whether a test requires a database.
my-enterprise-plugin/
├── .github/
│ └── workflows/
│ └── tests.yml
├── .wp-env.json
├── composer.json
├── phpunit.xml.dist
├── src/
│ ├── Billing/
│ ├── Gateways/
│ └── Plugin.php
└── tests/
├── bootstrap-unit.php
├── bootstrap-integration.php
├── Unit/
│ ├── Billing/
│ └── Gateways/
├── Integration/
│ ├── DatabaseQueryTest.php
│ └── HookRegistrationTest.php
└── Fixtures/
└── sample-invoice.jsonConfiguring phpunit.xml.dist for test suites
Configuring distinct test suites inside phpunit.xml.dist enables developers to run unit tests separately from integration tests.
<?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-unit.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="false"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="unit">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory suffix="Test.php">tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>Developers can run vendor/bin/phpunit --testsuite=unit during active local development and run the complete suite before pushing code.
Enterprise testing matrix and maintenance checklist
Adopting automated testing requires disciplined practices. Teams should review their testing coverage against standard verification criteria.
| Testing layer | Tooling | Execution speed | Database requirement | Primary objective |
|---|---|---|---|---|
| Unit tests | PHPUnit 10/11, Brain Monkey, Mockery | Milliseconds | None (Memory only) | Pure business logic, calculations, filter transformations |
| Integration tests | WP_UnitTestCase, wp-env | Seconds | MySQL test database | WP_Query results, database schema writes, hook priorities |
| Acceptance tests | wp-browser, Codeception | Seconds to minutes | Headless browser + DB | Admin settings forms, user permissions, checkout submissions |
| Block e2e tests | Playwright, @wordpress/scripts | Minutes | Docker environment | Gutenberg editor canvas, block sidebar controls, block save output |
To maintain a healthy test suite:
- Keep unit tests pure: Never call database functions inside unit test directories.
- Rely on transaction rollbacks: Keep integration test execution fast by rolling back database writes.
- Run CI on every commit: Automate test execution on PHP 8.2 and 8.3 via GitHub Actions.
- Test block transforms: Use Jest and Playwright to verify that Gutenberg block markup updates do not break stored content.
- Refactor for testability: Separate business logic from WordPress display templates.
Professional WordPress development relies on verifiable automated tests. Learn more about professional WordPress development and our architectural engineering standards at WPPoland.
Advanced Mockery patterns for WordPress architecture
Enterprise WordPress codebases regularly handle architectural patterns such as repositories, service containers, and domain services. When writing unit tests for a custom plugin that interacts with external APIs or asynchronous queue handlers, developers cannot rely solely on function mocks. They need robust object test doubles capable of verifying state transitions, expected method calls, and exception handling.
Mockery provides expressive syntax for defining method expectations. Beyond basic return values, Mockery supports argument matchers, invocation counts, and return value sequencing.
<?php
declare(strict_types=1);
namespace WPPoland\Tests\Unit;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;
use WPPoland\Http\ApiClientInterface;
use WPPoland\Sync\InventorySynchronizer;
use RuntimeException;
final class InventorySynchronizerTest extends TestCase {
use MockeryPHPUnitIntegration;
public function test_sync_retries_on_network_timeout(): void {
$apiClient = Mockery::mock(ApiClientInterface::class);
// Expect two attempts: first fails with exception, second succeeds
$apiClient->shouldReceive('fetchProductStock')
->twice()
->with('SKU-9901')
->andReturnValues([
new RuntimeException('Connection timed out'),
['sku' => 'SKU-9901', 'stock' => 45],
]);
$synchronizer = new InventorySynchronizer($apiClient);
$stock = $synchronizer->syncProduct('SKU-9901');
$this->assertSame(45, $stock);
}
public function test_sync_throws_exception_after_exhausting_retries(): void {
$apiClient = Mockery::mock(ApiClientInterface::class);
$apiClient->shouldReceive('fetchProductStock')
->times(3)
->with('SKU-9901')
->andThrow(new RuntimeException('Fatal gateway outage'));
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Fatal gateway outage');
$synchronizer = new InventorySynchronizer($apiClient, 3);
$synchronizer->syncProduct('SKU-9901');
}
}This pattern guarantees that edge cases, network disruptions, and rate-limiting responses are tested thoroughly without issuing live HTTP calls.
Testing custom REST API endpoints and schema validation
WordPress REST API endpoints serve modern headless architectures, mobile applications, and interactive block frontends. Writing integration tests for custom WP_REST_Controller implementations verifies route registration, permission callbacks, payload schema validation, and status codes.
WP_UnitTestCase integrates directly with WP_REST_Server. Rather than making external HTTP requests, tests dispatch requests directly through the internal server instance, allowing rapid execution.
<?php
declare(strict_types=1);
namespace WPPoland\Tests\Integration;
use WP_UnitTestCase;
use WP_REST_Request;
use WP_REST_Server;
final class OrdersEndpointTest extends WP_UnitTestCase {
private WP_REST_Server $server;
protected function setUp(): void {
parent::setUp();
global $wp_rest_server;
$this->server = $wp_rest_server = new WP_REST_Server();
do_action('rest_api_init');
}
public function test_unauthenticated_request_is_rejected(): void {
$request = new WP_REST_Request('POST', '/wppoland/v1/orders');
$request->set_header('content-type', 'application/json');
$request->set_body(wp_json_encode(['item_id' => 42]));
$response = $this->server->dispatch($request);
$this->assertSame(401, $response->get_status());
$data = $response->get_data();
$this->assertSame('rest_cannot_create', $data['code']);
}
public function test_authorized_user_creates_order_successfully(): void {
$adminId = $this->factory->user->create(['role' => 'administrator']);
wp_set_current_user($adminId);
$request = new WP_REST_Request('POST', '/wppoland/v1/orders');
$request->set_header('content-type', 'application/json');
$request->set_body(wp_json_encode([
'item_id' => 42,
'quantity' => 2,
'amount' => 199.50,
]));
$response = $this->server->dispatch($request);
$this->assertSame(201, $response->get_status());
$data = $response->get_data();
$this->assertArrayHasKey('order_id', $data);
$this->assertSame(199.50, $data['amount']);
}
}Testing REST controllers ensures endpoints validate incoming JSON types and reject missing properties before data reaches storage layers.
Testing database migrations and custom table updates
Enterprise WordPress platforms frequently create custom database tables to handle high-velocity events such as transactional audits, analytics logs, or sync queues. Verifying that table creation, column altering, and index updates succeed without data loss requires dedicated migration integration tests.
When writing migration tests, verify that dbDelta() executes correctly and that custom SQL migration scripts perform atomic updates.
<?php
declare(strict_types=1);
namespace WPPoland\Tests\Integration;
use WP_UnitTestCase;
use WPPoland\Database\MigrationManager;
final class MigrationManagerTest extends WP_UnitTestCase {
public function test_migration_creates_custom_audit_table(): void {
global $wpdb;
$tableName = $wpdb->prefix . 'wppoland_audit_log';
$wpdb->query("DROP TABLE IF EXISTS {$tableName}");
$manager = new MigrationManager();
$manager->runMigrations();
$tableExists = $wpdb->get_var(
$wpdb->prepare("SHOW TABLES LIKE %s", $tableName)
);
$this->assertSame($tableName, $tableExists);
// Verify index structure
$indices = $wpdb->get_results("SHOW INDEX FROM {$tableName}", ARRAY_A);
$indexNames = wp_list_pluck($indices, 'Key_name');
$this->assertContains('idx_created_at', $indexNames);
}
}Testing schema migrations prevents schema collisions across staging and production deployments.
PHPStan and static analysis integration alongside PHPUnit
Static analysis is the partner of unit testing. While unit tests verify that specific inputs produce expected outputs, static analysis tools like PHPStan analyze the entire codebase to detect type mismatches, dead code, and invalid function signatures before tests even execute.
In the WordPress ecosystem, szepeviktor/phpstan-wordpress provides deep WordPress core stubs and dynamic return type extensions. When paired with PHPUnit, static analysis guarantees that your test suites themselves do not rely on undefined variables or broken mocks.
A recommended phpstan.neon.dist configuration sets level 8 or 9:
includes:
- vendor/szepeviktor/phpstan-wordpress/extension.neon
parameters:
level: 8
paths:
- src
- tests
excludePaths:
- tests/Fixtures/*
bootstrapFiles:
- tests/bootstrap-unit.php
checkMissingIterableValueType: falseRunning vendor/bin/phpstan analyse as part of your local pre-commit hook and CI pipeline catches type bugs instantly, reducing the number of repetitive assertions needed in unit tests.
Code coverage reporting and threshold enforcement
Tracking test coverage provides visibility into untested code paths and branches. However, striving for 100% test coverage can lead to low-quality tests that assert trivial getters and setters while ignoring complex edge cases.
In modern WordPress engineering, teams establish sensible code coverage baselines (typically 80% to 85% on domain logic) and enforce them in GitHub Actions using Xdebug 3 or PCOV.
# Generating code coverage with PCOV
php -d pcov.enabled=1 vendor/bin/phpunit --testsuite=unit --coverage-text --coverage-html=coverage-reportConfiguring PHPUnit’s coverage block ensures that coverage calculation measures only actionable application code, excluding test fixtures, legacy wrappers, and third-party libraries. When integrated into CI, pull requests that introduce untested logic fail the build before deployment.







