PHP Pest Testing Framework for WordPress Plugins

Pest is a modern PHP testing framework built on top of PHPUnit that provides a more expressive, fluent API — expect($value)->toBe(42) — and reduces test boilerplate significantly. Pest’s higher-order tests, built-in parallel test execution, and architectural testing rules (arch()) make it a compelling alternative to raw PHPUnit for WordPress plugin test suites.

Problem: PHPUnit test syntax for WordPress plugins is verbose — each test class requires extends WP_UnitTestCase, setUp/tearDown boilerplate, and the assertion API is not fluent — making tests harder to read and write.

Solution: Use Pest for WordPress testing: install with composer require pestphp/pest --dev and the pest-plugin-wordpress bridge. Write tests as simple closures in tests/ files — it('creates a post', function () { expect(wp_insert_post([...]))->toBeInt(); }). Pest's fluent expectations and describe/it structure reduce boilerplate significantly while remaining compatible with the WP test framework.


The examples below set up Pest for a WordPress plugin with the WP-CLI scaffold test bootstrap, write unit and integration tests using Pest's fluent API, and use the arch() rule to enforce that no plugin class uses die() or exit().


# 1. Install Pest (alongside existing PHPUnit setup)
composer require pestphp/pest --dev
composer require pestphp/pest-plugin-arch --dev

# 2. Initialise Pest (creates tests/Pest.php bootstrap)
./vendor/bin/pest --init

# 3. Run tests
./vendor/bin/pest
./vendor/bin/pest --parallel   # parallel execution
./vendor/bin/pest --coverage   # coverage report
./vendor/bin/pest tests/Unit/  # only unit tests


in( 'Feature' );

// tests/Unit/PriceCalculatorTest.php
use MyPlugin\PriceCalculator;

it( 'applies percentage discount', function () {
    $calc = new PriceCalculator();
    expect( $calc->applyDiscount( 100.00, 20 ) )->toBe( 80.00 );
} );

it( 'throws on negative price', function () {
    $calc = new PriceCalculator();
    expect( fn() => $calc->applyDiscount( -10, 10 ) )
        ->toThrow( \InvalidArgumentException::class );
} );

it( 'clamps discount between 0 and 100 percent', function () {
    $calc = new PriceCalculator();
    expect( $calc->applyDiscount( 100.00, 150 ) )->toBe( 0.00 )
    ->and( $calc->applyDiscount( 100.00, -10 ) )->toBe( 100.00 );
} );

// Dataset-driven tests (replaces @dataProvider)
it( 'formats prices correctly', function ( float $amount, string $currency, string $expected ) {
    expect( format_price( $amount, $currency ) )->toBe( $expected );
} )->with( [
    [ 9.99,    'USD', '$9.99'   ],
    [ 1234.50, 'EUR', '€1,234.50' ],
    [ 0.0,     'GBP', '£0.00'  ],
] );

// tests/Architecture/PluginArchTest.php — architectural rules
arch( 'plugin classes never call die() or exit()' )
    ->expect( 'MyPlugin' )
    ->not->toUse( 'die' )
    ->not->toUse( 'exit' );

arch( 'no debug functions left in code' )
    ->expect( 'MyPlugin' )
    ->not->toUse( [ 'var_dump', 'print_r', 'dd', 'dump' ] );

arch( 'all classes are final or abstract' )
    ->expect( 'MyPlugin\ValueObjects' )
    ->toBeFinal();

// Feature test that boots WordPress
// tests/Feature/OptionRepositoryTest.php
use MyPlugin\OptionRepository;

it( 'saves and retrieves a plugin option', function () {
    $repo = new OptionRepository();
    $repo->set( 'my_key', 'my_value' );
    expect( $repo->get( 'my_key' ) )->toBe( 'my_value' );
    $repo->delete( 'my_key' );
    expect( $repo->get( 'my_key' ) )->toBeNull();
} );


NOTE: Pest's arch() rules perform static analysis on your namespace without executing code — they parse PHP files directly; ensure your composer.json autoload map includes all source directories so Pest can discover all classes, otherwise architectural rules will silently pass by not finding the classes they should be checking.