WordPress Testing & Quality Assurance progressive_disclosure: entry_point: summary: "WordPress plugin and theme testing with PHPUnit, WP_Mock, PHPCS, and CI/CD for quality assurance" when_to_use: - "Testing WordPress plugins with PHPUnit integration tests" - "Unit testing without loading WordPress core (WP_Mock)" - "Enforcing coding standards with PHPCS" quick_start: - "Set up PHPUnit with WordPress test suite" - "Write unit tests with WP_Mock" - "Configure PHPCS with WPCS ruleset" Testing Strategy Testing Pyramid for WordPress
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:
Unit Tests (60%): Fast, isolated, no WordPress Pure PHP functions Class methods with clear inputs/outputs Business logic without side effects Integration Tests (30%): WordPress-loaded tests Database operations Hook/filter interactions Custom post type registration Settings API functionality E2E Tests (10%): Browser automation Critical user workflows Admin panel interactions Frontend form submissions When to Use PHPUnit vs WP_Mock
Use PHPUnit (Integration Tests) when:
✅ Testing database operations ($wpdb, post creation, meta data) ✅ Testing WordPress hooks (actions/filters actually firing) ✅ Testing template rendering and output ✅ Testing plugin activation/deactivation logic ✅ Testing with actual WordPress functions
Use WP_Mock (Unit Tests) when:
✅ Testing pure business logic ✅ Testing functions that call WordPress functions but logic is independent ✅ Need fast test execution (no database setup) ✅ Testing in isolation without side effects ✅ Mocking external API calls Test Coverage Goals
Minimum Coverage Requirements:
New Code: 80% minimum coverage Critical Paths: 95% coverage (payment processing, authentication, data validation) Legacy Code: Gradual improvement, prioritize high-risk areas Public APIs: 100% coverage for all public methods
What to Test (Priority Order):
Security Functions: Nonce verification, sanitization, capability checks Data Operations: Database CRUD, data validation, transformation Business Logic: Calculations, workflows, state transitions Hook Callbacks: Action/filter handlers Public APIs: REST endpoints, WP-CLI commands
What NOT to Test:
❌ WordPress core functions (assume they work) ❌ Third-party library internals ❌ Simple getters/setters with no logic ❌ Configuration files (theme.json, block.json) PHPUnit Integration Testing WordPress Test Suite Setup
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
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
<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>
WP_UnitTestCase Base Class
tests/bootstrap.php:
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' => 'editor@example.com', ]); // 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 blogs Database Fixtures and Teardown setUp() and tearDown() Methods: 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(): 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); } } Complete Plugin Test Example tests/test-plugin-functionality.php: 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 Unit Testing What is WP_Mock and When to Use It WP_Mock Purpose: Test PHP code without loading WordPress Mock WordPress functions to return expected values Verify WordPress functions are called with correct arguments Much faster than integration tests (no database setup) When to Use WP_Mock: ✅ Perfect for: Pure business logic that calls WordPress functions Data transformation/validation functions Service classes with WordPress dependencies Testing in continuous integration (faster CI builds) ❌ NOT Suitable for: Testing actual database operations Testing hook interactions between plugins Testing template rendering Testing functions that rely on WordPress state Installation and Setup # 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:<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>
Mocking WordPress Functions
tests/unit/test-data-processor.php:
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); } } Mocking Filters and Actions Testing add_filter() Calls: 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(); } } Testing in Isolation (No WordPress Dependency) Example: Email Service Class: 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: 'name', 'return' => 'My WordPress Site', ]); // Mock get_option WP_Mock::userFunction('get_option', [ 'args' => 'admin_email', 'return' => 'admin@example.com', ]); // Mock wp_mail and verify arguments WP_Mock::userFunction('wp_mail', [ 'times' => 1, 'args' => [ 'user@example.com', '[My WordPress Site] Notification', 'Test message content', WP_Mock\Functions::type('array'), ], 'return' => true, ]); $service = new MyPlugin\EmailService(); $result = $service->send_notification( 'user@example.com', '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' => 'admin@test.com', ]); // Simulate wp_mail failure WP_Mock::userFunction('wp_mail', [ 'return' => false, ]); $service = new MyPlugin\EmailService(); $result = $service->send_notification('user@test.com', 'Message'); $this->assertFalse($result); } } PHPCS & Coding Standards Installing PHPCS and WPCS 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 .phpcs.xml.dist Configuration Complete Configuration File:<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>
Running PHPCS and PHPCBF
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" ] } }
Pre-commit Hooks
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
IDE Integration
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:
Go to Settings → PHP → Quality Tools → PHP_CodeSniffer Set Configuration path: {PROJECT_ROOT}/vendor/bin/phpcs Go to Settings → Editor → Inspections → PHP → Quality Tools Enable "PHP_CodeSniffer validation" Set Coding standard: "Custom" Set Path: {PROJECT_ROOT}/.phpcs.xml.dist GitHub Actions CI/CD Workflow File Structure
.github/workflows/tests.yml:
name: Test Suite
on: push: branches: [ main, develop ] pull_request: branches: [ main ]
jobs: # Job 1: Coding Standards Check phpcs: name: PHPCS runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-suggest
- name: Run PHPCS
run: vendor/bin/phpcs --report=summary
# Job 2: PHPUnit Tests with Matrix phpunit: name: PHPUnit (PHP ${{ matrix.php }}, WP ${{ matrix.wordpress }}) runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3']
wordpress: ['6.4', '6.5', '6.6', '6.7', 'latest']
include:
- php: '8.3'
wordpress: 'trunk'
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 code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mysqli, zip
tools: composer
coverage: xdebug
- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress
- name: Install WordPress test suite
run: |
bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1:3306 ${{ matrix.wordpress }}
- name: Run PHPUnit tests
run: vendor/bin/phpunit --coverage-clover=coverage.xml
- name: Upload coverage to Codecov
if: matrix.php == '8.3' && matrix.wordpress == 'latest'
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
flags: unittests
name: codecov-umbrella
# Job 3: WP_Mock Unit Tests wp-mock: name: WP_Mock Unit Tests runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run WP_Mock tests
run: vendor/bin/phpunit -c phpunit-wp-mock.xml.dist
Matrix Testing (Multiple PHP/WP Versions)
Strategy Explanation:
strategy: fail-fast: false # Continue testing other versions even if one fails matrix: php: ['8.1', '8.2', '8.3'] # Test PHP versions wordpress: ['6.4', '6.5', '6.6', '6.7', 'latest'] # Test WP versions include: # Add specific combination not in default matrix - php: '8.3' wordpress: 'trunk' # WordPress development version exclude: # Exclude incompatible combinations - php: '8.1' wordpress: 'trunk'
Matrix Results:
Creates 18 test jobs (3 PHP × 6 WordPress versions) Ensures compatibility across supported versions Identifies version-specific issues early PHPCS Checks in CI
Dedicated PHPCS Job:
phpcs-detailed: name: Detailed PHPCS Report runs-on: ubuntu-latest
steps: - uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer, cs2pr
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run PHPCS with annotations
run: vendor/bin/phpcs -q --report=checkstyle | cs2pr
- name: Generate PHPCS report
if: failure()
run: vendor/bin/phpcs --report=summary --report-file=phpcs-report.txt
- name: Upload PHPCS report
if: failure()
uses: actions/upload-artifact@v3
with:
name: phpcs-report
path: phpcs-report.txt
PHPUnit Test Execution
With Code Coverage:
phpunit-coverage: name: PHPUnit with Coverage 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
steps: - uses: actions/checkout@v4
- name: Setup PHP with Xdebug
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mysqli, zip, gd
tools: composer
coverage: xdebug
ini-values: xdebug.mode=coverage
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Install WordPress test suite
run: bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1:3306 latest
- name: Run tests with coverage
run: vendor/bin/phpunit --coverage-html coverage-html --coverage-clover coverage.xml
- name: Upload coverage HTML report
uses: actions/upload-artifact@v3
with:
name: coverage-report
path: coverage-html
- name: Check coverage threshold
run: |
COVERAGE=$(vendor/bin/phpunit --coverage-text | grep "Lines:" | awk '{print $2}' | sed 's/%//')
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage $COVERAGE% is below 80% threshold"
exit 1
fi
Coverage Reporting
Codecov Integration:
- name: Upload to Codecov uses: codecov/codecov-action@v4 with: files: ./coverage.xml flags: unittests name: codecov-umbrella fail_ci_if_error: true verbose: true
Coveralls Integration:
- name: Upload to Coveralls uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: ./coverage.xml
Complete Workflow Example
.github/workflows/ci.yml (Production-Ready):
name: CI Pipeline
on: push: branches: [ main, develop ] pull_request: branches: [ main ] schedule: - cron: '0 0 * * 0' # Weekly on Sunday
jobs: coding-standards: name: Coding Standards runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.3' tools: composer, cs2pr - run: composer install --prefer-dist --no-progress - run: vendor/bin/phpcs -q --report=checkstyle | cs2pr
unit-tests: name: Unit Tests (WP_Mock) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.3' tools: composer - run: composer install --prefer-dist --no-progress - run: vendor/bin/phpunit -c phpunit-wp-mock.xml.dist --testdox
integration-tests: name: Integration Tests runs-on: ubuntu-latest strategy: matrix: php: ['8.1', '8.3'] wordpress: ['6.5', '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 steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: mysqli tools: composer coverage: xdebug - run: composer install --prefer-dist --no-progress - run: bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1:3306 ${{ matrix.wordpress }} - run: vendor/bin/phpunit --coverage-clover=coverage.xml - uses: codecov/codecov-action@v4 if: matrix.php == '8.3' && matrix.wordpress == 'latest' with: files: ./coverage.xml
deploy-ready: name: Deployment Check needs: [coding-standards, unit-tests, integration-tests] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - run: echo "All checks passed - ready for deployment"
Testing Best Practices Test Naming Conventions
Method Naming Pattern:
test_[method_name][scenario][expected_result]
Examples:
// ✅ GOOD: Descriptive test names public function test_sanitize_email_with_valid_email_returns_email() {} public function test_sanitize_email_with_invalid_email_returns_empty_string() {} public function test_save_post_meta_with_valid_data_returns_true() {} public function test_user_login_with_wrong_password_returns_wp_error() {}
// ❌ BAD: Vague test names public function test_email() {} public function test_function() {} public function test_it_works() {}
Class Naming:
// Pattern: Test_[ClassName] class Test_Email_Service extends WP_UnitTestCase {} class Test_Data_Validator extends WP_Mock\Tools\TestCase {} class Test_Post_Meta_Handler extends WP_UnitTestCase {}
Arrange-Act-Assert Pattern
Structure Every Test:
public function test_calculate_discount() { // ARRANGE: Set up test data and conditions $original_price = 100; $discount_percent = 20; $calculator = new MyPlugin\PriceCalculator();
// ACT: Execute the code being tested
$discounted_price = $calculator->apply_discount($original_price, $discount_percent);
// ASSERT: Verify expected outcome
$this->assertEquals(80, $discounted_price);
}
Complete Example:
public function test_save_user_preferences_updates_database() { // ARRANGE $user_id = $this->factory->user->create(); $preferences = [ 'theme' => 'dark', 'notifications' => true, ]; $service = new MyPlugin\UserPreferences();
// ACT
$result = $service->save_preferences($user_id, $preferences);
// ASSERT
$this->assertTrue($result);
$saved_prefs = get_user_meta($user_id, 'preferences', true);
$this->assertEquals('dark', $saved_prefs['theme']);
$this->assertTrue($saved_prefs['notifications']);
}
Data Providers
Purpose: Test same logic with multiple inputs
/* * @dataProvider email_validation_provider / public function test_email_validation($email, $expected) { $validator = new MyPlugin\Validator(); $result = $validator->is_valid_email($email); $this->assertEquals($expected, $result); }
/* * Data provider for email validation tests / public function email_validation_provider(): array { return [ 'valid email' => ['user@example.com', true], 'invalid no at' => ['userexample.com', false], 'invalid no domain' => ['user@', false], 'invalid spaces' => ['user @example.com', false], 'valid subdomain' => ['user@mail.example.com', true], 'invalid special chars' => ['user#@example.com', false], ]; }
Complex Data Provider:
/* * @dataProvider discount_calculation_provider / public function test_discount_calculation($price, $discount, $expected) { $calculator = new MyPlugin\PriceCalculator(); $result = $calculator->apply_discount($price, $discount); $this->assertEquals($expected, $result); }
public function discount_calculation_provider(): array { return [ '20% off 100' => [100, 20, 80], '50% off 100' => [100, 50, 50], '0% off 100' => [100, 0, 100], '100% off 100' => [100, 100, 0], '20% off 0' => [0, 20, 0], ]; }
Testing Hooks and Filters
Testing add_action/add_filter:
public function test_init_hooks_registered() { // Remove all hooks first remove_all_actions('init');
// Register plugin hooks
MyPlugin\Hooks::register();
// Verify action was added
$this->assertTrue(has_action('init', 'MyPlugin\PostTypes::register'));
$this->assertEquals(10, has_action('init', 'MyPlugin\PostTypes::register'));
}
public function test_content_filter_registered() { remove_all_filters('the_content');
MyPlugin\Hooks::register();
$this->assertTrue(has_filter('the_content', 'MyPlugin\Content::add_reading_time'));
}
Testing Hook Callbacks:
public function test_save_post_hook_saves_meta() { $post_id = $this->factory->post->create([ 'post_type' => 'book', ]);
$_POST['book_isbn'] = '978-3-16-148410-0';
$_POST['book_nonce'] = wp_create_nonce('save_book_meta');
// Manually trigger the hook callback
do_action('save_post', $post_id);
// Verify meta was saved
$isbn = get_post_meta($post_id, '_isbn', true);
$this->assertEquals('978-3-16-148410-0', $isbn);
}
Testing AJAX Handlers
AJAX Test Setup:
public function test_ajax_load_more_posts() { // Create test posts $post_ids = $this->factory->post->create_many(5);
// Set up AJAX request
$_POST['action'] = 'load_more_posts';
$_POST['page'] = 1;
$_POST['nonce'] = wp_create_nonce('load_more_nonce');
// Set current user (if authentication required)
wp_set_current_user($this->factory->user->create(['role' => 'subscriber']));
// Capture output
try {
$this->_handleAjax('load_more_posts');
} catch (WPAjaxDieContinueException $e) {
// Expected exception
}
// Get response
$response = json_decode($this->_last_response, true);
$this->assertTrue($response['success']);
$this->assertCount(5, $response['data']['posts']);
}
Common Testing Patterns Testing Custom Post Types class Test_Book_Post_Type extends WP_UnitTestCase {
public function setUp(): void {
parent::setUp();
// Ensure CPT is registered
MyPlugin\PostTypes::register_book();
}
public function test_book_post_type_exists() {
$this->assertTrue(post_type_exists('book'));
}
public function test_book_supports_features() {
$post_type = get_post_type_object('book');
$this->assertTrue(post_type_supports('book', 'title'));
$this->assertTrue(post_type_supports('book', 'editor'));
$this->assertTrue(post_type_supports('book', 'thumbnail'));
$this->assertFalse(post_type_supports('book', 'comments'));
}
public function test_book_has_rest_support() {
$post_type = get_post_type_object('book');
$this->assertTrue($post_type->show_in_rest);
}
public function test_create_book_post() {
$book_id = $this->factory->post->create([
'post_type' => 'book',
'post_title' => 'The Great Gatsby',
]);
$book = get_post($book_id);
$this->assertEquals('book', $book->post_type);
$this->assertEquals('The Great Gatsby', $book->post_title);
}
}
Testing Settings/Options class Test_Plugin_Settings extends WP_UnitTestCase {
public function tearDown(): void {
delete_option('my_plugin_settings');
parent::tearDown();
}
public function test_default_settings_created() {
$settings = MyPlugin\Settings::get_defaults();
$this->assertIsArray($settings);
$this->assertArrayHasKey('api_key', $settings);
$this->assertEquals('', $settings['api_key']);
}
public function test_save_settings() {
$new_settings = [
'api_key' => 'test_key_123',
'enabled' => true,
];
$result = MyPlugin\Settings::save($new_settings);
$this->assertTrue($result);
$saved = get_option('my_plugin_settings');
$this->assertEquals('test_key_123', $saved['api_key']);
$this->assertTrue($saved['enabled']);
}
public function test_sanitize_settings() {
$dirty_input = [
'api_key' => '<script>alert("xss")</script>',
'enabled' => 'yes',
];
$clean = MyPlugin\Settings::sanitize($dirty_input);
$this->assertEquals('alert("xss")', $clean['api_key']);
$this->assertTrue($clean['enabled']);
}
}
Testing Database Operations class Test_Database_Operations extends WP_UnitTestCase {
protected static $table_name;
public static function setUpBeforeClass(): void {
parent::setUpBeforeClass();
global $wpdb;
self::$table_name = $wpdb->prefix . 'plugin_logs';
$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,
action varchar(50) NOT NULL,
created_at datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query("DROP TABLE IF EXISTS " . self::$table_name);
parent::tearDownAfterClass();
}
public function test_insert_log_entry() {
global $wpdb;
$user_id = 1;
$action = 'user_login';
$result = $wpdb->insert(
self::$table_name,
[
'user_id' => $user_id,
'action' => $action,
],
['%d', '%s']
);
$this->assertEquals(1, $result);
$this->assertGreaterThan(0, $wpdb->insert_id);
// Verify data
$log = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM " . self::$table_name . " WHERE id = %d",
$wpdb->insert_id
)
);
$this->assertEquals($user_id, $log->user_id);
$this->assertEquals($action, $log->action);
}
public function test_query_logs_by_user() {
global $wpdb;
$user_id = 42;
// Insert test data
$wpdb->insert(self::$table_name, ['user_id' => $user_id, 'action' => 'login'], ['%d', '%s']);
$wpdb->insert(self::$table_name, ['user_id' => $user_id, 'action' => 'logout'], ['%d', '%s']);
// Query logs
$logs = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM " . self::$table_name . " WHERE user_id = %d",
$user_id
)
);
$this->assertCount(2, $logs);
}
}
Testing REST API Endpoints class Test_REST_API extends WP_UnitTestCase {
protected $server;
public 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_endpoint_registered() {
$routes = $this->server->get_routes();
$this->assertArrayHasKey('/myplugin/v1/items', $routes);
}
public function test_get_items_endpoint() {
// Create test posts
$post_ids = $this->factory->post->create_many(3, ['post_type' => 'book']);
$request = new WP_REST_Request('GET', '/myplugin/v1/items');
$response = $this->server->dispatch($request);
$this->assertEquals(200, $response->get_status());
$data = $response->get_data();
$this->assertCount(3, $data);
}
public function test_create_item_requires_authentication() {
$request = new WP_REST_Request('POST', '/myplugin/v1/items');
$request->set_body_params([
'title' => 'New Item',
]);
$response = $this->server->dispatch($request);
$this->assertEquals(401, $response->get_status());
}
public function test_create_item_with_authentication() {
$user_id = $this->factory->user->create(['role' => 'editor']);
wp_set_current_user($user_id);
$request = new WP_REST_Request('POST', '/myplugin/v1/items');
$request->set_body_params([
'title' => 'New Item',
'content' => 'Item content',
]);
$response = $this->server->dispatch($request);
$this->assertEquals(201, $response->get_status());
$data = $response->get_data();
$this->assertEquals('New Item', $data['title']);
}
}
Related Skills: When testing WordPress applications, consider these complementary skills (available in the skill library):
WordPress Plugin Fundamentals: Core plugin architecture and hooks - essential foundation for understanding what to test WordPress Security & Validation: Security patterns and data validation - critical for security testing strategies Python pytest Testing: Modern testing patterns - concepts applicable to WordPress testing approaches GitHub Actions CI/CD: CI/CD automation - integrate WordPress tests into automated pipelines
Further Reading:
WordPress PHPUnit Documentation WP_Mock GitHub Repository WordPress Coding Standards PHPUnit Documentation