jakehenshall / pest-plugin-wordpress
The most comprehensive WordPress testing framework built on Pest PHP v4. Includes PHPStan v2.1 for static analysis, SQLite for fast testing, and 150+ helper functions. Test WordPress plugins and themes with Laravel-style syntax. Browser testing, HTTP testing, email mocking, AJAX testing, REST API he
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
pkg:composer/jakehenshall/pest-plugin-wordpress
Requires
- php: ^8.3.0
- aaemnnosttv/wp-sqlite-db: ^1.3
- pestphp/pest: ^4.0
- php-stubs/wordpress-stubs: ^6.9
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- symfony/console: ^7.0
- symfony/filesystem: ^7.0
- symfony/process: ^7.0
- szepeviktor/phpstan-wordpress: ^2.0
- yoast/phpunit-polyfills: ^3.0|^4.0
Requires (Dev)
Suggests
- pestphp/pest-plugin-browser: Browser testing support for E2E tests
This package is auto-updated.
Last update: 2025-12-19 19:06:33 UTC
README
The complete WordPress testing solution. One package includes everything: Pest PHP v4, PHPStan v2.1, SQLite, MySQL support, WordPress stubs, and 150+ helper functions. Write beautiful, Laravel-style tests with zero configuration.
🔋 Batteries Included: Install once, test immediately. No setup, no configuration, no additional packages needed.
test('creates posts and sends emails', function () { actingAsAdmin(); $postId = factory()::post(['post_title' => 'Hello World']); fakeEmail(); wp_mail('admin@example.com', 'New Post', 'Post created!'); assertPostExists($postId); assertEmailSent('admin@example.com'); });
Why This Package?
Testing WordPress shouldn't be complicated. This package brings the joy of testing to WordPress with:
- 🚀 150+ Helper Functions - Everything you need out of the box
- 🎨 Laravel-Style Syntax - Beautiful, expressive test code
- ⚡ Fast - SQLite database built-in for speed (MySQL supported too)
- 🔬 PHPStan Built-in - Static analysis included, no extra setup
- 🌐 Browser Testing - Test WordPress admin, Gutenberg, WooCommerce with real browsers
- 🔌 Plugin Compatible - Works with Yoast, WooCommerce, ACF
- 🌐 WP-CLI Integration - Native WordPress tooling
- 📦 Complete Coverage - REST API, AJAX, Blocks, Email, Cron, and more
- 🎯 Pest v4 Features - Test sharding, skip helpers, new expectations
- 🔋 Batteries Included - One package, zero configuration
Requirements
- PHP >= 8.3.0
- Composer
That's it! Everything else (Pest, PHPStan, SQLite, WordPress stubs) is included automatically.
Installation
Install via Composer in your WordPress plugin or theme:
composer require jakehenshall/pest-plugin-wordpress --dev
What you get automatically:
- ✅ Pest PHP v4 - Complete testing framework
- ✅ PHPStan v2.1 - Static analysis with WordPress rules
- ✅ SQLite - Fast in-memory database for tests
- ✅ MySQL Support - Production-like testing
- ✅ WordPress Stubs - Full IntelliSense support
- ✅ 150+ Helper Functions - HTTP, Email, AJAX, REST API, and more
- ✅ 14 Ready-to-Use Stubs - Test examples, configs, CI/CD templates
- ✅ WP-CLI Commands - Native WordPress integration
One package. Zero configuration. Start testing immediately.
Using PHPStan (Built-in)
PHPStan is automatically included with WordPress-specific rules. Add to your composer.json:
{
"scripts": {
"phpstan": "phpstan analyse --memory-limit=2G",
"phpstan:baseline": "phpstan analyse --memory-limit=2G --generate-baseline"
}
}
Create phpstan.neon:
parameters: level: 6 paths: - your-plugin.php - src scanFiles: - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php
Run analysis:
composer phpstan
What's included:
- ✅ PHPStan v2.1
- ✅ WordPress-specific rules
- ✅ WordPress function stubs
- ✅ Level 6 analysis ready
Quick Start
1. Install the Package
composer require jakehenshall/pest-plugin-wordpress --dev
This single command installs:
- Pest PHP v4 (testing framework)
- PHPStan v2.1 (static analysis)
- SQLite database driver
- WordPress function stubs
- All 150+ helper functions
2. Run Setup
For a plugin:
vendor/bin/wp-pest setup plugin --plugin-slug=my-awesome-plugin
For a theme:
vendor/bin/wp-pest setup theme
Or via WP-CLI:
wp pest setup plugin --plugin-slug=my-plugin
This will:
- Create
tests/directory structure - Download WordPress core
- Set up SQLite database (automatically included)
- Create example tests (unit, integration, browser)
- Configure PHPUnit/Pest
- Generate
phpunit.xmlconfiguration - Set up WordPress test config
Optional: You can also copy these stubs from vendor/jakehenshall/pest-plugin-wordpress/stubs/:
phpstan.neon.stub- PHPStan configurationphpstan-baseline.neon.stub- PHPStan baseline.gitignore.stub- Ignore test artifacts.github-workflows-tests.yml.stub- CI/CD workflowcomposer.json.stub- Example project structure
3. Run Your Tests
# Run all tests vendor/bin/pest # Run unit tests only vendor/bin/pest --group=unit # Run integration tests only vendor/bin/pest --group=integration
Or via WP-CLI:
wp pest test all wp pest test unit wp pest test integration
4. Write Your First Test
Create tests/Integration/MyFirstTest.php:
<?php if (isUnitTest()) { return; } test('creates a post successfully', function () { $postId = factory()::post([ 'post_title' => 'My First Test Post', 'post_status' => 'publish', ]); assertPostExists($postId); assertPostHasStatus($postId, 'publish'); expect(get_post($postId)->post_title)->toBe('My First Test Post'); }); test('admin can access settings', function () { actingAsAdmin(); assertAuthenticated(); assertUserCan('manage_options'); });
5. See Results
PASS Tests\Integration\MyFirstTest
✓ creates a post successfully
✓ admin can access settings
Tests: 2 passed
Time: 0.14s
6. (Optional) Set Up CI/CD
Copy the GitHub Actions workflow stub:
mkdir -p .github/workflows cp vendor/jakehenshall/pest-plugin-wordpress/stubs/.github-workflows-tests.yml.stub .github/workflows/tests.yml
Edit the workflow and replace {{PLUGIN_SLUG}} with your plugin slug.
Also available:
.gitignore.stub- Ignore test files and WordPress corephpstan.neon.stub- PHPStan configurationcomposer.json.stub- Example project structure
Common Testing Patterns
Setup and Teardown
Tests automatically clean up after themselves, but you can add custom setup:
beforeEach(function () { $this->userId = factory()::user(['role' => 'editor']); actingAs($this->userId); }); afterEach(function () { // Custom cleanup if needed });
Shared Data with Datasets
dataset('user_roles', [ 'admin' => ['administrator'], 'editor' => ['editor'], 'author' => ['author'], ]); test('user can edit posts', function ($role) { $userId = factory()::user(['role' => $role]); actingAs($userId); assertUserCan('edit_posts'); })->with('user_roles');
Testing Custom REST Endpoints
test('custom REST endpoint works', function () { register_rest_route('my-plugin/v1', '/data', [ 'methods' => 'GET', 'callback' => fn() => ['data' => 'value'], ]); restGet('/my-plugin/v1/data') ->assertOk() ->assertJsonPath('data', 'value'); });
Troubleshooting
Tests Won't Run?
# Ensure WordPress is downloaded ls -la wp/ # Re-run setup if needed vendor/bin/wp-pest setup plugin --plugin-slug=your-plugin
Autoload Issues?
# Regenerate autoload files
composer dump-autoload
Permission Errors?
# Make bin executable
chmod +x vendor/bin/wp-pest
Features Overview
🌐 Browser Testing (NEW in v4)
Test WordPress in real browsers with Playwright-powered browser testing:
test('admin can create post in block editor', function () { browserLoginAsAdmin(); $page = visitNewPost(); $page->type('.editor-post-title__input', 'My New Post'); publishPost($page); assertPostPublished($page); })->group('browser');
Browser Testing Features:
- Test WordPress admin UI interactions
- Test block editor (Gutenberg)
- Test frontend themes
- Test WooCommerce checkout flows
- Test contact forms
- Multi-device testing (mobile, tablet, desktop)
- Dark/light mode testing
- Visual regression testing
- Smoke testing
Installation:
composer require pestphp/pest-plugin-browser --dev npm install playwright@latest npx playwright install
Available Functions:
// Navigation visitWordPress('/'); // Visit any WordPress page visitAdmin('index.php'); // Visit admin page visitBlockEditor($postId); // Open block editor visitNewPost('post'); // New post editor visitLogin(); // Login page // Authentication browserLoginAs('username', 'password'); browserLoginAsAdmin(); browserLoginAsUser($userId, 'password'); browserLogout(); // Block Editor addGutenbergBlock($page, 'core/paragraph'); publishPost($page); saveDraft($page); updatePost($page); // WooCommerce visitWooCommerceProduct($productId); visitWooCommerceCart(); visitWooCommerceCheckout(); addToCart($page); fillCheckoutForm($page, $data); placeOrder($page); // Assertions assertLoggedInAs($page, 'admin'); assertCanSeeAdminBar($page); assertInBlockEditor($page); assertPostPublished($page); assertOrderComplete($page); assertNoWordPressErrors($page); // Device & Theme onMobile($page); onTablet($page); onDesktop($page); inDarkMode($page); inLightMode($page); // Screenshots & Debugging screenshotAs($page, 'checkout-complete');
Browser Testing Examples:
// Test admin dashboard test('dashboard loads without errors', function () { browserLoginAsAdmin(); visitAdmin('index.php') ->assertSee('Dashboard') ->assertNoJavascriptErrors() ->assertNoConsoleLogs(); })->group('browser'); // Test Gutenberg block editor test('can add paragraph block', function () { browserLoginAsAdmin(); $page = visitNewPost(); $page->type('.editor-post-title__input', 'Test Post'); addGutenbergBlock($page, 'core/paragraph'); assertInBlockEditor($page); })->group('browser'); // Test WooCommerce checkout test('customer can complete checkout', function () { skipIfWooCommerceNotActive(); $productId = factory()::post([ 'post_type' => 'product', 'post_title' => 'Test Product', ]); update_post_meta($productId, '_price', '29.99'); $page = visitWooCommerceProduct($productId); $page = addToCart($page); $page = visitWooCommerceCheckout(); $page = fillCheckoutForm($page, [ 'billing_email' => 'customer@example.com', ]); $page = placeOrder($page); assertOrderComplete($page); })->group('browser', 'woocommerce'); // Test responsive design test('homepage works on mobile', function () { visitWordPress('/') ->on()->mobile() ->assertSee('Welcome') ->assertNoJavascriptErrors(); })->group('browser'); // Smoke testing test('critical pages have no errors', function () { $routes = ['/', '/about', '/contact', '/shop']; visit($routes)->assertNoSmoke(); })->group('browser', 'smoke');
🎯 Test Sharding (NEW in v4)
Split your test suite across multiple processes for faster CI/CD:
# Split tests into 4 shards vendor/bin/pest --shard=1/4 vendor/bin/pest --shard=2/4 vendor/bin/pest --shard=3/4 vendor/bin/pest --shard=4/4 # Combine with parallel execution vendor/bin/pest --shard=1/4 --parallel
GitHub Actions Example:
name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest strategy: matrix: php: ["8.3", "8.4"] shard: [1, 2, 3, 4] name: Tests (PHP ${{ matrix.php }}, Shard ${{ matrix.shard }}/4) steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: sqlite3 - name: Install Dependencies run: composer install - name: Setup WordPress run: | vendor/bin/wp-pest setup plugin \ --plugin-slug=my-plugin \ --skip-delete - name: Run Tests run: vendor/bin/pest --parallel --shard=${{ matrix.shard }}/4
Performance Tips:
- Optimal Shard Count: Start with 4 shards, adjust based on test suite size
- Browser Tests: Use sharding for browser tests as they're slower
- Parallel + Sharding: Combine both for maximum speed
- CI Resources: Match shard count to available CI workers
# Fast local development (no browser tests) vendor/bin/pest --exclude-group=browser --parallel # Full CI run with sharding vendor/bin/pest --shard=1/4 --parallel
⏭️ Skip Helpers (NEW in v4)
Skip tests conditionally based on environment:
// Skip locally or on CI test('browser test', function () { skipBrowserTestsLocally(); // Skip slow tests in development browserLoginAsAdmin(); visitAdmin('index.php'); })->group('browser'); test('external API test', function () { skipExternalApiTestsOnCi(); // Skip on CI if no API keys $response = wp_remote_get('https://api.example.com'); })->group('api'); // Skip based on environment test('multisite test', function () { skipIfNotMultisite(); $blogId = createBlog('test.example.com'); })->group('multisite'); // Skip based on plugins test('woocommerce feature', function () { skipIfWooCommerceNotActive(); // Test WooCommerce })->group('woocommerce'); // Skip based on WordPress/PHP version test('requires WP 6.4+', function () { skipIfWordPressVersion('<', '6.4'); // Test feature }); // Aliases for readability test('multisite only', function () { onlyInMultisite(); // Test runs only in multisite })->group('multisite'); test('CI only', function () { onlyOnCi(); // Test runs only on CI })->group('ci');
Available Skip Helpers:
// Environment skipLocally() // Pest v4 built-in skipOnCi() // Pest v4 built-in skipBrowserTestsLocally() skipBrowserTestsOnCi() skipExternalApiTestsLocally() skipExternalApiTestsOnCi() skipLongRunningTestsLocally() skipLongRunningTestsOnCi() // WordPress Environment skipIfMultisite() skipIfNotMultisite() skipIfRestApiDisabled() skipIfGutenbergNotAvailable() // Plugins skipIfPluginNotActive($plugin) skipIfPluginActive($plugin) skipIfWooCommerceNotActive() skipIfYoastNotActive() skipIfAcfNotActive() // Versions skipIfPhpVersion($operator, $version) skipIfWordPressVersion($operator, $version) // Aliases onlyInMultisite() onlyInSingleSite() onlyWithPlugin($plugin) onlyOnCi() onlyLocally() // Platform skipOnWindows() skipOnMac() skipOnLinux()
✅ New WordPress Expectations (Pest v4)
New chainable expectations for WordPress:
// Validate WordPress concepts expect('my-post-slug')->toBeSlug(); expect('publish')->toBeValidPostStatus(); expect('administrator')->toBeValidUserRole(); expect('manage_options')->toBeValidCapability(); expect('post')->toBeValidPostType(); expect('category')->toBeValidTaxonomy(); // Post assertions expect($postId)->toBePublished(); expect($postId)->toHavePostMeta('_thumbnail_id', 123); // User assertions expect($userId)->toHaveUserRole('editor'); expect($userId)->toHaveCapability('edit_posts'); // WP_Error assertions expect($result)->toBeWordPressError(); expect($error)->toHaveErrorCode('invalid_username'); // Examples test('validates post data', function () { $slug = 'my-awesome-post'; $status = 'publish'; expect($slug)->toBeSlug(); expect($status)->toBeValidPostStatus(); $postId = factory()::post([ 'post_name' => $slug, 'post_status' => $status, ]); expect($postId)->toBePublished(); }); test('validates user permissions', function () { $userId = factory()::user(['role' => 'editor']); expect($userId)->toHaveUserRole('editor'); expect($userId)->toHaveCapability('edit_posts'); expect($userId)->not->toHaveCapability('manage_options'); }); test('handles WordPress errors', function () { $result = wp_insert_post([ 'post_title' => '', // Invalid ]); expect($result)->toBeWordPressError(); expect($result)->toHaveErrorCode('empty_content'); });
🏭 Factory Functions
Create WordPress entities with one line:
// Posts $postId = factory()::post(['post_title' => 'Test Post']); $postIds = factory()::posts(5); // Users $userId = factory()::user(['role' => 'editor']); $adminId = factory()::user(['role' => 'administrator']); // Terms $categoryId = factory()::term('Technology', 'category'); $tagIds = factory()::terms(5, 'post_tag'); // Comments $commentId = factory()::comment($postId, ['comment_content' => 'Great!']); // Attachments $attachmentId = factory()::attachment(['post_mime_type' => 'image/jpeg']);
🔐 Authentication
Switch between users effortlessly:
// Act as different roles actingAsAdmin(); actingAsEditor(); actingAsGuest(); // Act as specific user $user = actingAs($userId); // Assertions assertAuthenticated(); assertNotAuthenticated(); assertUserCan('manage_options'); assertUserCannot('edit_posts');
🌐 HTTP Testing
Test HTTP requests with fluent assertions:
get('/') ->assertOk() ->assertSee('Welcome'); post('/wp-admin/admin-ajax.php', ['action' => 'my_action']) ->assertStatus(200) ->assertSee('success'); from('https://google.com') ->get('/page') ->assertOk();
🎭 HTTP Mocking
Mock external API calls:
fakeHttp('https://api.example.com/*', [ 'body' => json_encode(['data' => 'mocked']), 'response' => ['code' => 200], ]); $response = wp_remote_get('https://api.example.com/users'); assertHttpSent('https://api.example.com/*'); assertHttpSentCount('https://api.example.com/*', 1); // Prevent unexpected requests preventStrayRequests();
📧 Email Testing
Intercept and test emails:
fakeEmail(); wp_mail('user@example.com', 'Welcome!', 'Thanks for signing up'); assertEmailSent('user@example.com'); assertEmailSentCount(1); assertEmailSentTo(['user1@example.com', 'user2@example.com']);
⏰ Cron/Scheduled Events
Test scheduled tasks:
wp_schedule_event(time(), 'daily', 'my_cleanup_task'); assertCronScheduled('my_cleanup_task'); runCron('my_cleanup_task'); runAllCrons(); runDueCrons(); clearAllCrons();
🗄️ Database Testing
Direct database assertions:
assertDatabaseHas('posts', [ 'post_title' => 'Test Post', 'post_status' => 'publish', ]); assertDatabaseMissing('posts', ['post_status' => 'trash']); assertDatabaseCount('posts', 10); truncateTable('postmeta'); seedTable('posts', [['post_title' => 'Seeded Post']]);
🔌 REST API Testing
Fluent REST API testing:
restGet('/wp/v2/posts') ->assertOk() ->assertJsonCount(10) ->assertJsonPath('0.title.rendered', 'Post Title'); $userId = actingAsEditor()->ID; restPost('/wp/v2/posts', [ 'title' => 'New Post', 'status' => 'publish', ], $userId) ->assertCreated() ->assertJsonPath('title.rendered', 'New Post');
🔌 Plugin Compatibility
Test with popular plugins:
withYoast(function () { // Test with Yoast SEO active expect(function_exists('wpseo_init'))->toBeTrue(); }); withWooCommerce(function () { // Test WooCommerce integration $productId = factory()::post(['post_type' => 'product']); assertPostExists($productId); }); withAcf(function () { // Test ACF integration update_field('my_field', 'value', $postId); }); withPlugin('contact-form-7/wp-contact-form-7.php', function () { // Test with any plugin });
🌍 Multisite Support
Test multisite networks:
assertMultisite(); $blogId = createBlog('testsite.example.com', '/'); assertBlogExists($blogId); switchToBlog($blogId); // Run tests in blog context restoreCurrentBlog(); deleteBlog($blogId);
📁 File Upload Testing
Fake file uploads:
$image = fakeImage('photo.jpg', 1920, 1080); $file = fakeUpload('document.pdf', 'content', 'application/pdf'); assertFileUploaded($image['id']); assertImageSize($image['id'], 'thumbnail');
⏱️ Time Travel
Freeze and manipulate time:
freezeTime(strtotime('2024-01-01 00:00:00')); // Your time-dependent code travelInTime(3600); // Move 1 hour forward travelToTime(strtotime('2025-12-31')); restoreTime();
💾 Cache Testing
Test caching behaviour:
assertCached('my_key', 'my_group'); assertNotCached('expired_key'); assertTransient('my_transient'); assertNoTransient('deleted_transient'); flushCache();
⚡ AJAX Testing
Test AJAX handlers:
callAjax('my_action', ['key' => 'value'], true) ->assertSuccess() ->assertJsonPath('data.id', 123); callAjax('public_action', [], false) ->assertFailed();
🔀 Redirect Testing
Capture and test redirects:
captureRedirects(); // Code that redirects wp_redirect('/success'); assertRedirected('/success'); assertRedirectStatus(302); assertRedirectContains('?message=saved');
🧱 Gutenberg Blocks
Test block editor:
registerBlock('my-plugin/custom-block'); assertBlockRegistered('core/paragraph'); assertHasBlock('core/paragraph', $content); assertBlockCount(5, $postContent); $output = renderBlock('core/paragraph', [ 'content' => 'Hello World' ]);
📢 Admin Notices
Test admin UI:
captureAdminNotices(); add_settings_error('general', 'settings_updated', 'Settings saved', 'success'); assertAdminNotice('Settings saved'); assertAdminNoticeType('success');
🧭 Navigation Menus
Test menus:
$menuId = createMenu('Primary Menu', 'primary'); addMenuItem($menuId, [ 'menu-item-title' => 'Home', 'menu-item-url' => home_url('/'), ]); assertMenuExists('Primary Menu'); assertMenuHasItems($menuId, 5);
📦 Widgets
Test widgets and sidebars:
registerWidget(MyCustomWidget::class); addWidgetToSidebar('sidebar-1', 'my_widget', ['title' => 'Widget']); assertWidgetRegistered(MyCustomWidget::class); assertSidebarExists('sidebar-1'); assertSidebarHasWidgets('sidebar-1', 3);
✅ WordPress Assertions
40+ WordPress-specific assertions:
// Posts assertPostExists($postId); assertPostHasStatus($postId, 'publish'); assertPostHasMeta($postId, 'key', 'value'); assertPostHasTerm($postId, $termId, 'category'); // Terms assertTermExists($termId, 'category'); // Users assertUserExists($userId); assertUserHasRole($userId, 'editor'); // Options assertOptionExists('my_setting'); assertOptionEquals('my_setting', 'value'); // Hooks assertHookAdded('init', 'my_function'); assertFilterAdded('the_content', 'my_filter'); // Post Types & Taxonomies assertPostTypeExists('book'); assertTaxonomyExists('genre'); assertShortcodeExists('my_shortcode'); // Queries assertQueryHasPosts($query); assertQueryPostCount($query, 5); // Assets assertEnqueued('my-script', 'script'); assertEnqueued('my-style', 'style'); // Plugins assertPluginActive('plugin/plugin.php'); assertPluginInactive('inactive-plugin/plugin.php');
Complete Example
Here's a comprehensive test showing multiple features:
<?php test('complete e-commerce flow', function () { // Setup admin user $admin = actingAsAdmin(); // Create products $productId = factory()::post([ 'post_type' => 'product', 'post_title' => 'Test Product', ]); // Fake payment gateway API fakeHttp('https://payment-gateway.com/api/*', [ 'body' => json_encode(['status' => 'approved']), 'response' => ['code' => 200], ]); // Fake email notifications fakeEmail(); // Test REST API restGet("/wp/v2/products/{$productId}", [], $admin->ID) ->assertOk() ->assertJsonPath('title.rendered', 'Test Product'); // Process order (triggers email) do_action('order_completed', $orderId); // Verify email sent assertEmailSent($admin->user_email); // Verify API called assertHttpSent('https://payment-gateway.com/api/*'); // Verify database assertDatabaseHas('posts', [ 'ID' => $productId, 'post_type' => 'product', ]); });
Setup Options
Command Line Options
vendor/bin/wp-pest setup [project-type] [options]
Arguments:
project-type- Eitherpluginortheme
Options:
--wp-version[=VERSION]- WordPress version to test against (default:latest)--plugin-slug[=SLUG]- Plugin slug (required for plugins)--skip-delete- Skip cleanup (useful for CI)
Examples
# Plugin with specific WP version vendor/bin/wp-pest setup plugin --plugin-slug=my-plugin --wp-version=6.4 # Theme setup vendor/bin/wp-pest setup theme # CI environment vendor/bin/wp-pest setup plugin --plugin-slug=my-plugin --skip-delete
Running in CI/CD
GitHub Actions with Test Sharding
name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: php: ["8.3", "8.4"] wordpress: ["latest", "6.4", "6.5"] shard: [1, 2, 3, 4] name: PHP ${{ matrix.php }} - WP ${{ matrix.wordpress }} - Shard ${{ matrix.shard }}/4 steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: sqlite3 coverage: none - name: Install Composer Dependencies run: composer install --prefer-dist --no-progress - name: Setup WordPress Tests run: | vendor/bin/wp-pest setup plugin \ --plugin-slug=my-plugin \ --wp-version=${{ matrix.wordpress }} \ --skip-delete - name: Run Tests run: vendor/bin/pest --parallel --shard=${{ matrix.shard }}/4 browser-tests: runs-on: ubuntu-latest strategy: fail-fast: false matrix: shard: [1, 2] name: Browser Tests - Shard ${{ matrix.shard }}/2 steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: "8.3" extensions: sqlite3 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" - name: Install Dependencies run: | composer install --prefer-dist --no-progress composer require pestphp/pest-plugin-browser --dev npm install playwright@latest npx playwright install --with-deps - name: Setup WordPress Tests run: | vendor/bin/wp-pest setup plugin \ --plugin-slug=my-plugin \ --skip-delete - name: Start WordPress Server run: | cd wp && php -S localhost:8080 & sleep 5 - name: Run Browser Tests run: vendor/bin/pest --group=browser --shard=${{ matrix.shard }}/2 - name: Upload Screenshots if: failure() uses: actions/upload-artifact@v4 with: name: browser-screenshots-${{ matrix.shard }} path: tests/screenshots/
GitLab CI with Test Sharding
variables: MYSQL_ROOT_PASSWORD: root WP_VERSION: latest stages: - test .test-template: &test-template image: php:8.3 before_script: - apt-get update && apt-get install -y sqlite3 libsqlite3-dev - composer install --prefer-dist --no-progress - vendor/bin/wp-pest setup plugin --plugin-slug=my-plugin --skip-delete test:shard-1: <<: *test-template stage: test script: - vendor/bin/pest --parallel --shard=1/4 test:shard-2: <<: *test-template stage: test script: - vendor/bin/pest --parallel --shard=2/4 test:shard-3: <<: *test-template stage: test script: - vendor/bin/pest --parallel --shard=3/4 test:shard-4: <<: *test-template stage: test script: - vendor/bin/pest --parallel --shard=4/4 browser-tests: image: mcr.microsoft.com/playwright:v1.40.0-focal stage: test before_script: - apt-get update && apt-get install -y php8.3 php8.3-sqlite3 composer - composer install - composer require pestphp/pest-plugin-browser --dev - npm install playwright@latest script: - vendor/bin/wp-pest setup plugin --plugin-slug=my-plugin --skip-delete - php -S localhost:8080 -t wp & - sleep 5 - vendor/bin/pest --group=browser
CircleCI with Test Sharding
version: 2.1 jobs: test: parameters: php-version: type: string shard: type: integer total-shards: type: integer docker: - image: cimg/php:<< parameters.php-version >> steps: - checkout - run: name: Install Dependencies command: | composer install --no-progress - run: name: Setup WordPress command: | vendor/bin/wp-pest setup plugin \ --plugin-slug=my-plugin \ --skip-delete - run: name: Run Tests command: | vendor/bin/pest \ --parallel \ --shard=<< parameters.shard >>/<< parameters.total-shards >> workflows: test: jobs: - test: matrix: parameters: php-version: ["8.3", "8.4"] shard: [1, 2, 3, 4] total-shards: [4]
Performance Optimization Tips
1. Optimal Sharding Strategy:
# Small test suite (< 100 tests) vendor/bin/pest --parallel # Medium test suite (100-500 tests) vendor/bin/pest --parallel --shard=1/2 # Large test suite (500+ tests) vendor/bin/pest --parallel --shard=1/4 # Very large suite with browser tests (1000+ tests) vendor/bin/pest --parallel --shard=1/8
2. Separate Browser Tests:
# Run unit/integration tests with high parallelism jobs: unit-tests: strategy: matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - run: vendor/bin/pest --exclude-group=browser --shard=${{ matrix.shard }}/8 # Run browser tests separately with fewer shards browser-tests: strategy: matrix: shard: [1, 2] steps: - run: vendor/bin/pest --group=browser --shard=${{ matrix.shard }}/2
3. Skip Slow Tests Locally:
// In tests/Pest.php uses()->group('browser')->in('Browser'); uses()->group('slow')->in('Slow'); // Run only fast tests locally // vendor/bin/pest --exclude-group=browser --exclude-group=slow
4. Cache Dependencies:
# GitHub Actions - name: Cache Composer uses: actions/cache@v4 with: path: vendor key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - name: Cache WordPress uses: actions/cache@v4 with: path: wp key: ${{ runner.os }}-wp-${{ matrix.wordpress }}
Local Development Workflow
# Fast feedback loop (skip slow tests) vendor/bin/pest --exclude-group=browser --exclude-group=slow # Test specific feature vendor/bin/pest tests/Feature/MyFeatureTest.php # Run with coverage (slower) vendor/bin/pest --coverage # Full test suite before pushing vendor/bin/pest --parallel
Running in CI/CD (Legacy)
Simple GitHub Actions (No Sharding)
name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest strategy: matrix: php: ["8.3", "8.4"] wordpress: ["latest", "6.4"] steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: sqlite3 - name: Install Dependencies run: composer install --prefer-dist --no-progress - name: Setup WordPress Tests run: | vendor/bin/wp-pest setup plugin \ --plugin-slug=my-plugin \ --wp-version=${{ matrix.wordpress }} \ --skip-delete - name: Run Tests run: vendor/bin/pest
GitLab CI
test: image: php:8.3 before_script: - apt-get update && apt-get install -y sqlite3 libsqlite3-dev - composer install script: - vendor/bin/wp-pest setup plugin --plugin-slug=my-plugin --skip-delete - vendor/bin/pest
Project Structure
After running setup, your project will have:
your-plugin/
├── .github/ # (Optional) Copy from stubs
│ └── workflows/
│ └── tests.yml # CI/CD workflow
├── .gitignore # (Optional) Copy from stubs
├── composer.json # Your project dependencies
├── phpstan.neon # (Optional) Copy from stubs
├── phpstan-baseline.neon # (Optional) Generated baseline
├── tests/
│ ├── Pest.php # Pest configuration
│ ├── Helpers.php # Helper functions
│ ├── bootstrap/
│ │ ├── integration.php # Integration test bootstrap
│ │ ├── unit.php # Unit test bootstrap
│ │ └── wp-tests-config.php
│ ├── Unit/
│ │ └── ExampleTest.php
│ └── Integration/
│ └── ExampleTest.php
├── phpunit.xml # PHPUnit configuration
└── wp/ # WordPress installation
├── src/ # WordPress core
└── tests/ # WordPress test suite
Available Stubs
All stubs are located in vendor/jakehenshall/pest-plugin-wordpress/stubs/:
Test Files:
ExampleUnitTest.php.stubExampleIntegrationTest.php.stubExampleBrowserTest.php.stubExampleWooCommerceBrowserTest.php.stub
Configuration:
Pest.php.stub- Pest configurationphpunit.xml.stub- PHPUnit configurationphpstan.neon.stub- PHPStan configurationphpstan-baseline.neon.stub- PHPStan baselinewp-tests-config.php.stub- WordPress test config
Bootstrap:
bootstrap-unit.php.stubbootstrap-integration.php.stubbootstrap-integration-universal.php.stub
Project Setup:
composer.json.stub- Example composer.json.gitignore.stub- Ignore test artifacts.github-workflows-tests.yml.stub- GitHub Actions CI/CD
Helpers:
Helpers.php.stub- Custom helper functions
Copy any stub to your project:
cp vendor/jakehenshall/pest-plugin-wordpress/stubs/phpstan.neon.stub phpstan.neon
Advanced Testing Examples
Testing WooCommerce Integration
test('complete e-commerce flow', function () { withWooCommerce(function () { $admin = actingAsAdmin(); // Create products $productIds = factory()::posts(5, [ 'post_type' => 'product', 'post_status' => 'publish', ]); // Fake payment gateway API fakeHttp('https://payment-gateway.com/api/*', [ 'body' => json_encode(['status' => 'approved']), 'response' => ['code' => 200], ]); // Test REST API restGet('/wc/v3/products', [], $admin->ID) ->assertOk() ->assertJsonCount(5); // Verify API called assertHttpSent('https://payment-gateway.com/api/*'); }); });
Testing Custom Post Types with ACF
test('custom post type with ACF fields', function () { withAcf(function () { register_post_type('book', ['public' => true]); $bookId = factory()::post([ 'post_type' => 'book', 'post_title' => 'The Great Gatsby', ]); update_field('isbn', '978-0-7432-7356-5', $bookId); update_field('author', 'F. Scott Fitzgerald', $bookId); assertPostHasMeta($bookId, 'isbn', '978-0-7432-7356-5'); restGet("/wp/v2/book/{$bookId}") ->assertOk() ->assertJsonPath('title.rendered', 'The Great Gatsby'); }); });
Testing Email Workflows
test('newsletter subscription flow', function () { fakeEmail(); $email = 'subscriber@example.com'; // Schedule verification email scheduleCron('send_verification_email', time(), ['email' => $email]); runCron('send_verification_email'); assertEmailSent($email, function ($email) { return str_contains($email['subject'], 'Verify'); }); assertDatabaseHas('subscribers', [ 'email' => $email, 'status' => 'pending', ]); });
Testing External APIs with Caching
test('external API with caching', function () { fakeHttp('https://api.weather.com/forecast/*', [ 'body' => json_encode(['temperature' => 22, 'conditions' => 'sunny']), 'response' => ['code' => 200], ]); $response = wp_remote_get('https://api.weather.com/forecast/london'); $data = json_decode(wp_remote_retrieve_body($response), true); set_transient('weather_london', $data, HOUR_IN_SECONDS); assertHttpSentCount('https://api.weather.com/forecast/*', 1); // Second request from cache - no additional API call $cached = get_transient('weather_london'); expect($cached)->toBe($data); assertHttpSentCount('https://api.weather.com/forecast/*', 1); });
Testing Role-Based Permissions
test('role-based content access', function () { $privatePostId = factory()::post([ 'post_status' => 'private', 'post_title' => 'Private Content', ]); // Guest cannot access actingAsGuest(); restGet("/wp/v2/posts/{$privatePostId}") ->assertNotFound(); // Editor can access $editor = actingAsEditor(); restGet("/wp/v2/posts/{$privatePostId}", [], $editor->ID) ->assertOk() ->assertJsonPath('title.rendered', 'Private Content'); });
Testing Background Processing
test('background batch processing', function () { $postIds = factory()::posts(100); foreach (array_chunk($postIds, 10) as $batch) { scheduleCron('process_batch', time(), ['post_ids' => $batch]); } runAllCrons(); foreach ($postIds as $postId) { assertPostHasMeta($postId, '_processed', '1'); } });
Documentation
All documentation is now contained in this README for your convenience.
Comparison with Alternatives
| Feature | This Package | WP PHPUnit | Brain Monkey |
|---|---|---|---|
| Pest PHP v4 | ✅ | ❌ | ❌ |
| PHPStan Built-in | ✅ | ❌ | ❌ |
| SQLite Built-in | ✅ | ❌ | N/A |
| Laravel-Style | ✅ | ❌ | ❌ |
| Browser Testing | ✅ | ❌ | ❌ |
| Test Sharding | ✅ | ❌ | ❌ |
| Skip Helpers | ✅ | ❌ | ❌ |
| Custom Expectations | ✅ | ❌ | ❌ |
| Zero Config | ✅ | ❌ | ❌ |
| HTTP Testing | ✅ | ⚠️ | ❌ |
| Email Testing | ✅ | ❌ | ❌ |
| Time Travel | ✅ | ❌ | ❌ |
| AJAX Testing | ✅ | ❌ | ❌ |
| Block Testing | ✅ | ❌ | ❌ |
| Plugin Tests | ✅ | ✅ | ❌ |
| WP-CLI | ✅ | ❌ | ❌ |
| Functions | 150+ | ~40 | ~20 |
| Setup Required | Minimal | Complex | Manual |
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Credits
- Built on Pest PHP
- Inspired by Laravel Testing
License
The MIT License (MIT). Please see License File for more information.
Support
- Issues: GitHub Issues
- Discussions: GitHub Discussions
Found a Bug?
If you've found a bug or issue, please report it on our issue tracker. Your feedback helps us improve!
Made with ❤️ for the WordPress community