5 min read
Testing in Laravel
Most Laravel developers know they should write more tests. The question is where to start and what's actually worth the effort. This post covers the practical patterns I use in real Laravel projects.
Pest vs PHPUnit
Laravel ships with both PHPUnit and Pest. Pest is the modern choice. It's built on top of PHPUnit but gives you a cleaner syntax, better output, and useful Laravel-specific helpers out of the box.
Install Pest if it's not already there:
composer require pestphp/pest pestphp/pest-plugin-laravel --dev ./vendor/bin/pest --init
Tests live in two directories:
tests/Unit/— pure PHP, no framework boottests/Feature/— full Laravel application, database, HTTP
Feature tests
Feature tests are the ones worth writing first. They exercise the full stack: routing, middleware, controllers, models, and database. If a feature test passes, you know the feature works.
Test HTTP responses directly:
it('shows the dashboard to authenticated users', function () { $user = User::factory()->create(); $this->actingAs($user) ->get('/dashboard') ->assertOk() ->assertSee($user->name); }); it('redirects guests away from the dashboard', function () { $this->get('/dashboard') ->assertRedirect('/login'); });
Test form submissions:
it('creates a post when valid data is submitted', function () { $user = User::factory()->create(); $this->actingAs($user) ->post('/posts', [ 'title' => 'My first post', 'body' => 'Some content here.', ]) ->assertRedirect('/posts'); $this->assertDatabaseHas('posts', [ 'title' => 'My first post', 'user_id' => $user->id, ]); }); it('rejects posts with missing title', function () { $user = User::factory()->create(); $this->actingAs($user) ->post('/posts', ['body' => 'No title here.']) ->assertSessionHasErrors(['title']); });
Unit tests
Unit tests cover isolated logic: helper functions, service classes, value objects, anything that doesn't need the framework to run.
it('formats a price in pence to pounds', function () { expect(formatPrice(1999))->toBe('£19.99'); expect(formatPrice(100))->toBe('£1.00'); expect(formatPrice(0))->toBe('£0.00'); });
Service class example:
it('calculates the total with tax', function () { $calculator = new OrderTotalCalculator(vatRate: 0.20); $total = $calculator->calculate(items: [ ['price' => 1000, 'quantity' => 2], ['price' => 500, 'quantity' => 1], ]); expect($total)->toBe(3000); // (2000 + 500) * 1.20 = 3000 });
Factories
Model factories are how you create test data. Laravel generates them with Artisan:
php artisan make:factory PostFactory
A good factory covers the most common states your model can be in:
class PostFactory extends Factory { public function definition(): array { return [ 'title' => $this->faker->sentence(), 'body' => $this->faker->paragraphs(3, asText: true), 'published_at' => null, 'user_id' => User::factory(), ]; } public function published(): static { return $this->state(['published_at' => now()]); } public function draft(): static { return $this->state(['published_at' => null]); } }
Use states in tests:
it('only shows published posts', function () { Post::factory()->count(3)->published()->create(); Post::factory()->count(2)->draft()->create(); $this->get('/posts') ->assertOk() ->assertJsonCount(3, 'data'); });
Database testing
Laravel resets your database between tests by default. Use RefreshDatabase or the Pest equivalent to ensure a clean slate:
// In tests/Pest.php uses(RefreshDatabase::class)->in('Feature');
If test speed matters, LazilyRefreshDatabase only resets if the test actually touches the database:
uses(LazilyRefreshDatabase::class)->in('Feature');
For read-only tests (just asserting on existing records), wrap in a database transaction instead:
uses(DatabaseTransactions::class)->in('Feature/ReadOnly');
Testing jobs and events
Don't let jobs or events actually fire during tests. Fake them:
it('dispatches a welcome email job on registration', function () { Queue::fake(); $this->post('/register', [ 'name' => 'Paul', 'email' => '[email protected]', 'password' => 'password', 'password_confirmation' => 'password', ])->assertRedirect(); Queue::assertPushed(SendWelcomeEmail::class); });
Same pattern for events:
it('fires a UserRegistered event', function () { Event::fake(); $this->post('/register', $validData)->assertRedirect(); Event::assertDispatched(UserRegistered::class); });
Testing notifications and mail
it('emails the user after password reset', function () { Mail::fake(); $user = User::factory()->create(); $this->post('/forgot-password', ['email' => $user->email]); Mail::assertQueued(PasswordResetMail::class, function ($mail) use ($user) { return $mail->hasTo($user->email); }); });
What to actually test
The tests most worth writing, in rough priority order:
- Authentication and authorisation flows — wrong access control causes real damage
- Form submissions — validation rules, success paths, failure paths
- Any business logic with branching conditions
- Anything that touches money or user data
- Jobs and scheduled tasks that run in the background
- API responses consumed by a frontend or external service
What you can skip for most projects:
- Trivial CRUD with no logic (getters, setters, basic model relationships)
- Third-party package behaviour (test your code, not theirs)
- Pure UI rendering with no data logic
Running tests
# Run all tests ./vendor/bin/pest # Run a specific file ./vendor/bin/pest tests/Feature/PostTest.php # Run tests matching a name ./vendor/bin/pest --filter="creates a post" # Run with coverage (requires Xdebug or PCOV) ./vendor/bin/pest --coverage
Add a composer script to keep it short:
"scripts": { "test": "pest" }
Then: composer test.
A note on test-driven development
You don't have to write tests before you write code for every feature. But for anything non-trivial, writing the test first forces you to think about the interface before the implementation. What input does this function need? What should it return? What should it refuse?
Even a rough feature test written before you start coding — one that fails and that you work towards making pass — tends to produce cleaner code than testing after the fact.