Write Unit Tests for WordPress Plugins with PHPUnit and WP-CLI Scaffold

WordPress unit tests run in a test environment that bootstraps a stripped-down WordPress installation against a separate test database — all database writes are wrapped in transactions that are rolled back after each test, ensuring a clean state for every test method. The recommended setup path is wp scaffold plugin-tests my-plugin, which creates a tests/ directory, a bootstrap.php that loads the WordPress test library, a sample test class, and a bin/install-wp-tests.sh script that downloads WordPress core and creates the test database. The test class extends WP_UnitTestCase (which extends PHPUnit\Framework\TestCase) and uses factory methods — $this->factory->post->create(), $this->factory->user->create(), $this->factory->term->create() — to create test fixtures without managing IDs manually. Testing a function that reads from the database uses the factory to insert the required data before calling the function and then asserts against the return value — the database transaction rollback ensures the inserted data does not persist to the next test. Testing hooks and filters uses do_action() and apply_filters() directly and asserts the side effects — adding a filter with add_filter() in the test and then calling the code under test confirms the filter is applied correctly. Mocking external HTTP requests in WordPress unit tests uses the pre_http_request filter to intercept wp_remote_get() and wp_remote_post() calls and return a fake response array — this prevents tests from making real network requests and making the test suite dependent on external services. The @group and @covers annotations in docblocks allow running subsets of tests with phpunit --group=checkout and tracking code coverage per class. The Git hooks post shows how to run the test suite automatically in a pre-push hook so tests are never bypassed before code reaches the remote.

Problem: A WordPress plugin has no test coverage — changes to core functions break existing behaviour silently and are discovered only after deployment because there is no automated way to verify that the plugin still works correctly after modifications.

Solution: Scaffold a PHPUnit test suite with WP-CLI, install the WordPress test library against a separate test database, write tests using WP_UnitTestCase and factory methods, and mock external HTTP requests with the pre_http_request filter.

# Scaffold the test suite inside the plugin directory
wp scaffold plugin-tests my-plugin
cd wp-content/plugins/my-plugin

# Install WordPress test library (downloads WP core + creates test DB)
bash bin/install-wp-tests.sh wordpress_test root '' localhost latest

# Install PHPUnit (WordPress 6.x requires PHPUnit 9.x)
composer require --dev phpunit/phpunit:^9

# Run the test suite
vendor/bin/phpunit --colors=always

# Run a specific test group
vendor/bin/phpunit --group=api

# Generate HTML code coverage report
vendor/bin/phpunit --coverage-html=coverage/

// tests/test-coupon-validator.php

/**
 * @covers Myplugin_Coupon_Validator
 * @group  checkout
 */
class Test_Coupon_Validator extends WP_UnitTestCase {

    private int $user_id;

    public function set_up(): void {
        parent::set_up();
        // Create a test user with the subscriber role
        $this->user_id = $this->factory->user->create(['role' => 'subscriber']);
    }

    /** @test */
    public function valid_coupon_returns_discount_amount(): void {
        // Arrange: create a coupon post with meta
        $coupon_id = $this->factory->post->create([
            'post_title'  => 'SAVE10',
            'post_type'   => 'shop_coupon',
            'post_status' => 'publish',
        ]);
        update_post_meta($coupon_id, 'coupon_amount',    '10');
        update_post_meta($coupon_id, 'discount_type',    'percent');
        update_post_meta($coupon_id, 'usage_limit',      '100');
        update_post_meta($coupon_id, 'usage_count',      '5');
        update_post_meta($coupon_id, 'expiry_date',      date('Y-m-d', strtotime('+1 year')));

        // Act
        $validator = new Myplugin_Coupon_Validator('SAVE10', $this->user_id);
        $result    = $validator->validate();

        // Assert
        $this->assertTrue($result->is_valid());
        $this->assertSame(10.0, $result->get_discount_percent());
    }

    /** @test */
    public function expired_coupon_returns_error(): void {
        $coupon_id = $this->factory->post->create([
            'post_title'  => 'EXPIRED',
            'post_type'   => 'shop_coupon',
            'post_status' => 'publish',
        ]);
        update_post_meta($coupon_id, 'expiry_date', '2020-01-01');

        $validator = new Myplugin_Coupon_Validator('EXPIRED', $this->user_id);
        $result    = $validator->validate();

        $this->assertFalse($result->is_valid());
        $this->assertStringContainsString('expired', strtolower($result->get_error_message()));
    }

    /** @test */
    public function external_api_call_is_mocked(): void {
        // Mock the HTTP request — prevent real network calls in tests
        add_filter('pre_http_request', function($preempt, $args, $url) {
            if (strpos($url, 'api.example.com/coupons') !== false) {
                return ['response' => ['code' => 200],
                        'body'     => json_encode(['valid' => true, 'discount' => 15])];
            }
            return $preempt;
        }, 10, 3);

        $validator = new Myplugin_Coupon_Validator('REMOTE15', $this->user_id);
        $result    = $validator->validate();

        $this->assertTrue($result->is_valid());
        $this->assertSame(15.0, $result->get_discount_percent());
    }
}

NOTE: The bin/install-wp-tests.sh script creates a separate test database (e.g., wordpress_test) that is completely wiped and rebuilt each time the script runs — never point it at your production or development database. Verify the database name before running the script and keep the test database out of any backup rotation that targets production data.