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

Related articles

Let's begin

Have an idea worth building?

Tell me what you are creating, what stage the project is at, and where you need technical support — I will respond with practical next steps.