From wordpress-expert
Guides WordPress testing strategy using PHPUnit and WP_UnitTestCase, with test categories, coverage targets, and AAA patterns. Use for writing tests, infrastructure setup, or coverage review.
How this skill is triggered — by the user, by Claude, or both
Slash command
/wordpress-expert:testingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
For all custom code (themes and plugins), design and maintain tests using PHPUnit with the WordPress test framework (`WP_UnitTestCase`).
For all custom code (themes and plugins), design and maintain tests using PHPUnit with the WordPress test framework (WP_UnitTestCase).
tests/
├── unit/ # Pure unit tests (no WordPress dependencies)
│ ├── Validators/ # Input validation functions
│ ├── Formatters/ # Data formatting/transformation
│ └── Calculators/ # Business logic calculations
├── integration/ # Tests requiring WordPress (WP_UnitTestCase)
│ ├── PostTypes/ # Custom post type registration & behavior
│ ├── Taxonomies/ # Custom taxonomy behavior
│ ├── AJAX/ # AJAX handler tests
│ ├── REST/ # REST API endpoint tests
│ ├── Database/ # Custom table operations
│ ├── Hooks/ # Filter and action behavior
│ └── Admin/ # Admin page functionality
├── security/ # Security-specific tests
│ ├── Nonce/ # CSRF protection verification
│ ├── Capability/ # Authorization checks
│ ├── Sanitization/ # Input sanitization coverage
│ ├── Escaping/ # Output escaping coverage
│ └── SQLInjection/ # SQL injection resistance
├── performance/ # Performance regression tests
│ ├── QueryCount/ # Database query count assertions
│ ├── MemoryUsage/ # Memory consumption limits
│ └── ExecutionTime/ # Execution time thresholds
└── e2e/ # End-to-end tests (Cypress/Playwright)
├── Frontend/ # User-facing functionality
├── Admin/ # Dashboard functionality
└── Forms/ # Form submission flows
Every test should follow the AAA pattern:
public function test_user_can_save_settings() {
// Arrange: Set up test conditions
$user_id = $this->factory->user->create( [ 'role' => 'administrator' ] );
wp_set_current_user( $user_id );
$_POST['settings_nonce'] = wp_create_nonce( 'save_settings' );
$_POST['settings'] = [ 'option_a' => 'value_a' ];
// Act: Perform the action
$result = save_settings_handler();
// Assert: Verify the outcome
$this->assertTrue( $result );
$this->assertEquals( 'value_a', get_option( 'option_a' ) );
}
Each test method tests ONE behavior. If you need "and" in your test name, you probably need two tests.
Good:
public function test_unauthenticated_user_cannot_access_admin_ajax() { }
public function test_authenticated_user_can_access_admin_ajax() { }
Bad:
public function test_ajax_authentication_and_response() { }
Test names should describe the expected behavior in plain English:
public function test_post_with_missing_title_returns_validation_error() { }
public function test_expired_transient_returns_false() { }
public function test_admin_notice_appears_after_successful_save() { }
For testing multiple inputs against the same logic:
/**
* @dataProvider invalid_email_provider
*/
public function test_invalid_email_returns_error( $email ) {
$result = validate_email( $email );
$this->assertWPError( $result );
}
public function invalid_email_provider() {
return [
'missing_at_sign' => [ 'notanemail.com' ],
'missing_domain' => [ 'test@' ],
'spaces' => [ 'test @example.com' ],
'empty_string' => [ '' ],
];
}
Don't make real HTTP requests or file system operations in tests:
public function test_api_call_handles_timeout() {
// Mock wp_remote_get to simulate timeout
add_filter( 'pre_http_request', function( $preempt, $args, $url ) {
return new WP_Error( 'http_request_failed', 'Operation timed out' );
}, 10, 3 );
$result = fetch_api_data();
$this->assertFalse( $result );
}
Leverage WordPress test framework factories for creating test data:
// Create posts
$post_id = $this->factory->post->create( [
'post_title' => 'Test Post',
'post_type' => 'custom_type',
] );
// Create users
$user_id = $this->factory->user->create( [ 'role' => 'editor' ] );
// Create terms
$term_id = $this->factory->term->create( [
'taxonomy' => 'category',
'name' => 'Test Category',
] );
Use setUp() and tearDown() to ensure test isolation:
public function setUp(): void {
parent::setUp();
// Set up test conditions before each test
$this->admin_user = $this->factory->user->create( [ 'role' => 'administrator' ] );
}
public function tearDown(): void {
// Clean up after each test
wp_set_current_user( 0 );
parent::tearDown();
}
Write tests for the bug BEFORE writing the fix:
After each change:
<phpunit
bootstrap="tests/bootstrap.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true">
<testsuites>
<testsuite name="unit">
<directory suffix="Test.php">./tests/unit/</directory>
</testsuite>
<testsuite name="integration">
<directory suffix="Test.php">./tests/integration/</directory>
</testsuite>
<testsuite name="security">
<directory suffix="Test.php">./tests/security/</directory>
</testsuite>
<testsuite name="performance">
<directory suffix="Test.php">./tests/performance/</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">./src/</directory>
<directory suffix=".php">./includes/</directory>
</include>
<exclude>
<directory suffix=".php">./vendor/</directory>
<directory suffix=".php">./tests/</directory>
</exclude>
<report>
<html outputDirectory="tests/coverage/html"/>
<clover outputFile="tests/coverage/clover.xml"/>
</report>
</coverage>
</phpunit>
public function test_filter_modifies_post_title() {
$original_title = 'Original Title';
$filtered_title = apply_filters( 'my_plugin_post_title', $original_title );
$this->assertEquals( 'Modified: Original Title', $filtered_title );
}
public function test_action_sends_email() {
// Use a test mailer
$mailer = tests_retrieve_phpmailer_instance();
do_action( 'my_plugin_send_notification', '[email protected]' );
$this->assertEquals( '[email protected]', $mailer->get_recipient( 'to' )->address );
}
public function test_ajax_handler_requires_nonce() {
// Simulate AJAX request without nonce
try {
$this->_handleAjax( 'my_ajax_action' );
} catch ( WPAjaxDieContinueException $e ) {
// Expected to die with -1 (nonce failure)
}
$response = json_decode( $this->_last_response );
$this->assertEquals( -1, $response );
}
public function test_rest_endpoint_requires_authentication() {
$request = new WP_REST_Request( 'POST', '/my-plugin/v1/data' );
$response = rest_do_request( $request );
$this->assertEquals( 401, $response->get_status() );
}
public function test_rest_endpoint_returns_valid_data() {
$user_id = $this->factory->user->create( [ 'role' => 'administrator' ] );
wp_set_current_user( $user_id );
$request = new WP_REST_Request( 'GET', '/my-plugin/v1/data' );
$response = rest_do_request( $request );
$this->assertEquals( 200, $response->get_status() );
$this->assertArrayHasKey( 'data', $response->get_data() );
}
public function test_custom_table_created_on_activation() {
global $wpdb;
$table_name = $wpdb->prefix . 'my_custom_table';
my_plugin_activation_hook();
$this->assertEquals( $table_name, $wpdb->get_var( "SHOW TABLES LIKE '{$table_name}'" ) );
}
# Run all tests
phpunit
# Run specific test suite
phpunit --testsuite=unit
phpunit --testsuite=integration
phpunit --testsuite=security
# Run specific test file
phpunit tests/unit/Validators/EmailValidatorTest.php
# Run with coverage report
phpunit --coverage-html tests/coverage/html
# Run with code coverage filter
phpunit --filter test_specific_method
Integrate with CI/CD pipelines (GitHub Actions, GitLab CI, etc.):
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
steps:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Install dependencies
run: composer install
- name: Run tests
run: phpunit --coverage-clover coverage.xml
# Generate HTML coverage report
phpunit --coverage-html tests/coverage/html
# View in browser
open tests/coverage/html/index.html
# Generate coverage summary
phpunit --coverage-text
# Check coverage threshold (fail if below 80%)
phpunit --coverage-text --coverage-clover=coverage.xml --coverage-threshold=80
npx claudepluginhub dr-robert-li/cowork-wordpress-expertWrites PHPUnit tests for PHP projects: unit tests, assertions, mocks, stubs, data providers, exception testing, TDD workflows. Supports WooCommerce via WC_Unit_Test_Case.
Generates test scaffolding for untested PHP and JavaScript code. Supports unit, integration, e2e, and data tests with framework-specific guidance (PHPUnit, Jest, Cypress).
Designs and implements testing strategies—unit, integration, E2E—for any codebase. Provides framework recommendations (Vitest, Playwright, pytest, etc.) and test structure templates.