Backend and API Development·8 min
Unit and Feature Testing in Laravel with Pest and PHPUnit: A Practical Guide
By Bahaj Abderrazak·Published July 16, 2024·Updated September 24, 2026
Automated testing isn't just about catching regressions — it's about having the confidence to refactor and ship features quickly without breaking existing customer workflows. Here is a pragmatic guide to testing Laravel applications.
# Unit and Feature Testing in Laravel with Pest and PHPUnit: A Practical Guide
Writing automated tests is one of the highest-leverage habits a developer can build. While untracked codebases become fragile and terrifying to refactor over time, a tested application gives you the freedom to update dependencies, optimize slow queries, and roll out features with absolute confidence.
Laravel provides arguably the best testing tooling in the backend ecosystem, whether you prefer traditional PHPUnit or the expressive, modern syntax of Pest PHP.
In this guide, I walk through the practical testing strategy I use on production Laravel applications, focusing on the tests that provide 80% of the value with 20% of the maintenance overhead.
## The Testing Pyramid: Feature Tests Over Unit Tests
In web development, the classic "unit test everything in isolation" advice often leads to brittle test suites heavily mocked with mocks that pass even when the real integration breaks.
For web APIs and applications, **Feature Tests** should make up the majority of your test suite. A feature test boots the framework, makes an HTTP request, runs real database transactions, and inspects the response status and database state.
| Test Type | Scope | Example |
|---|---|---|
| **Unit Test** | Isolated function or calculation | Calculating VAT or invoice totals |
| **Feature Test** | Full HTTP cycle + database | Testing registration, payment webhook, API endpoint |
| **End-to-End Test** | Full browser automation | Testing UI clicks via Playwright or Cypress |
## Setting Up an In-Memory Database for Lightning Speed
Always run your test suite against an isolated database so test records never pollute your development environment. In Laravel, use PostgreSQL or SQLite in-memory via `phpunit.xml`:
```xml
```
Setting `BCRYPT_ROUNDS` to 4 accelerates user registration tests dramatically, turning minutes of password hashing into milliseconds.
## Writing Expressive Feature Tests with Pest PHP
Pest PHP provides a clean, Jest-like syntax that reduces boilerplate and makes tests feel like readable documentation:
```php
use App\Models\User;
use App\Models\Invoice;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('a client can retrieve only their own invoices', function () {
// 1. Arrange: create two separate users and invoices
$clientA = User::factory()->create();
$clientB = User::factory()->create();
$invoiceA = Invoice::factory()->create(['user_id' => $clientA->id]);
$invoiceB = Invoice::factory()->create(['user_id' => $clientB->id]);
// 2. Act: authenticate as Client A and request invoice list
$response = $this->actingAs($clientA)
->getJson('/api/v1/invoices');
// 3. Assert: verify status and scope isolation
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $invoiceA->id)
->assertJsonMissing(['id' => $invoiceB->id]);
});
```
Notice how this single test verifies:
- Authentication middleware
- Controller query scoping
- JSON serialization
- Multi-tenant data isolation
## Testing Validation Rules
Never let invalid inputs reach your database. Test boundary validation rigorously:
```php
test('order creation requires a valid shipping address and items array', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/v1/orders', [
'shipping_address' => '',
'items' => [],
])
->assertStatus(422)
->assertJsonValidationErrors(['shipping_address', 'items']);
});
```
## Mocking External HTTP APIs with `Http::fake()`
One of the worst testing mistakes is hitting external APIs (Stripe, Twilio, SendGrid) inside your test suite. It introduces latency, costs money, and fails when third-party servers undergo maintenance.
Laravel's `Http` facade makes mocking external services straightforward:
```php
use Illuminate\Support\Facades\Http;
test('it handles successful payment callbacks', function () {
// Fake external payment gateway response
Http::fake([
'api.paymentgateway.com/*' => Http::response([
'transaction_id' => 'tx_987654',
'status' => 'CAPTURED',
], 200),
]);
$response = $this->postJson('/api/v1/checkout/process', [
'amount' => 1500,
'token' => 'tok_valid_test',
]);
$response->assertOk();
$this->assertDatabaseHas('payments', [
'transaction_id' => 'tx_987654',
'status' => 'completed',
]);
});
```
## Continuous Integration: Enforcing Quality on Every Pull Request
A test suite only protects you if it runs automatically before every deploy. By integrating tests into your CI/CD workflow, failing tests prevent broken code from ever reaching staging or production.
For how automated testing fits into a modern release pipeline, see my dedicated [Laravel CI/CD Pipeline Guide](/en/blog/laravel-cicd-pipeline-guide). For framework architecture decisions, review [Laravel vs Django vs .NET](/en/blog/laravel-vs-django-vs-dotnet).
Need an experienced backend engineer to architect, test, and scale your Laravel application? Learn more about my [backend and API development services](/en/services/backend-api-development) or [reach out to discuss your backend roadmap](/en/contact).
LaravelPHPUnitTestingPest PHPBackend DevelopmentTDD