orchestra/pest-plugin-testbench
Pest Plugin for Testbench lets you use PestPHP to test your Laravel packages, bringing Pest's expressive, readable syntax to Orchestra Testbench so building and maintaining package test suites takes less boilerplate.
Prerequisites. This package is an adapter, not a test framework. It is only useful when all of the following are already true: the repo is a Laravel package (not an application), orchestra/testbench is the harness, and Pest is the runner you intend to use. If the repo is an application, you want pestphp/pest-plugin-laravel instead — the two are not substitutes.
Install.
composer require --dev orchestra/pest-plugin-testbench
Pest discovers plugins automatically when the package is published with "type": "pest-plugin", so in most setups there is no registration step at all. Verify it landed by checking vendor/composer/installed.json for the package's type, and by confirming vendor/orchestra/pest-plugin-testbench/src exists. If your Pest version does not auto-discover, that is the first thing to debug — do not start writing tests until the plugin is demonstrably loaded.
Wire the Testbench test case into Pest. Everything funnels through one binding in tests/Pest.php:
<?php
use Orchestra\Testbench\TestCase;
uses(TestCase::class)->in('Feature', 'Unit');
Keep Unit on the binding only if those tests actually need the container. Pure value-object tests boot a full Laravel application for nothing and will dominate your suite runtime.
First use case — port one existing test. Pick a single, already-green PHPUnit test that exercises the package's service provider. Convert it, run it, and compare. The smallest meaningful smoke test is a canary that proves the container really booted:
it('boots the package service provider', function () {
expect(app())
->toBeInstanceOf(\Illuminate\Foundation\Application::class)
->and(app()->getLoadedProviders())
->toHaveKey(\Acme\MyPackage\MyPackageServiceProvider::class);
});
If that passes, the harness is working. If it passes but your real tests also pass without the provider registered, you have the dangerous failure mode described below — a green build for the wrong reason.
Registering package providers. Testbench's PHPUnit idiom is overriding getPackageProviders($app) on a test class. Pest files are closures, so the reliable pattern is to keep one thin base class in tests/TestCase.php:
namespace Tests;
use Orchestra\Testbench\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function getPackageProviders($app): array
{
return [\Acme\MyPackage\MyPackageServiceProvider::class];
}
}
and bind that in Pest.php (uses(Tests\TestCase::class)->in('Feature')). This works regardless of what conveniences the plugin adds, and it is where defineEnvironment() / getEnvironmentSetUp() belong too. Check vendor/orchestra/pest-plugin-testbench/src and vendor/orchestra/testbench-core/src/Concerns before assuming the plugin removes the need for this class — the README does not document the surface area.
Where to look first, in order: tests/Pest.php (your binding), vendor/orchestra/pest-plugin-testbench/src (what the plugin actually hooks), vendor/orchestra/testbench-core/src/Concerns (available traits, attributes, fixtures), and vendor/pestphp/pest-plugin-laravel (helpers like laravel-style accessors and expectation macros you may already be used to in app repos).
Base class plus uses() binding is the workhorse. Do not try to configure Testbench per-file from inside a Pest closure. Put provider registration, environment setup, and shared migrations on Tests\TestCase, then bind directories to it. This is the one pattern that survives every Pest major without change.
Split boot cost by directory. Applications boot the whole container; package tests often don't need to.
uses(Tests\TestCase::class)->in('Feature');
uses(\PHPUnit\Framework\TestCase::class)->in('Unit');
Reserve the Testbench-bound suite for code that touches config, the container, or the database.
Datasets are the replacement for PHPUnit data providers. This is the single biggest readability win of the migration and the thing developers notice day to day:
dataset('driver names', ['mysql', 'pgsql', 'sqlite']);
it('normalises a driver name', function (string $driver) {
expect(Config::normalise($driver))->toBe(strtolower($driver));
})->with('driver names');
Named datasets belong in tests/Datasets/ or at the bottom of Pest.php. Convert existing @dataProvider methods into datasets rather than translating them into closures — otherwise you keep the PHPUnit shape without its ergonomics.
Shared per-test lifecycle hooks. Use beforeEach() on the uses() chain for environment that every test in a directory needs, and keep it out of individual tests:
uses(Tests\TestCase::class)
->beforeEach(fn () => $this->loadMigrationsFrom(__DIR__.'/../database/migrations'))
->in('Feature');
Anything that mutates state (migrations, RefreshDatabase) must be in the binding or a base class — not in a beforeEach that runs after the container has already resolved the connection.
Testbench fixtures via WithFixtures. Supported from v3.3.0 / v2.2.0 onward. This is the idiomatic Testbench way to assert against package data without re-declaring it in every test. Apply the concern on the base TestCase and confirm it in a test that fails loudly if the fixture is missing:
it('loads the package fixture', function () {
expect(fixture('users'))->not->toBeEmpty();
});
If that assertion errors rather than fails, the concern is not applied.
Database isolation. Trait-based isolation (RefreshDatabase, DatabaseTransactions, or the Testbench equivalents) follows the same rule as providers: apply on the base class, not inside a closure. Verify engagement with an explicit assertion that a table is empty at the start of a test, then insert and confirm the next test does not see the row. That two-test pair is worth keeping permanently as a regression guard — it is the cheapest defence against silent state leakage after an upgrade.
Workbench / package scaffolding. If your package ships a workbench/ app for exploration, Testbench's discovery handles it; the Pest binding does not change that. Treat workbench tests as their own directory with its own uses() binding so a broken workbench app does not take the unit suite down with it.
Migration workflow — convert in parallel, never in one commit. Pest and PHPUnit run side by side in the same repo, and the plugin does not force a cutover. The sequence that works:
Unit first (no container, lowest risk), then Feature.phpunit.xml testsuites and PHPUnit-only dev dependencies only once Pest is the authoritative green/red signal.Shared CI helper. With more than two or three packages, put the uses() binding and the version constraints in a shared template rather than re-typing them. The bindings drift otherwise, and a drifted binding produces passing tests against an unconfigured app.
Version lockstep is the number one operational hazard. The major lines map to Pest majors: v2 → Pest 2, v3.0.0 → Pest 3 / Pest Plugin Laravel 3, v4.0.0 → Pest 4 / Pest Plugin Laravel 4, v5.0.0 → Pest 5. Additionally v3.2.0 adds Testbench 10, v4.1.0 adds Laravel 13, v3.1.0 adds PHP 8.4, and v2.1.1 / v3.2.1 pulled in orchestra/sidekick. A Pest upgrade is therefore a three-way coordinated bump (Pest + Pest Plugin Laravel + this plugin), plus a Testbench check. Put all four in one Dependabot group so a partial bump cannot land. A half-applied upgrade will usually still install — it just silently drops behaviour.
The failure mode that should worry you is a green build for the wrong reason. A red build is cheap. The pathological case is a Testbench trait or provider that is not applied under Pest, leaving tests asserting against uninitialised config or leaked database state — they pass, so nobody investigates. Defend with canaries: one test asserting the provider is in getLoadedProviders(), one asserting the DB is empty at test start, one asserting a config key from defineEnvironment() is present. Keep them permanently. They will catch the next plugin major before it catches you.
Do not rely on Testbench attributes without verifying them. Testbench exposes attributes for config, environment, and migrations, and whether they are honoured depends on how Pest reflects test closures in your version. Prove it with a throwaway test that asserts the attribute's effect, then decide. If attributes do not apply, express the same thing in defineEnvironment() on the base class or as a dataset — do not paper over it with a beforeEach that happens to work by ordering.
There is no official documentation. The README's "Official Documentation" block is commented out; there are badges, a Contributing link, and nothing else. Budget the ramp-up honestly. The practical substitute is reading vendor/orchestra/pest-plugin-testbench/src (it is small) and vendor/orchestra/testbench-core/src/Concerns. Assign one engineer as the internal reference and have them write a one-page internal README covering the binding pattern and the version matrix. This is the single highest-leverage thing you can do on day one.
Zero dependents and a low star count are not the risk they look like — but keep the fork path open. The package is maintained under the same orchestral org as Testbench itself, and the release history shows prompt tracking of upstream majors (Pest 3/4/5, Testbench 10, Laravel 13, PHP 8.4). Still, MIT license and a small surface area mean a fork is a managed fallback rather than a crisis. Write down which suites would revert to PHPUnit if the plugin stalls at a future Pest major, and where the fork would live.
Debugging recipe. When a test passes under PHPUnit and fails under Pest, in this order: (1) confirm the binding directory is right — uses() is path-based and a nested directory needs its own binding or a wildcard; (2) --filter to the single test and check app()->getLoadedProviders() inside it; (3) check whether the failure is a setUp ordering issue by moving the logic into the base class; (4) check which Testbench traits are actually applied via class_uses_recursive($this); (5) only then suspect the plugin. Also remember Pest reads phpunit.xml for testsuites and coverage config — a stale testsuite entry will make Pest skip files with no error at all.
Parallel testing needs its own verification. Pest's --parallel under Testbench means multiple bootstrapped applications and multiple database connections. In-memory SQLite does not always behave the way it does serially. Run serial and parallel against the same baseline before switching CI over; a suite that is correct serially and flaky in parallel is worse than one that is just slow.
Extension points, in order of preference. (1) The Tests\TestCase base class — always the safest place for provider registration and environment setup. (2) tests/Pest.php uses() chains with beforeEach for directory-scoped concerns. (3) Datasets in tests/Datasets/ for parameterised cases. (4) Pest expectations or custom helpers for repeated assertions. Reach for the plugin's own API last, precisely because it is the least documented piece and the most likely to shift under you.
Keep Unit off the container unless it needs it. Teams migrating from PHPUnit tend to put TestCase::class on everything because the old base class was on everything. In Pest this is a visible, measurable cost per test. Moving pure logic to PHPUnit\Framework\TestCase is a legitimate and often large speed win — but do it after the migration is stable, not during, so you are not debugging two changes at once.
How can I help you explore Laravel packages today?