ralphjsmit/pest-plugin-livewire
A Pest plugin for Livewire that brings Livewire component testing into Pest's expressive syntax, with helpers for mounting components, asserting state, events, and rendered output so your Livewire tests stay short and readable.
Install (legacy stacks only). The plugin is dev-only and targets Pest 1.x/2.x with Livewire 2.x:
composer require --dev ralphjsmit/pest-plugin-livewire
There is no config file, no service provider to register, and no artisan command. Pest discovers the plugin through the package's composer metadata; on a healthy install, the livewire() helper is simply available inside any Pest test file. If you get Call to undefined function livewire(), that is a plugin-registration failure, not a missing import — see Gotchas.
Where to look first. In order:
composer.json of your project — confirm you are actually on pest ^1|^2 and livewire ^2. On Livewire 3 / Pest 3, stop here; the native API below is strictly better.src/ and README — the public surface is a single helper plus a handful of chainable assertions. Read it top to bottom; it is well under an hour.Livewire::test() docs — everything the plugin does is a thin wrapper over that object, so Livewire's own documentation is your real reference for assertion semantics.First use case: one form component, one happy path. The whole point is that a component test reads like the rest of your Pest suite instead of switching into Livewire::test() ceremony:
// tests/Feature/Components/CreatePostTest.php
use App\Http\Livewire\CreatePost;
it('creates a post', function () {
livewire(CreatePost::class)
->assertSee('New post')
->set('title', 'Hello world')
->set('body', 'Some body copy')
->call('save')
->assertHasNoErrors()
->assertDispatched('post-created');
});
The equivalent without the plugin is one extra import and a class name:
use Livewire\Livewire;
it('creates a post', function () {
Livewire::test(CreatePost::class)
->assertSee('New post')
->set('title', 'Hello world')
->call('save')
->assertHasNoErrors()
->assertDispatched('post-created');
});
That comparison is the entire value proposition — and the reason this package is a convenience, not a dependency.
Dataset-driven validation tests. This is where Pest-style syntax actually pays for itself. Instead of ten near-identical test methods, assert each field's rule in one test body:
it('requires a value', function (string $field) {
livewire(CreatePost::class)
->set($field, '')
->call('save')
->assertHasErrors([$field => 'required']);
})->with(['title', 'body', 'slug']);
it('enforces max length', function (string $field, int $max) {
livewire(CreatePost::class)
->set($field, str_repeat('a', $max + 1))
->call('save')
->assertHasErrors([$field => 'max']);
})->with([
['title', 120],
['body', 5000],
]);
Named datasets (->with(['title' => ['title']])) pay off immediately when a failure prints with data set "body" instead of with data set #1.
Property updates then actions. The chain mirrors user behaviour, so keep the order honest: render assertion → set() in the order a user would type → call() the action → assert state/events/redirect. A test that calls save() before setting fields proves nothing:
it('redirects after publishing', function () {
livewire(CreatePost::class)
->set('title', 'Shipping today')
->set('published', true)
->call('save')
->assertRedirect(route('posts.index'));
});
State assertions after mutation. assertSet() is the workhorse for verifying what the component actually committed, and it catches the classic Livewire bug where an action mutates a public property but forgets $this->validate():
it('normalises the slug on save', function () {
livewire(CreatePost::class)
->set('title', 'Hello, World!')
->call('save')
->assertSet('slug', 'hello-world');
});
Event and listener assertions. Assert what the component emits (for JS/wire: consumers) and separately that it responds to what it listens for:
it('refreshes when the post is updated', function () {
livewire(PostList::class)
->call('$refresh')
->assertSee('Updated title');
});
it('broadcasts on delete', function () {
livewire(PostList::class)
->call('delete', postId: 1)
->assertDispatched('post-deleted');
});
Group components with describe() and share setup. Livewire tests need a database, and a form component test suite will get slow if each file bootstraps independently. Keep RefreshDatabase global in tests/Pest.php (or scoped per directory) and use beforeEach for shared fixtures:
describe('post form', function () {
beforeEach(function () {
$this->actingAs(User::factory()->create());
});
it('pre-fills on edit', function () {
$post = Post::factory()->create(['title' => 'Existing']);
livewire(EditPost::class, ['post' => $post])
->assertSet('title', 'Existing');
});
});
Note the second argument: mounting with parameters is the pattern for every edit/show component, and it's the same array you'd pass to Livewire::test().
Custom expectations. If your team leans on expect(), wrap the plugin's repeat assertions once rather than copy-pasting them. Useful ones are "no validation errors" and "saved successfully", which almost every form test ends with:
// tests/Pest.php
expect()->extend('toHaveSaved', function () {
return $this->assertHasNoErrors()
->assertDispatched('saved');
});
Integration tip — keep browser concerns out. This is a component-level tool: it exercises mount(), set(), call(), and the rendered HTML string. It does not run Alpine, does not fire wire:model.lazy debounce timers, and does not do real file uploads end-to-end (you fake the disk and set an UploadedFile on the property directly). If a defect requires JS, it belongs in Dusk/Playwright with one or two smoke tests, not here.
Migration pattern — replace, don't fork blindly. If you inherit a codebase using this package and you're moving to Livewire 3, the cheapest path is a global helper you own, defined in an autoloaded dev file. It keeps every existing test body unchanged:
// tests/Support/helpers.php (add to composer.json autoload-dev.files)
use Livewire\Livewire;
function livewire(string $class, array $params = [])
{
return Livewire::test($class, $params);
}
Then swap the validation assertions in-place for the native ones: assertHasErrors → assertHasErrors, set('form.title', ...) → fillForm([...]), assertSet(...) on form objects → assertFormSet(...).
The package is archived — treat it as source material, not a dependency. Last release April 2023, zero dependents, no maintainer. Every day you keep it installed is a day your test suite's behaviour is defined by code nobody will patch. Audit usage, vendor the handful of helpers you actually use into tests/Support/, preserve the MIT attribution, and drop it from composer.json. Budget a day or two.
False-green is the real danger, not a hard failure. The dangerous scenario is not composer blowing up — that's loud and obvious. It's the plugin installing cleanly against a newer Livewire/Pest, with assertions that no longer mean what they did in 2023, and a suite that passes while asserting nothing. If you spike it, prove each assertion fails when it should before trusting it.
livewire() undefined almost always means plugin registration, not imports. Pest's plugin discovery changed across major versions. If the helper vanishes after a Pest upgrade, check the installed plugin list before touching tests/Pest.php. This failure is silent at autoload time and only surfaces when the first test using the helper runs.
Livewire 2 vocabulary differs from Livewire 3. assertEmitted() became assertDispatched(); assertHasErrors([$field => 'rule']) kept its shape but error-message matching tightened; Livewire\Testing\TestableLivewire became Livewire\Features\SupportTesting\Testable. If you copy assertions between a Livewire 2 test and a Livewire 3 test, expect to rewrite them. Do not port assertions across versions by find-and-replace.
assertSet() is strict about types. ->assertSet('published', true) fails against 1 or '1', and Livewire's public properties coerce on assignment. Cast in the component (public bool $published) rather than loosening the assertion — the coercions your test surfaces here are usually real bugs.
Redirect assertions stop the chain. In Livewire, once an action returns a redirect, subsequent assertSee() on that same chain is checking a response you've already left. Split into two tests: one for the redirect, one for the state change.
File uploads need Storage::fake() and a fake disk, always. Forgetting this writes real files into storage/app during CI and produces tests that pass locally and fail in parallel runs. Also remember Livewire's TemporaryUploadedFile lifecycle: set the property to an UploadedFile::fake()->create(...), then call the action that promotes it.
--parallel and shared state. Livewire component tests hold in-memory component state and assertDispatched listeners. With pest --parallel, isolate the database (Per-test transactions or a per-process database) and never rely on Event::fake() set in one test leaking into another — put fakes in beforeEach, not at file top level.
Exceptions hide as validation errors. A ModelNotFoundException inside save() can surface as an opaque failed assertion. Add $this->withoutExceptionHandling() while debugging a red test — it turns the ambiguous assertion failure into the actual stack trace.
Extend via expect()->extend(), not by patching the package. If you're keeping it short-term, add your own expectations on top. Never edit vendor/ — the next composer update erases the change and the plugin is not getting upstream patches.
Document the exit. Whatever you do, put a line in onboarding/CONTRIBUTING stating the package is archived and the internal helper is authoritative. The most likely failure mode going forward is a new engineer re-adding it to a fresh service because it showed up in an old PR.
How can I help you explore Laravel packages today?