agentsclimarketplace

Wp testing

Skill iwritec0de/wp-dev/skills/wp-testing

WordPress development plugin for Claude Code

Install
npx -y skills add iwritec0de/wp-dev --skill wp-testing

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

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.

The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.

SKILL.md

28.3 KB, as published. Nobody here has run it

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
/**
 * PHPUnit bootstrap file for the plugin.
 */

// Determine the WordPress test suite location.
$_tests_dir = getenv( 'WP_TESTS_DIR' );

if ( ! $_tests_dir ) {
    $_tests_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/wordpress-tests-lib';
}

// Verify the test suite exists.
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 );
}

// 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( dirname( __FILE__ ) ) . '/my-plugin.php';
}
tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' );

// Start up the WP testing environment.
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 {

    /**
     * Test that a callback is properly attached and executed on an action.
     */
    public function test_init_action_registers_post_type() {
        // The plugin hooks register_custom_post_types() on 'init'.
        // Confirm the callback is registered.
        $this->assertGreaterThan(
            0,
            has_action( 'init', [ My_Plugin::class, 'register_custom_post_types' ] )
        );
    }

    /**
     * Test that an action callback produces the expected side effect.
     */
    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.' );
    }

    /**
     * Test that a filter modifies data correctly.
     */
    public function test_content_filter_appends_cta() {
        // The plugin adds a CTA to the_content for single posts.
        $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 );
    }

    /**
     * Test that a filter preserves the original value when conditions are not met.
     */
    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() {
        // Assuming the plugin registers [my_greeting] shortcode.
        $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 {

    /**
     * @var WP_REST_Server
     */
    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() {
        // Create mixed content.
        $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() {
        // Simulate plugin 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 );

        // Simulate saving settings with potentially unsafe data.
        $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' ] );

        // Simulate uninstall logic.
        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 );

        // Trigger admin menu registration.
        do_action( 'admin_menu' );

        global $menu, $submenu;

        // Check top-level menu exists.
        $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 {

    /**
     * Helper to simulate an AJAX request.
     */
    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 ) {
            // Expected — wp_send_json_* calls wp_die().
        }
    }

    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 ) {
            // Expected.
        }

        $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() );

        // The plugin adds a handling fee.
        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 {

    /**
     * @var My_Payment_Gateway
     */
    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() {
        // Mock the external API call.
        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 ) {
    // Capture for assertions instead of sending.
    $GLOBALS['test_emails'][] = $args;
    return $args;
} );

// Or block sending entirely:
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() {
    // Create test data that the cron job would process.
    $post_id = $this->factory->post->create( [
        'post_type' => 'my_item',
        'meta_input' => [ 'expires_at' => date( 'Y-m-d', strtotime( '-1 day' ) ) ],
    ] );

    // Call the callback directly.
    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

// BAD: Testing that WordPress fires 'init' is testing core.
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

// BAD: This hits a real external server.
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

// BAD: WP_UnitTestCase handles transactions automatically.
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

// BAD: Fragile; breaks on whitespace or attribute order changes.
public function test_output() {
    $this->assertSame( '<div class="widget"><h2>Title</h2></div>', $output );
}

// GOOD: Check for semantic presence.
public function test_output() {
    $this->assertStringContainsString( 'class="widget"', $output );
    $this->assertStringContainsString( '<h2>Title</h2>', $output );
}

Do NOT hard-code post IDs

// BAD: Assumes specific auto-increment state.
public function test_get_post() {
    $post = get_post( 1 );
    $this->assertSame( 'My Post', $post->post_title );
}

// GOOD: Create via factory.
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

// BAD: You are testing WordPress, not your code.
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:

/**
 * @group rest-api
 * @group slow
 */
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:

# Start the environment
npx wp-env start

# Run PHPUnit inside the test container
npx wp-env run tests-cli --env-cwd=wp-content/plugins/my-plugin phpunit

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.