| name | wp-testing |
| description | This skill should be used when the user asks to 'write WordPress tests', 'add PHPUnit tests', 'test a plugin', 'test a theme', 'test REST endpoints', 'test WooCommerce', 'set up WordPress testing', 'bootstrap PHPUnit for WordPress', or mentions 'WP_UnitTestCase', 'WordPress test factories', 'phpunit wordpress', 'wp-env testing'. Provides WordPress-specific testing patterns using PHPUnit and the WordPress test suite. |
| license | MIT |
| metadata | {"author":"Chris Kelley (hello@iwritecode.io)","version":"1.0.0"} |
WordPress Testing with PHPUnit
Critical Rules
- Always extend
WP_UnitTestCase for integration tests. It provides automatic database transactions, factory methods, and WordPress-specific assertions.
- Use WordPress factory methods (
$this->factory->post, $this->factory->user, etc.) to create test data. Never insert directly into the database.
- Database cleanup is automatic.
WP_UnitTestCase wraps each test in a transaction and rolls back after completion. Do not manually truncate tables.
- Never test WordPress core. Do not write tests to verify that
add_action() fires or that get_post() returns a post. Trust core; test YOUR code.
- Use
wp-env for a reproducible local test environment. It provides a Docker-based WordPress installation with PHPUnit pre-configured.
- Isolate external dependencies. Mock HTTP requests, emails, file system operations, and third-party APIs. Never let tests hit real external services.
- One assertion concept per test. A test method should verify one logical behavior, even if that requires multiple
assert* calls.
Test Bootstrap Setup
tests/bootstrap.php
<?php
$_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" ) ) {
echo "Could not find {$_tests_dir}/includes/functions.php, have you run bin/install-wp-tests.sh?" . PHP_EOL;
exit( 1 );
}
require_once "{$_tests_dir}/includes/functions.php";
function _manually_load_plugin() {
require dirname( dirname( __FILE__ ) ) . '/my-plugin.php';
}
tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' );
require "{$_tests_dir}/includes/bootstrap.php";
phpunit.xml.dist
<?xml version="1.0"?>
<phpunit
bootstrap="tests/bootstrap.php"
backupGlobals="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
>
<testsuites>
<testsuite name="unit">
<directory prefix="test-" suffix=".php">./tests/unit/</directory>
</testsuite>
<testsuite name="integration">
<directory prefix="test-" suffix=".php">./tests/integration/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./includes</directory>
<directory suffix=".php">./src</directory>
<exclude>
<directory suffix=".php">./vendor</directory>
<directory suffix=".php">./tests</directory>
</exclude>
</whitelist>
</filter>
</phpunit>
Plugin Testing Patterns
Hook and Filter Tests
<?php
class Test_My_Plugin_Hooks extends WP_UnitTestCase {
public function test_init_action_registers_post_type() {
$this->assertGreaterThan(
0,
has_action( 'init', [ My_Plugin::class, 'register_custom_post_types' ] )
);
}
public function test_custom_action_fires_callback() {
$callback_ran = false;
add_action( 'my_plugin_after_save', function () use ( &$callback_ran ) {
$callback_ran = true;
} );
do_action( 'my_plugin_after_save', 42 );
$this->assertTrue( $callback_ran, 'Callback should have been executed on my_plugin_after_save.' );
}
public function test_content_filter_appends_cta() {
$this->go_to( '/?p=' . $this->factory->post->create() );
$original = '<p>Hello World</p>';
$filtered = apply_filters( 'the_content', $original );
$this->assertStringContainsString( 'class="my-plugin-cta"', $filtered );
}
public function test_content_filter_skips_pages() {
$page_id = $this->factory->post->create( [ 'post_type' => 'page' ] );
$this->go_to( '/?page_id=' . $page_id );
$original = '<p>Page content</p>';
$filtered = apply_filters( 'the_content', $original );
$this->assertStringNotContainsString( 'class="my-plugin-cta"', $filtered );
}
}
Shortcode Tests
<?php
class Test_My_Plugin_Shortcodes extends WP_UnitTestCase {
public function test_greeting_shortcode_renders_default() {
$output = do_shortcode( '[my_greeting]' );
$this->assertStringContainsString( '<div class="greeting">', $output );
$this->assertStringContainsString( 'Welcome!', $output );
}
public function test_greeting_shortcode_accepts_name_attribute() {
$output = do_shortcode( '[my_greeting name="Alice"]' );
$this->assertStringContainsString( 'Welcome, Alice!', $output );
}
public function test_greeting_shortcode_escapes_html_in_attributes() {
$output = do_shortcode( '[my_greeting name="<script>alert(1)</script>"]' );
$this->assertStringNotContainsString( '<script>', $output );
}
public function test_shortcode_with_content() {
$output = do_shortcode( '[my_wrapper]Inner content here[/my_wrapper]' );
$this->assertStringContainsString( '<div class="my-wrapper">', $output );
$this->assertStringContainsString( 'Inner content here', $output );
}
}
REST API Endpoint Tests
<?php
class Test_My_Plugin_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 tearDown(): void {
global $wp_rest_server;
$wp_rest_server = null;
parent::tearDown();
}
public function test_route_is_registered() {
$routes = $this->server->get_routes();
$this->assertArrayHasKey( '/my-plugin/v1/items', $routes );
}
public function test_get_items_returns_200() {
$this->factory->post->create_many( 3, [
'post_type' => 'my_item',
'post_status' => 'publish',
] );
$request = new WP_REST_Request( 'GET', '/my-plugin/v1/items' );
$response = $this->server->dispatch( $request );
$this->assertSame( 200, $response->get_status() );
$this->assertCount( 3, $response->get_data() );
}
public function test_create_item_requires_authentication() {
$request = new WP_REST_Request( 'POST', '/my-plugin/v1/items' );
$request->set_body_params( [ 'title' => 'New Item' ] );
$response = $this->server->dispatch( $request );
$this->assertSame( 401, $response->get_status() );
}
public function test_create_item_as_admin() {
$admin_id = $this->factory->user->create( [ 'role' => 'administrator' ] );
wp_set_current_user( $admin_id );
$request = new WP_REST_Request( 'POST', '/my-plugin/v1/items' );
$request->set_body_params( [
'title' => 'New Item',
'content' => 'Item description',
] );
$response = $this->server->dispatch( $request );
$this->assertSame( 201, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 'New Item', $data['title'] );
}
public function test_delete_item_returns_404_for_missing() {
$admin_id = $this->factory->user->create( [ 'role' => 'administrator' ] );
wp_set_current_user( $admin_id );
$request = new WP_REST_Request( 'DELETE', '/my-plugin/v1/items/99999' );
$response = $this->server->dispatch( $request );
$this->assertSame( 404, $response->get_status() );
}
}
Custom Post Type Tests
<?php
class Test_My_Plugin_Post_Types extends WP_UnitTestCase {
public function test_portfolio_post_type_exists() {
$this->assertTrue( post_type_exists( 'portfolio' ) );
}
public function test_portfolio_supports_expected_features() {
$supports = get_all_post_type_supports( 'portfolio' );
$this->assertArrayHasKey( 'title', $supports );
$this->assertArrayHasKey( 'editor', $supports );
$this->assertArrayHasKey( 'thumbnail', $supports );
}
public function test_portfolio_is_publicly_queryable() {
$post_type_obj = get_post_type_object( 'portfolio' );
$this->assertTrue( $post_type_obj->publicly_queryable );
}
public function test_portfolio_query_returns_only_portfolio_posts() {
$this->factory->post->create_many( 2, [ 'post_type' => 'post' ] );
$this->factory->post->create_many( 3, [
'post_type' => 'portfolio',
'post_status' => 'publish',
] );
$query = new WP_Query( [
'post_type' => 'portfolio',
'posts_per_page' => -1,
] );
$this->assertSame( 3, $query->found_posts );
foreach ( $query->posts as $post ) {
$this->assertSame( 'portfolio', $post->post_type );
}
}
public function test_portfolio_custom_taxonomy_is_registered() {
$this->assertTrue( taxonomy_exists( 'portfolio_category' ) );
$taxonomy = get_taxonomy( 'portfolio_category' );
$this->assertContains( 'portfolio', $taxonomy->object_type );
}
}
Options and Settings Tests
<?php
class Test_My_Plugin_Settings extends WP_UnitTestCase {
public function test_default_options_are_set_on_activation() {
do_action( 'activate_my-plugin/my-plugin.php' );
$options = get_option( 'my_plugin_settings' );
$this->assertIsArray( $options );
$this->assertSame( 'default', $options['mode'] );
$this->assertSame( 10, $options['items_per_page'] );
$this->assertTrue( $options['cache_enabled'] );
}
public function test_save_settings_sanitizes_input() {
$admin_id = $this->factory->user->create( [ 'role' => 'administrator' ] );
wp_set_current_user( $admin_id );
$input = [
'mode' => '<script>alert("xss")</script>advanced',
'items_per_page' => '25abc',
'cache_enabled' => '1',
];
$sanitized = My_Plugin_Settings::sanitize( $input );
$this->assertSame( 'advanced', $sanitized['mode'] );
$this->assertSame( 25, $sanitized['items_per_page'] );
$this->assertTrue( $sanitized['cache_enabled'] );
}
public function test_update_and_retrieve_option() {
$settings = [
'mode' => 'advanced',
'items_per_page' => 20,
'cache_enabled' => false,
];
update_option( 'my_plugin_settings', $settings );
$retrieved = get_option( 'my_plugin_settings' );
$this->assertSame( 'advanced', $retrieved['mode'] );
$this->assertSame( 20, $retrieved['items_per_page'] );
$this->assertFalse( $retrieved['cache_enabled'] );
}
public function test_delete_option_on_uninstall() {
update_option( 'my_plugin_settings', [ 'mode' => 'advanced' ] );
My_Plugin_Uninstall::clean_up();
$this->assertFalse( get_option( 'my_plugin_settings' ) );
}
}
Admin Page Tests
<?php
class Test_My_Plugin_Admin extends WP_UnitTestCase {
public function test_admin_menu_is_registered() {
$admin_id = $this->factory->user->create( [ 'role' => 'administrator' ] );
wp_set_current_user( $admin_id );
do_action( 'admin_menu' );
global $menu, $submenu;
$menu_slugs = wp_list_pluck( $menu, 2 );
$this->assertContains( 'my-plugin', $menu_slugs );
}
public function test_settings_page_renders_without_errors() {
$admin_id = $this->factory->user->create( [ 'role' => 'administrator' ] );
wp_set_current_user( $admin_id );
set_current_screen( 'toplevel_page_my-plugin' );
ob_start();
My_Plugin_Admin::render_settings_page();
$output = ob_get_clean();
$this->assertStringContainsString( '<form', $output );
$this->assertStringContainsString( 'my_plugin_settings', $output );
}
public function test_admin_page_requires_manage_options_capability() {
$subscriber_id = $this->factory->user->create( [ 'role' => 'subscriber' ] );
wp_set_current_user( $subscriber_id );
$this->assertFalse( current_user_can( 'manage_options' ) );
}
public function test_admin_scripts_enqueued_on_plugin_page() {
$admin_id = $this->factory->user->create( [ 'role' => 'administrator' ] );
wp_set_current_user( $admin_id );
set_current_screen( 'toplevel_page_my-plugin' );
do_action( 'admin_enqueue_scripts', 'toplevel_page_my-plugin' );
$this->assertTrue( wp_script_is( 'my-plugin-admin', 'enqueued' ) );
$this->assertTrue( wp_style_is( 'my-plugin-admin', 'enqueued' ) );
}
}
AJAX Handler Tests
<?php
class Test_My_Plugin_AJAX extends WP_UnitTestCase {
protected function make_ajax_call( string $action, array $post_data = [] ) {
$_POST['action'] = $action;
$_POST['nonce'] = wp_create_nonce( 'my_plugin_nonce' );
foreach ( $post_data as $key => $value ) {
$_POST[ $key ] = $value;
}
try {
do_action( 'wp_ajax_' . $action );
} catch ( WPAjaxDieContinueException $e ) {
}
}
public function test_ajax_save_item_success() {
$admin_id = $this->factory->user->create( [ 'role' => 'administrator' ] );
wp_set_current_user( $admin_id );
$this->make_ajax_call( 'my_plugin_save_item', [
'item_name' => 'Test Item',
'item_desc' => 'A description.',
] );
$response = json_decode( $this->_last_response, true );
$this->assertTrue( $response['success'] );
$this->assertSame( 'Item saved.', $response['data']['message'] );
}
public function test_ajax_save_item_rejects_invalid_nonce() {
$admin_id = $this->factory->user->create( [ 'role' => 'administrator' ] );
wp_set_current_user( $admin_id );
$_POST['action'] = 'my_plugin_save_item';
$_POST['nonce'] = 'invalid_nonce';
try {
do_action( 'wp_ajax_my_plugin_save_item' );
} catch ( WPAjaxDieContinueException $e ) {
}
$response = json_decode( $this->_last_response, true );
$this->assertFalse( $response['success'] );
}
public function test_ajax_handler_rejects_unauthorized_users() {
$subscriber_id = $this->factory->user->create( [ 'role' => 'subscriber' ] );
wp_set_current_user( $subscriber_id );
$this->make_ajax_call( 'my_plugin_save_item', [
'item_name' => 'Should Fail',
] );
$response = json_decode( $this->_last_response, true );
$this->assertFalse( $response['success'] );
}
}
WooCommerce Testing
Extending WC_Unit_Test_Case
<?php
class Test_My_WC_Extension extends WC_Unit_Test_Case {
public function test_simple_product_creation() {
$product = WC_Helper_Product::create_simple_product();
$this->assertInstanceOf( WC_Product_Simple::class, $product );
$this->assertSame( 'publish', $product->get_status() );
$this->assertGreaterThan( 0, $product->get_price() );
}
public function test_variable_product_with_variations() {
$product = WC_Helper_Product::create_variation_product();
$this->assertInstanceOf( WC_Product_Variable::class, $product );
$variations = $product->get_children();
$this->assertNotEmpty( $variations );
}
public function test_order_creation_and_totals() {
$order = WC_Helper_Order::create_order();
$this->assertInstanceOf( WC_Order::class, $order );
$this->assertGreaterThan( 0, $order->get_total() );
$this->assertSame( 'pending', $order->get_status() );
}
public function test_order_status_transitions() {
$order = WC_Helper_Order::create_order();
$order->set_status( 'processing' );
$order->save();
$this->assertSame( 'processing', $order->get_status() );
$order->set_status( 'completed' );
$order->save();
$this->assertSame( 'completed', $order->get_status() );
}
}
Cart and Checkout Testing
<?php
class Test_My_WC_Cart extends WC_Unit_Test_Case {
public function setUp(): void {
parent::setUp();
WC()->cart->empty_cart();
}
public function test_add_product_to_cart() {
$product = WC_Helper_Product::create_simple_product();
WC()->cart->add_to_cart( $product->get_id(), 2 );
$this->assertSame( 2, WC()->cart->get_cart_contents_count() );
}
public function test_cart_total_calculation() {
$product = WC_Helper_Product::create_simple_product( true, [
'regular_price' => '25.00',
] );
WC()->cart->add_to_cart( $product->get_id(), 3 );
WC()->cart->calculate_totals();
$this->assertEquals( 75.00, WC()->cart->get_cart_contents_total() );
}
public function test_coupon_applies_discount() {
$product = WC_Helper_Product::create_simple_product( true, [
'regular_price' => '100.00',
] );
$coupon = WC_Helper_Coupon::create_coupon( 'test10', [
'discount_type' => 'percent',
'amount' => '10',
] );
WC()->cart->add_to_cart( $product->get_id() );
WC()->cart->apply_coupon( 'test10' );
WC()->cart->calculate_totals();
$this->assertEquals( 90.00, WC()->cart->get_cart_contents_total() );
}
public function test_custom_fee_added_to_cart() {
$product = WC_Helper_Product::create_simple_product( true, [
'regular_price' => '50.00',
] );
WC()->cart->add_to_cart( $product->get_id() );
WC()->cart->calculate_totals();
$fees = WC()->cart->get_fees();
$handling_fee = null;
foreach ( $fees as $fee ) {
if ( 'handling-fee' === $fee->id ) {
$handling_fee = $fee;
break;
}
}
$this->assertNotNull( $handling_fee );
$this->assertEquals( 5.00, $handling_fee->amount );
}
}
Payment Gateway Testing
<?php
class Test_My_Payment_Gateway extends WC_Unit_Test_Case {
protected $gateway;
public function setUp(): void {
parent::setUp();
$this->gateway = new My_Payment_Gateway();
}
public function test_gateway_is_registered() {
$gateways = WC()->payment_gateways()->payment_gateways();
$this->assertArrayHasKey( 'my_gateway', $gateways );
}
public function test_gateway_has_correct_properties() {
$this->assertSame( 'my_gateway', $this->gateway->id );
$this->assertNotEmpty( $this->gateway->method_title );
$this->assertTrue( $this->gateway->has_fields() );
}
public function test_process_payment_success() {
add_filter( 'pre_http_request', function ( $preempt, $args, $url ) {
if ( strpos( $url, 'api.mypayment.com/charge' ) !== false ) {
return [
'response' => [ 'code' => 200 ],
'body' => wp_json_encode( [
'status' => 'success',
'transaction_id' => 'txn_123456',
] ),
];
}
return $preempt;
}, 10, 3 );
$order = WC_Helper_Order::create_order();
$result = $this->gateway->process_payment( $order->get_id() );
$this->assertSame( 'success', $result['result'] );
$this->assertNotEmpty( $result['redirect'] );
$updated_order = wc_get_order( $order->get_id() );
$this->assertSame( 'processing', $updated_order->get_status() );
}
public function test_process_payment_failure() {
add_filter( 'pre_http_request', function ( $preempt, $args, $url ) {
if ( strpos( $url, 'api.mypayment.com/charge' ) !== false ) {
return [
'response' => [ 'code' => 400 ],
'body' => wp_json_encode( [
'status' => 'error',
'message' => 'Card declined',
] ),
];
}
return $preempt;
}, 10, 3 );
$order = WC_Helper_Order::create_order();
$result = $this->gateway->process_payment( $order->get_id() );
$this->assertSame( 'failure', $result['result'] );
}
}
What to Mock
External HTTP APIs
Use the pre_http_request filter to intercept outgoing HTTP calls and return controlled responses.
add_filter( 'pre_http_request', function ( $preempt, $parsed_args, $url ) {
if ( strpos( $url, 'api.example.com' ) !== false ) {
return [
'headers' => new WpOrg\Requests\Utility\CaseInsensitiveDictionary(),
'body' => wp_json_encode( [ 'key' => 'value' ] ),
'response' => [
'code' => 200,
'message' => 'OK',
],
'cookies' => [],
'filename' => '',
];
}
return $preempt;
}, 10, 3 );
Email
Prevent real emails by short-circuiting wp_mail():
add_filter( 'wp_mail', function ( $args ) {
$GLOBALS['test_emails'][] = $args;
return $args;
} );
add_filter( 'pre_wp_mail', '__return_true' );
File Uploads
Use a temporary directory to avoid polluting the uploads folder:
add_filter( 'upload_dir', function ( $dirs ) {
$tmp = sys_get_temp_dir() . '/wp-test-uploads';
if ( ! is_dir( $tmp ) ) {
mkdir( $tmp, 0755, true );
}
$dirs['path'] = $tmp;
$dirs['url'] = 'http://example.org/wp-content/test-uploads';
$dirs['basedir'] = $tmp;
$dirs['baseurl'] = 'http://example.org/wp-content/test-uploads';
return $dirs;
} );
Cron
Do not test that WordPress cron triggers on schedule. Instead, test the callback function directly:
public function test_cron_callback_processes_expired_items() {
$post_id = $this->factory->post->create( [
'post_type' => 'my_item',
'meta_input' => [ 'expires_at' => date( 'Y-m-d', strtotime( '-1 day' ) ) ],
] );
My_Plugin_Cron::process_expired_items();
$post = get_post( $post_id );
$this->assertSame( 'draft', $post->post_status );
}
Anti-Patterns (WordPress-Specific)
Do NOT test that hooks fire
public function test_init_fires() {
$fired = false;
add_action( 'init', function () use ( &$fired ) { $fired = true; } );
do_action( 'init' );
$this->assertTrue( $fired );
}
Do NOT call wp_remote_get without mocking
public function test_api_call() {
$response = wp_remote_get( 'https://api.example.com/data' );
$this->assertSame( 200, wp_remote_retrieve_response_code( $response ) );
}
Do NOT manually manage database state
public function tearDown(): void {
global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->posts} WHERE post_type = 'my_cpt'" );
parent::tearDown();
}
Do NOT match raw HTML strings
public function test_output() {
$this->assertSame( '<div class="widget"><h2>Title</h2></div>', $output );
}
public function test_output() {
$this->assertStringContainsString( 'class="widget"', $output );
$this->assertStringContainsString( '<h2>Title</h2>', $output );
}
Do NOT hard-code post IDs
public function test_get_post() {
$post = get_post( 1 );
$this->assertSame( 'My Post', $post->post_title );
}
public function test_get_post() {
$post_id = $this->factory->post->create( [ 'post_title' => 'My Post' ] );
$post = get_post( $post_id );
$this->assertSame( 'My Post', $post->post_title );
}
Do NOT test core functions
public function test_get_option_works() {
update_option( 'test_key', 'test_value' );
$this->assertSame( 'test_value', get_option( 'test_key' ) );
}
Test Organization
Directory Structure
my-plugin/
├── my-plugin.php
├── includes/
├── src/
├── phpunit.xml.dist
├── tests/
│ ├── bootstrap.php
│ ├── unit/
│ │ ├── test-settings.php
│ │ ├── test-helpers.php
│ │ └── test-sanitization.php
│ └── integration/
│ ├── test-post-types.php
│ ├── test-rest-api.php
│ ├── test-hooks.php
│ ├── test-shortcodes.php
│ ├── test-admin.php
│ └── test-ajax.php
├── .wp-env.json
└── composer.json
Naming Conventions
- Test files:
test-{feature}.php (matches phpunit.xml.dist prefix config)
- Test classes:
Test_{Feature} extending WP_UnitTestCase
- Test methods:
test_{behavior_being_tested} -- describe what is being verified, not the method name
- Data providers:
data_provider_{context}
Test Groups
Use @group annotations to run subsets of tests:
public function test_paginated_endpoint() {
}
Run a specific group:
phpunit --group rest-api
Composer Configuration
{
"require-dev": {
"phpunit/phpunit": "^9.6",
"yoast/phpunit-polyfills": "^2.0"
},
"scripts": {
"test": "phpunit",
"test:unit": "phpunit --testsuite unit",
"test:integration": "phpunit --testsuite integration"
}
}
wp-env Configuration (.wp-env.json)
{
"core": null,
"phpVersion": "8.2",
"plugins": [ "." ],
"mappings": {
"wp-content/mu-plugins": "./tests/mu-plugins"
},
"config": {
"WP_DEBUG": true,
"SCRIPT_DEBUG": true
},
"env": {
"tests": {
"config": {
"WP_DEBUG": true,
"WP_TESTS_DOMAIN": "localhost"
}
}
}
}
Run tests via wp-env:
npx wp-env start
npx wp-env run tests-cli --env-cwd=wp-content/plugins/my-plugin phpunit