wpstarter/o-testbench
WpStarter port of Orchestra Testbench, providing a Laravel-style testing harness for package development so you can write integration tests for your WpStarter packages without booting a full application.
A new major tag (v2.0, published 2023-03-23) replaces v1.0. The upstream release notes for v2.0 do not enumerate changes — they only link to the diff:
https://github.com/wpstarter/o-testbench/compare/v1.0...v2.0
Because this is a major bump, treat it as potentially breaking until proven otherwise. Read that compare view (and the UPGRADING/CHANGELOG file in the repo if present) before you let a composer update pull it into CI. Everything below describes the v1.x feature surface that the package exposes; where a detail is version-sensitive it is flagged.
Install (pin the major to avoid surprise upgrades)
composer require --dev "wpstarter/o-testbench:^2.0"
If you are not ready to migrate, pin to the previous major:
composer require --dev "wpstarter/o-testbench:^1.0"
The package is a dev dependency; it belongs in require-dev, and it expects wpstarter/framework to be present.
Register the PHPUnit extension and pin a WP version
<phpunit>
<extensions>
<extension class="WpStarter\TestBench\TestBenchExtension"/>
</extensions>
<php>
<server name="WP_VERSION" value="6.0"/>
<server name="APP_ENV" value="testing"/>
</php>
</phpunit>
WP_VERSION is read by the bootstrap and is required from v1.0 onward; an unset value is the single most common cause of a mysteriously empty WordPress bootstrap.
First test — prove the harness booted before writing real tests
use WpStarter\TestBench\TestCase;
class SmokeTest extends TestCase
{
public function test_environment_boots(): void
{
$this->assertTrue(true);
$this->assertEquals('6.0', $this->wpVersion());
}
}
If this passes, the extension, constants, and version wiring are all sound. If it fails, stop and fix the harness — every subsequent failure will be noise.
Configure the environment in getEnvironmentSetUp()
protected function getEnvironmentSetUp($app): void
{
$app['config']->set('database.default', 'sqlite');
$app['config']->set('database.connections.sqlite.database', ':memory:');
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->activatePlugin('my-plugin');
}
This is the hook you will touch most; it runs before each test's application is ready, so it is the right place for config, plugins, themes, and constants — and the wrong place for assertions.
WpStarter\TestBench\TestCase — the base class; read its setUp/tearDown to know exactly what is reset between tests.getEnvironmentSetUp() — your primary override point.TestBenchExtension — confirms which PHPUnit lifecycle hooks are wired.You have a WPStarter plugin with a service provider, a couple of filters, and a REST route. The fastest path to value:
$this->assertTrue($app->providerIsLoaded(MyProvider::class)) or an equivalent container/hook assertion).$this->call('GET', '/wp-json/my-plugin/v1/thing').wp_posts.That set proves the harness, the container, routing, and the DB layer — the four things that break most often.
Use SQLite in-memory unless you specifically need MySQL behaviour (collations, ON DUPLICATE KEY, full-text):
$app = $this->createApplication([
'database' => [
'default' => 'testing',
'connections' => [
'testing' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
],
],
]);
WordPress table names are prefixed (wp_posts, wp_options, …). The package ships a WordPress-aware assertDatabaseHas():
$this->assertDatabaseHas('wp_posts', ['post_title' => 'Test']);
Keep one canonical helper for table names so a non-default $table_prefix does not silently break half your suite.
Activate inside getEnvironmentSetUp(), not in the test body — activation is environment setup, and doing it lazily produces order-dependent flakiness:
$this->activatePlugin('my-plugin');
$this->activateTheme('my-theme');
$this->deactivatePlugin('unwanted-plugin');
The helpers return $this in v1.x, so chaining expresses load order explicitly:
$this->activatePlugin('plugin-a')->activatePlugin('plugin-b');
$this->mockFunction('get_current_user_id', fn () => 1);
$this->mockFunction('current_user_can', fn () => true);
$this->mockFunction('register_block_type', fn () => true); // Block Editor surface
Two rules that save hours:
resetMocks() exists for exactly this; call it in tearDown() or rely on the base class doing it — verify which, once.mockFunction() and WP_Mock::userFunction() for the same symbol. They use different interception layers and the last one wins non-deterministically. Pick one per file.$response = $this->call('GET', '/wp-json/wp/v2/posts');
$this->assertEquals(200, $response->status());
$response = $this->call('POST', '/wp-json/wp/v2/blocks', ['title' => 'X']);
Assert on status and on a decoded body fragment; a 200 with a WP_Error payload is a real and common failure mode.
$this->artisan('wpstarter:command')
->expectsQuestion('confirm', 'yes')
->assertExitCode(0)
->assertOutputContains('Success');
expectsQuestion is order-sensitive — if you add a prompt, every existing test that asserts questions must be updated in the same order.
$this->assertBlockRegistered('core/paragraph');
$this->assertBlockOutput('core/paragraph', '<p>Test</p>');
Block Editor state is process-global. Opt in per test rather than globally:
public function test_block_registration(): void
{
$this->withBlockEditor();
$this->assertBlockRegistered('my-plugin/callout');
}
Put cross-cutting concerns in one subclass and never repeat them:
abstract class PluginTestCase extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->mockFunction('current_user_can', fn () => true);
}
protected function getEnvironmentSetUp($app): void
{
parent::getEnvironmentSetUp($app);
$this->activatePlugin('my-plugin');
}
}
The leverage point is that WPStarter bootstraps like Laravel, so you can test:
getEnvironmentSetUp().do_action/apply_filters, assert on the return.assertBlockEditorSetting('core/block-editor', 'allowedBlockTypes', [...]).Parallelism and WordPress globals are enemies. Isolate by giving each process its own DB name/domain rather than relying on :memory: if any test writes files:
<phpunit>
<extensions>
<extension class="WpStarter\TestBench\TestBenchExtension"/>
</extensions>
<php>
<server name="APP_ENV" value="testing"/>
<server name="DB_DATABASE" value=":memory:"/>
<server name="WP_TESTS_DOMAIN" value="example.test"/>
</php>
</phpunit>
Turn parallel runs on only after the serial suite is green; parallel debugging of shared WordPress state is not worth it.
1. Version drift in CI
^1.0 silently becomes ^2.0 in a lockfile-less environment, or a contributor's composer update pulls v2.0 mid-sprint.composer.lock, and be explicit about the major you target (^2.0 or ^1.0). Re-read the v1.0→v2.0 compare before bumping; the release notes give you nothing else to go on.2. Missing WordPress constants
WP_DEBUG, WP_TESTS_DIR, or WP_VERSION are unset.getEnvironmentSetUp() or a bootstrap file loaded before the suite:
define('WP_DEBUG', true);
define('WP_TESTS_DIR', __DIR__);
define('WP_VERSION', '6.0');
Prefer defined() || define() so a second definition does not warn.3. Plugin/theme load order
plugins_loaded callback runs before plugin A registered the hook it depends on.$this->activatePlugin('plugin-a')->activatePlugin('plugin-b')) and assert the resulting hook state in a dedicated ordering test.4. Singletons (wpdb, $wp_filter, $current_user)
$current_user poisons the next test.tearDown(); use resetMocks() for mocked functions and wp_set_current_user(0) (or a helper equivalent) for user state. Where you need a truly isolated symbol, WP_Mock::userFunction() is an alternative — but do not mix interception strategies for one function.5. Static analysis and IDE false positives
Call to undefined method on TestBench helpers.paths or ignore the base TestCase if the stubs are incomplete for the helpers you use.6. Block Editor bleed
withBlockEditor() inside the tests that need it, and call disableBlockEditorCache() when registration assertions go stale between tests.7. PHP / Laravel floor
composer.json require of the installed tag and align your CI matrix before upgrading. A failing composer install on 8.0 is the usual first symptom.1. Dump the WordPress state wholesale
$this->dumpWordPressState(); // globals, hooks, and more
Reach for this before scattering var_dump() calls; it answers "what did the bootstrap actually load?"
2. Inspect what a mock received
$this->assertEquals(
['key' => 'value'],
WP_Mock::userFunction('get_option')->getLastCallArgs()
);
Useful when a filter chain returns the right value for the wrong reason.
3. Slow suites
loadMigrationsFrom.:memory: SQLite; $app['db']->disableTransactions() only if you have another isolation strategy; disableBlockEditorCache() for block-heavy suites. Profile with --log-junit before optimising blindly.4. "Class not found"
wpstarter/o-testbench and wpstarter/framework are installed, then composer dump-autoload. Autoload staleness after a major upgrade is far more common than a genuinely missing class.5. Block registration looks empty
assertBlockEditorLoaded()) and settings are coherent (assertBlockEditorSettingsValid()) before asserting individual blocks — otherwise you debug the registry when the problem is activation order.1. Custom assertions Domain assertions belong in your base class, not copy-pasted:
class PostTestCase extends TestCase
{
protected function assertPostExists(int $id): void
{
$this->assertDatabaseHas('wp_posts', ['ID' => $id]);
}
protected function assertBlockRegistered(string $name): void
{
$this->assertTrue(has_block($name));
}
}
2. Factories for WordPress objects
Laravel factories do not model WP_Post/WP_User well; wrap them instead:
protected function createPost(array $attrs = []): int
{
return wp_insert_post(array_merge([
'post_title' => 'Test',
'post_status' => 'publish',
'post_type' => 'post',
], $attrs));
}
3. Overridable hooks in your base class
Expose protected function plugins(): array / protected function theme(): ?string in your PluginTestCase so individual tests can declare what they need instead of mutating state mid-test.
4. Filter-based seams For code that is hard to mock (core singletons), add a WPStarter filter around the call and have tests drive the filter. This is usually cheaper — and less brittle across a major upgrade — than expanding your mock surface.
How can I help you explore Laravel packages today?