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.