Writing Meaningful Tests with Pest PHP: Patterns That Scale
Move beyond "does it 200" testing. Learn architecture testing, dataset providers, custom expectations, and mutation testing with Pest PHP.
SenpaiDev
Author
Pest PHP makes testing enjoyable, but most tutorials only cover the basics. These patterns will help you write a test suite that genuinely catches regressions, documents behavior, and stays fast as your application grows.
Architecture Tests: Enforce Your Standards Automatically
Pest's architecture testing plugin lets you encode your project conventions as tests. These tests run in milliseconds and prevent entire categories of structural issues:
// In tests/Arch.php
arch('controllers never directly query the database')
->expect('App\Http\Controllers')
->not->toUse(['Illuminate\Support\Facades\DB', 'Illuminate\Database\Eloquent\Model']);
arch('models use strict types')
->expect('App\Models')
->toUseStrictTypes();
arch('all action classes are invokable')
->expect('App\Actions')
->toBeInvokable()
->toHaveMethod('__invoke');
arch('no debug functions left in production code')
->expect(['dd', 'dump', 'ray', 'var_dump'])
->not->toBeUsed();
Datasets for Data-Driven Tests
When the same logic applies to multiple inputs, use datasets instead of duplicating tests:
dataset('invalid emails', [
'missing @ symbol' => ['notanemail'],
'missing domain' => ['user@'],
'empty string' => [''],
'with spaces' => ['user @example.com'],
]);
it('rejects invalid email addresses', function (string $email) {
$response = $this->postJson('/api/register', [
'email' => $email,
'password' => 'securepassword123',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['email']);
})->with('invalid emails');
This generates 4 separate test cases with clear names, runs them all, and makes it trivial to add new cases later.
Custom Expectations for Domain Logic
Create reusable expectations that make your tests read like business requirements:
// In tests/Pest.php
expect()->extend('toBePublished', function () {
return $this->toHaveProperty('is_published', true)
->and($this->value->published_at)->not->toBeNull();
});
expect()->extend('toBelongTo', function (User $user) {
return $this->toHaveProperty('user_id', $user->id);
});
// Usage in tests
it('publishes a blog post', function () {
$post = Blog::factory()->create(['is_published' => true, 'published_at' => now()]);
expect($post)->toBePublished()->toBelongTo($post->author);
});
Testing Livewire Components with Pest
Livewire's testing API integrates perfectly with Pest's fluent style:
it('searches blog posts in real time', function () {
Blog::factory()->published()->create(['title' => 'Laravel Testing Guide']);
Blog::factory()->published()->create(['title' => 'Vue.js Tutorial']);
Livewire::test(BlogIndex::class)
->set('search', 'Laravel')
->assertSee('Laravel Testing Guide')
->assertDontSee('Vue.js Tutorial');
});
it('creates a blog post and notifies subscribers', function () {
$admin = User::factory()->admin()->create();
Notification::fake();
Livewire::actingAs($admin)
->test(BlogCreate::class)
->set('title', 'New Post')
->set('content', str_repeat('word ', 100))
->call('save')
->assertRedirect();
Notification::assertSentTo(
User::where('id', '!=', $admin->id)->get(),
NewBlogPostNotification::class
);
});
Higher-Order Tests for Repetitive Auth Checks
Use Pest's higher-order testing to check multiple authorization scenarios concisely:
test('admin-only routes reject unauthenticated requests')
->get('/admin/blog/create')->assertRedirect('/login');
test('admin-only routes reject regular users')
->actingAs(User::factory()->create())
->get('/admin/blog/create')->assertForbidden();
Mutation Testing with Infection
Standard coverage metrics tell you which lines were executed. Mutation testing tells you if your tests actually verify the behavior. Install Infection and run it periodically to find tests that pass even when the code is deliberately broken. A test suite with 80% mutation score is far more valuable than 100% line coverage.
Great tests are an investment. They slow you down slightly today and pay back tenfold when you refactor confidently, catch regressions before deployment, and onboard new developers who can read tests to understand business rules.
Laravel field notes
How To Apply This In A Real Laravel App
Use the article as a starting point, then validate the idea against the shape of your application. In Laravel projects, the safest pattern is to make the first version small, measurable, and easy to remove if the tradeoff is wrong.
Implementation approach
Start with one route, one controller or action, and one test that proves the expected behavior. Once the path is stable, extract shared code into a service class or action only if a second caller needs it.
For production work, keep config in environment variables, cache expensive reads, and add clear failure states. A feature that works locally but fails silently in a queue, scheduler, or cached config environment is not ready for users.
Review Checklist
- Add a feature or regression test before changing shared behavior.
- Run the route through production-like cache settings with config and route caching enabled.
- Check authorization, validation, and error responses before exposing the feature publicly.
- Document any non-obvious tradeoff in the code or article notes so future edits stay honest.
Written by
SenpaiDev
Developer and publisher at SenpaiDev, writing practical notes on Laravel, PHP, browser tools, and shipping better web products.
Comments (0)
Join the conversation
Log in to commentNo comments yet. Be the first to share your thoughts!