WordPress plugin and theme testing with PHPUnit integration tests, WP_Mock unit tests, PHPCS coding standards, and CI/CD workflows
The WordPress Testing Hierarchy:
/\
/ \ E2E Tests (Playwright)
/ \ - Full user workflows
/------\ - Browser automation
/ \
/ INTEG \ Integration Tests (PHPUnit + WordPress)
/ TESTS \ - Database operations
/ \ - Hook interactions
--------------
UNIT TESTS Unit Tests (WP_Mock)
- Pure logic
- No WordPress dependency
Test Distribution Guidelines:
Use PHPUnit (Integration Tests) when:
$wpdb, post creation, meta data)Use WP_Mock (Unit Tests) when:
Minimum Coverage Requirements:
What to Test (Priority Order):
What NOT to Test:
Step 1: Install Dependencies
# Install PHPUnit and WordPress polyfills
composer require --dev phpunit/phpunit "^9.6"
composer require --dev yoast/phpunit-polyfills "^2.0"
# Generate test scaffold with WP-CLI
wp scaffold plugin-tests my-plugin
# This creates:
# - tests/bootstrap.php
# - tests/test-sample.php
# - phpunit.xml.dist
# - bin/install-wp-tests.sh
Step 2: Install WordPress Test Library
# Install WordPress test suite and test database
# Syntax: bash bin/install-wp-tests.sh <db-name> <db-user> <db-pass> <db-host> <wp-version>
bash bin/install-wp-tests.sh wordpress_test root '' localhost latest
# For specific WordPress version:
bash bin/install-wp-tests.sh wordpress_test root '' localhost 6.7
Step 3: Configure phpunit.xml.dist
<?xml version="1.0"?>
<phpunit
bootstrap="tests/bootstrap.php"
backupGlobals="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
stopOnFailure="false"
>
<testsuites>
<testsuite name="plugin">
<directory prefix="test-" suffix=".php">./tests/</directory>
<exclude>./tests/bootstrap.php</exclude>
</testsuite>
</testsuites>
<coverage includeUncoveredFiles="true">
<include>
<directory suffix=".php">./includes/</directory>
</include>
<exclude>
<directory>./vendor/</directory>
<directory>./tests/</directory>
</exclude>
<report>
<html outputDirectory="coverage-html"/>
<text outputFile="php://stdout" showOnlySummary="true"/>
</report>
</coverage>
<php>
<const name="WP_TESTS_PHPUNIT_POLYFILLS_PATH" value="vendor/yoast/phpunit-polyfills"/>
</php>
</phpunit>
tests/bootstrap.php:
<?php
/**
* PHPUnit bootstrap file
*/
// Composer autoloader
require_once dirname(__DIR__) . '/vendor/autoload.php';
// WordPress tests directory
$_tests_dir = getenv('WP_TESTS_DIR');
if (!$_tests_dir) {
$_tests_dir = rtrim(sys_get_temp_dir(), '/\\') . '/wordpress-tests-lib';
}
if (!file_exists("{$_tests_dir}/includes/functions.php")) {
throw new Exception("Could not find {$_tests_dir}/includes/functions.php");
}
// 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(__DIR__) . '/my-plugin.php';
}
tests_add_filter('muplugins_loaded', '_manually_load_plugin');
// Start up the WordPress testing environment
require "{$_tests_dir}/includes/bootstrap.php";
Using Built-in Factories:
<?php
class Test_Plugin_Integration extends WP_UnitTestCase {
/**
* Test creating posts with factory
*/
public function test_create_post_with_meta() {
// Create a post using factory
$post_id = $this->factory->post->create([
'post_title' => 'Test Post',
'post_content' => 'Test content for integration test',
'post_status' => 'publish',
'post_type' => 'post',
]);
$this->assertIsInt($post_id);
$this->assertGreaterThan(0, $post_id);
// Add post meta
add_post_meta($post_id, '_custom_field', 'custom_value');
// Verify meta was saved
$meta_value = get_post_meta($post_id, '_custom_field', true);
$this->assertEquals('custom_value', $meta_value);
}
/**
* Test creating users
*/
public function test_user_can_edit_post() {
// Create editor user
$editor_id = $this->factory->user->create([
'role' => 'editor',
'user_login' => 'test_editor',
'user_email' => '[email protected]',
]);
// Set as current user
wp_set_current_user($editor_id);
// Create post
$post_id = $this->factory->post->create([
'post_author' => $editor_id,
]);
// Test capabilities
$this->assertTrue(current_user_can('edit_post', $post_id));
$this->assertTrue(current_user_can('edit_posts'));
$this->assertFalse(current_user_can('manage_options'));
}
/**
* Test creating terms and taxonomy
*/
public function test_assign_categories() {
// Create category
$category_id = $this->factory->category->create([
'name' => 'Test Category',
'slug' => 'test-category',
]);
// Create post
$post_id = $this->factory->post->create();
// Assign category
wp_set_post_categories($post_id, [$category_id]);
// Verify assignment
$categories = wp_get_post_categories($post_id);
$this->assertContains($category_id, $categories);
}
/**
* Test creating comments
*/
public function test_post_has_comments() {
$post_id = $this->factory->post->create();
// Create multiple comments
$comment_ids = $this->factory->comment->create_many(3, [
'comment_post_ID' => $post_id,
'comment_approved' => 1,
]);
$this->assertCount(3, $comment_ids);
// Get comments for post
$comments = get_comments(['post_id' => $post_id]);
$this->assertCount(3, $comments);
}
}
Available Factory Objects:
$this->factory->post - Posts, pages, custom post types$this->factory->user - Users with roles$this->factory->term - Terms (categories, tags, custom taxonomies)$this->factory->category - Categories specifically$this->factory->tag - Tags specifically$this->factory->comment - Comments$this->factory->blog - Multisite blogssetUp() and tearDown() Methods:
<?php
class Test_Custom_Post_Type extends WP_UnitTestCase {
protected $post_ids = [];
/**
* Setup runs before EACH test method
*/
public function setUp(): void {
parent::setUp();
// Register custom post type
register_post_type('book', [
'public' => true,
'supports' => ['title', 'editor'],
]);
// Create test data
$this->post_ids = $this->factory->post->create_many(5, [
'post_type' => 'book',
]);
}
/**
* Teardown runs after EACH test method
*/
public function tearDown(): void {
// Clean up test data
foreach ($this->post_ids as $post_id) {
wp_delete_post($post_id, true); // Force delete
}
// Unregister post type
unregister_post_type('book');
parent::tearDown();
}
/**
* Test that books are created
*/
public function test_books_created() {
$this->assertCount(5, $this->post_ids);
$query = new WP_Query([
'post_type' => 'book',
'posts_per_page' => -1,
]);
$this->assertEquals(5, $query->found_posts);
}
}
setUpBeforeClass() and tearDownAfterClass():
<?php
class Test_Plugin_Database extends WP_UnitTestCase {
protected static $table_name;
/**
* Runs ONCE before all tests in class
*/
public static function setUpBeforeClass(): void {
parent::setUpBeforeClass();
global $wpdb;
self::$table_name = $wpdb->prefix . 'plugin_data';
// Create custom table
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE " . self::$table_name . " (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL,
data_value varchar(255) NOT NULL,
created_at datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY user_id (user_id)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
}
/**
* Runs ONCE after all tests in class
*/
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query("DROP TABLE IF EXISTS " . self::$table_name);
parent::tearDownAfterClass();
}
/**
* Test table exists
*/
public function test_custom_table_exists() {
global $wpdb;
$table_exists = $wpdb->get_var(
"SHOW TABLES LIKE '" . self::$table_name . "'"
);
$this->assertEquals(self::$table_name, $table_exists);
}
/**
* Test insert data
*/
public function test_insert_data() {
global $wpdb;
$result = $wpdb->insert(
self::$table_name,
[
'user_id' => 1,
'data_value' => 'test_value',
],
['%d', '%s']
);
$this->assertEquals(1, $result);
$this->assertGreaterThan(0, $wpdb->insert_id);
}
}
tests/test-plugin-functionality.php:
<?php
/**
* Test plugin core functionality
*/
class Test_Plugin_Functionality extends WP_UnitTestCase {
/**
* Test plugin registers custom post type
*/
public function test_custom_post_type_registered() {
$this->assertTrue(post_type_exists('book'));
$post_type = get_post_type_object('book');
$this->assertTrue($post_type->public);
$this->assertTrue($post_type->show_in_rest);
}
/**
* Test custom taxonomy registration
*/
public function test_custom_taxonomy_registered() {
$this->assertTrue(taxonomy_exists('genre'));
$taxonomy = get_taxonomy('genre');
$this->assertTrue($taxonomy->hierarchical);
$this->assertContains('book', $taxonomy->object_type);
}
/**
* Test saving custom meta data
*/
public function test_save_book_metadata() {
$book_id = $this->factory->post->create([
'post_type' => 'book',
'post_title' => 'Test Book',
]);
// Simulate saving meta (as would happen in save_post hook)
update_post_meta($book_id, '_isbn', '978-3-16-148410-0');
update_post_meta($book_id, '_author', 'John Doe');
update_post_meta($book_id, '_publication_year', 2024);
// Verify meta saved correctly
$this->assertEquals('978-3-16-148410-0', get_post_meta($book_id, '_isbn', true));
$this->assertEquals('John Doe', get_post_meta($book_id, '_author', true));
$this->assertEquals(2024, get_post_meta($book_id, '_publication_year', true));
}
/**
* Test shortcode output
*/
public function test_book_shortcode_output() {
$book_id = $this->factory->post->create([
'post_type' => 'book',
'post_title' => 'The Great Gatsby',
]);
update_post_meta($book_id, '_author', 'F. Scott Fitzgerald');
// Test shortcode
$output = do_shortcode('[book id="' . $book_id . '"]');
$this->assertStringContainsString('The Great Gatsby', $output);
$this->assertStringContainsString('F. Scott Fitzgerald', $output);
}
/**
* Test action hook fires correctly
*/
public function test_book_published_action_fires() {
$action_fired = false;
// Add temporary hook to verify action fires
add_action('my_plugin_book_published', function($post_id) use (&$action_fired) {
$action_fired = true;
});
// Create published book (should trigger action)
$book_id = $this->factory->post->create([
'post_type' => 'book',
'post_status' => 'publish',
]);
// Manually trigger the action (simulating what plugin does)
do_action('my_plugin_book_published', $book_id);
$this->assertTrue($action_fired, 'Book published action did not fire');
}
/**
* Test filter modifies content
*/
public function test_reading_time_filter() {
$content = str_repeat('word ', 200); // 200 words
// Apply filter
$filtered = apply_filters('my_plugin_content_filter', $content);
$this->assertStringContainsString('reading time', strtolower($filtered));
$this->assertStringContainsString('1 min', $filtered);
}
}
WP_Mock Purpose:
When to Use WP_Mock:
✅ Perfect for:
❌ NOT Suitable for:
# Install WP_Mock and Mockery
composer require --dev mockery/mockery "^1.6"
composer require --dev 10up/wp_mock "^1.0"
composer require --dev phpunit/phpunit "^9.6"
tests/bootstrap-wp-mock.php:
<?php
/**
* Bootstrap file for WP_Mock tests
*/
require_once __DIR__ . '/../vendor/autoload.php';
// WP_Mock setup
WP_Mock::bootstrap();
// Define WordPress constants if needed
if (!defined('ABSPATH')) {
define('ABSPATH', '/path/to/wordpress/');
}
phpunit-wp-mock.xml.dist:
<?xml version="1.0"?>
<phpunit
bootstrap="tests/bootstrap-wp-mock.php"
backupGlobals="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
>
<testsuites>
<testsuite name="unit">
<directory prefix="test-" suffix=".php">./tests/unit/</directory>
</testsuite>
</testsuites>
</phpunit>
tests/unit/test-data-processor.php:
<?php
use WP_Mock\Tools\TestCase;
class Test_Data_Processor extends TestCase {
public function setUp(): void {
WP_Mock::setUp();
}
public function tearDown(): void {
WP_Mock::tearDown();
}
/**
* Test sanitization function
*/
public function test_sanitize_input() {
// Mock sanitize_text_field
WP_Mock::userFunction('sanitize_text_field', [
'times' => 1,
'args' => ['<script>alert("xss")</script>'],
'return' => 'alert("xss")', // WordPress strips tags
]);
$processor = new MyPlugin\DataProcessor();
$result = $processor->sanitize_input('<script>alert("xss")</script>');
$this->assertEquals('alert("xss")', $result);
}
/**
* Test get_option is called
*/
public function test_get_setting() {
// Mock get_option call
WP_Mock::userFunction('get_option', [
'times' => 1,
'args' => ['my_plugin_api_key', ''],
'return' => 'test_api_key_12345',
]);
$processor = new MyPlugin\DataProcessor();
$api_key = $processor->get_api_key();
$this->assertEquals('test_api_key_12345', $api_key);
}
/**
* Test multiple function calls with different returns
*/
public function test_user_data_retrieval() {
$user_id = 42;
// Mock get_user_meta
WP_Mock::userFunction('get_user_meta', [
'times' => 1,
'args' => [$user_id, 'first_name', true],
'return' => 'John',
]);
WP_Mock::userFunction('get_user_meta', [
'times' => 1,
'args' => [$user_id, 'last_name', true],
'return' => 'Doe',
]);
$processor = new MyPlugin\DataProcessor();
$full_name = $processor->get_user_full_name($user_id);
$this->assertEquals('John Doe', $full_name);
}
/**
* Test function with type matcher
*/
public function test_save_data_with_array() {
// Accept any array as second argument
WP_Mock::userFunction('update_option', [
'times' => 1,
'args' => [
'my_plugin_settings',
WP_Mock\Functions::type('array'),
],
'return' => true,
]);
$processor = new MyPlugin\DataProcessor();
$result = $processor->save_settings(['api_key' => 'test123']);
$this->assertTrue($result);
}
}
Testing add_filter() Calls:
<?php
class Test_Hook_Registration extends WP_Mock\Tools\TestCase {
public function setUp(): void {
WP_Mock::setUp();
}
public function tearDown(): void {
WP_Mock::tearDown();
}
/**
* Test that filter is registered
*/
public function test_content_filter_registered() {
// Expect filter to be added
WP_Mock::expectFilterAdded(
'the_content',
'MyPlugin\ContentFilter::add_reading_time',
10,
1
);
// Execute function that adds the filter
MyPlugin\Hooks::register_filters();
// Verify expectations met
$this->assertConditionsMet();
}
/**
* Test that action is registered
*/
public function test_init_action_registered() {
WP_Mock::expectActionAdded(
'init',
'MyPlugin\PostTypes::register_custom_post_types',
10,
0
);
MyPlugin\Hooks::register_actions();
$this->assertConditionsMet();
}
/**
* Test apply_filters modifies value
*/
public function test_apply_custom_filter() {
$original_value = 100;
$filtered_value = 150;
// Mock apply_filters
WP_Mock::onFilter('my_plugin_price')
->with($original_value)
->reply($filtered_value);
$processor = new MyPlugin\PriceCalculator();
$result = $processor->get_final_price($original_value);
$this->assertEquals($filtered_value, $result);
}
/**
* Test do_action is called
*/
public function test_custom_action_fired() {
$order_id = 12345;
// Expect action to be fired with specific arguments
WP_Mock::expectAction('my_plugin_order_processed', $order_id);
$processor = new MyPlugin\OrderProcessor();
$processor->process_order($order_id);
$this->assertConditionsMet();
}
}
Example: Email Service Class:
<?php
namespace MyPlugin;
class EmailService {
public function send_notification(string $to, string $message): bool {
$subject = $this->get_email_subject();
$headers = $this->get_email_headers();
return wp_mail($to, $subject, $message, $headers);
}
protected function get_email_subject(): string {
$site_name = get_bloginfo('name');
return sprintf('[%s] Notification', $site_name);
}
protected function get_email_headers(): array {
$admin_email = get_option('admin_email');
return [
'From: ' . $admin_email,
'Content-Type: text/html; charset=UTF-8',
];
}
}
Unit Test Without WordPress:
<?php
use WP_Mock\Tools\TestCase;
class Test_Email_Service extends TestCase {
public function setUp(): void {
WP_Mock::setUp();
}
public function tearDown(): void {
WP_Mock::tearDown();
}
/**
* Test email sending logic
*/
public function test_send_notification_email() {
// Mock get_bloginfo
WP_Mock::userFunction('get_bloginfo', [
'args' => 'name',
'return' => 'My WordPress Site',
]);
// Mock get_option
WP_Mock::userFunction('get_option', [
'args' => 'admin_email',
'return' => '[email protected]',
]);
// Mock wp_mail and verify arguments
WP_Mock::userFunction('wp_mail', [
'times' => 1,
'args' => [
'[email protected]',
'[My WordPress Site] Notification',
'Test message content',
WP_Mock\Functions::type('array'),
],
'return' => true,
]);
$service = new MyPlugin\EmailService();
$result = $service->send_notification(
'[email protected]',
'Test message content'
);
$this->assertTrue($result);
}
/**
* Test email failure handling
*/
public function test_email_send_failure() {
WP_Mock::userFunction('get_bloginfo', [
'return' => 'Test Site',
]);
WP_Mock::userFunction('get_option', [
'return' => '[email protected]',
]);
// Simulate wp_mail failure
WP_Mock::userFunction('wp_mail', [
'return' => false,
]);
$service = new MyPlugin\EmailService();
$result = $service->send_notification('[email protected]', 'Message');
$this->assertFalse($result);
}
}
via Composer (Recommended):
# Allow PHPCS composer installer plugin
composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
# Install WordPress Coding Standards
composer require --dev wp-coding-standards/wpcs:"^3.0"
# Install PHP Compatibility checker
composer require --dev phpcompatibility/phpcompatibility-wp:"*"
# Install PHPCS itself (if not already installed)
composer require --dev squizlabs/php_codesniffer:"^3.7"
# Verify installation
vendor/bin/phpcs -i
# Should show: WordPress, WordPress-Core, WordPress-Docs, WordPress-Extra
Complete Configuration File:
<?xml version="1.0"?>
<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
name="WordPress Plugin Coding Standards"
xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/squizlabs/PHP_CodeSniffer/master/phpcs.xsd">
<description>Custom coding standards for WordPress plugin</description>
<!-- What to scan -->
<file>./includes</file>
<file>./my-plugin.php</file>
<!-- Exclude patterns -->
<exclude-pattern>*/vendor/*</exclude-pattern>
<exclude-pattern>*/node_modules/*</exclude-pattern>
<exclude-pattern>*/tests/*</exclude-pattern>
<exclude-pattern>*/build/*</exclude-pattern>
<exclude-pattern>*/.git/*</exclude-pattern>
<!-- Show progress -->
<arg value="ps"/>
<arg name="colors"/>
<arg name="extensions" value="php"/>
<arg name="parallel" value="8"/>
<!-- Rules: Use WordPress-Extra ruleset -->
<rule ref="WordPress-Extra">
<!-- Allow short array syntax [] instead of array() -->
<exclude name="Generic.Arrays.DisallowShortArraySyntax"/>
<!-- Allow multiple assignments in single line -->
<exclude name="Squiz.PHP.DisallowMultipleAssignments"/>
<!-- Relax file comment requirements -->
<exclude name="Squiz.Commenting.FileComment"/>
</rule>
<!-- WordPress.WP.I18n: Check text domain -->
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="my-plugin"/>
</property>
</properties>
</rule>
<!-- WordPress.NamingConventions.PrefixAllGlobals: Check function/class prefixes -->
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<properties>
<property name="prefixes" type="array">
<element value="my_plugin"/>
<element value="MyPlugin"/>
</property>
</properties>
</rule>
<!-- PHP version compatibility -->
<config name="testVersion" value="8.1-"/>
<rule ref="PHPCompatibilityWP"/>
<!-- Minimum supported WordPress version -->
<config name="minimum_wp_version" value="6.4"/>
<!-- Exclude specific rules for test files -->
<rule ref="WordPress.Files.FileName">
<exclude-pattern>*/tests/*</exclude-pattern>
</rule>
<!-- Enforce line length limit (warning at 80, error at 120) -->
<rule ref="Generic.Files.LineLength">
<properties>
<property name="lineLimit" value="120"/>
<property name="absoluteLineLimit" value="150"/>
</properties>
</rule>
<!-- Allow WordPress globals to be modified -->
<rule ref="WordPress.WP.GlobalVariablesOverride">
<type>error</type>
</rule>
</ruleset>
Command Line Usage:
# Check all files
vendor/bin/phpcs
# Check specific file
vendor/bin/phpcs includes/Core.php
# Show error codes
vendor/bin/phpcs -s
# Show only errors (hide warnings)
vendor/bin/phpcs -n
# Generate report summary
vendor/bin/phpcs --report=summary
# Check single file with detailed output
vendor/bin/phpcs -v includes/Admin/Settings.php
# Auto-fix fixable issues
vendor/bin/phpcbf
# Auto-fix specific file
vendor/bin/phpcbf includes/Core.php
# Dry run (show what would be fixed)
vendor/bin/phpcbf --dry-run
# Use specific standard
vendor/bin/phpcs --standard=WordPress-Core includes/
# Generate different report formats
vendor/bin/phpcs --report=json > phpcs-report.json
vendor/bin/phpcs --report=xml > phpcs-report.xml
vendor/bin/phpcs --report=csv > phpcs-report.csv
composer.json Scripts:
{
"scripts": {
"phpcs": "phpcs",
"phpcbf": "phpcbf",
"phpcs:check": "phpcs --report=summary",
"phpcs:fix": "phpcbf",
"test": [
"@phpcs",
"phpunit"
]
}
}
Install pre-commit hook (.git/hooks/pre-commit):
#!/bin/bash
# Run PHPCS on changed PHP files
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '.php$')
if [ -z "$FILES" ]; then
echo "No PHP files to check"
exit 0
fi
echo "Running PHPCS on changed files..."
vendor/bin/phpcs $FILES
PHPCS_EXIT=$?
if [ $PHPCS_EXIT -ne 0 ]; then
echo ""
echo "PHPCS found coding standard violations."
echo "Run 'composer phpcbf' to auto-fix issues."
echo ""
exit 1
fi
echo "PHPCS passed!"
exit 0
Make hook executable:
chmod +x .git/hooks/pre-commit
Visual Studio Code (.vscode/settings.json):
{
"phpcs.enable": true,
"phpcs.standard": "WordPress",
"phpcs.executablePath": "${workspaceFolder}/vendor/bin/phpcs",
"phpcbf.enable": true,
"phpcbf.executablePath": "${workspaceFolder}/vendor/bin/phpcbf",
"phpcbf.onsave": false,
"editor.formatOnSave": false,
"[php]": {
"editor.defaultFormatter": "bmewburn.vscode-intelephense-client",
"editor.formatOnSave": true
}
}
PHPStorm Configuration:
{PROJECT_ROOT}/vendor/bin/phpcs{PROJECT_ROOT}/.phpcs.xml.dist.github/workflows/tests.yml: