# Wp Testing

> 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.

- Skill: `iwritec0de/wp-testing` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add iwritec0de/wp-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/iwritec0de/wp-testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- License: MIT
- Author: iwritec0de (https://skillmd.com/u/iwritec0de)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/iwritec0de/wp-testing

---


# 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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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.

```php
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()`:

```php
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:

```php
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:

```php
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

```php
// 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

```php
// 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

```php
// 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

```php
// 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

```php
// 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

```php
// 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:

```php
/**
 * @group rest-api
 * @group slow
 */
public function test_paginated_endpoint() {
    // ...
}
```

Run a specific group:

```bash
phpunit --group rest-api
```

### Composer Configuration

```json
{
    "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`)

```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:

```bash
# 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
```

