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

This package is auto-updated.

Last update: 2025-12-19 19:06:33 UTC


README

Latest Version on Packagist Total Downloads MIT License

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.xml configuration
  • Set up WordPress test config

Optional: You can also copy these stubs from vendor/jakehenshall/pest-plugin-wordpress/stubs/:

  • phpstan.neon.stub - PHPStan configuration
  • phpstan-baseline.neon.stub - PHPStan baseline
  • .gitignore.stub - Ignore test artifacts
  • .github-workflows-tests.yml.stub - CI/CD workflow
  • composer.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 core
  • phpstan.neon.stub - PHPStan configuration
  • composer.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:

  1. Optimal Shard Count: Start with 4 shards, adjust based on test suite size
  2. Browser Tests: Use sharding for browser tests as they're slower
  3. Parallel + Sharding: Combine both for maximum speed
  4. 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 - Either plugin or theme

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.stub
  • ExampleIntegrationTest.php.stub
  • ExampleBrowserTest.php.stub
  • ExampleWooCommerceBrowserTest.php.stub

Configuration:

  • Pest.php.stub - Pest configuration
  • phpunit.xml.stub - PHPUnit configuration
  • phpstan.neon.stub - PHPStan configuration
  • phpstan-baseline.neon.stub - PHPStan baseline
  • wp-tests-config.php.stub - WordPress test config

Bootstrap:

  • bootstrap-unit.php.stub
  • bootstrap-integration.php.stub
  • bootstrap-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

License

The MIT License (MIT). Please see License File for more information.

Support

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