Laravel Testing Guide: PHPUnit and Pest for Laravel Applications
Laravel ships with a complete testing setup: PHPUnit configured, database factories, HTTP test helpers, and Artisan commands to generate tests. Feature tests let you make real HTTP requests to your application without a running server. This guide covers unit tests, feature tests, database testing, facade mocking, queue/event faking, and Pest as a modern alternative.
Key Takeaways
Laravel's feature tests make HTTP requests against your real app. $this->get('/route') boots Laravel, runs middleware, hits controllers, queries the real database. No mocking required — you test the whole stack.
RefreshDatabase resets the database between tests. Add this trait to reset state without manually truncating tables. It wraps tests in a transaction (fast) or re-migrates (when transactions won't work).
Factories generate test data. User::factory()->create() creates a user in the test database. User::factory(10)->create() creates 10. Factories let you focus on the specific data each test cares about.
Event::fake(), Mail::fake(), Queue::fake() prevent side effects. Faking these in tests stops real emails from sending, real jobs from queuing, while still letting you assert they were dispatched.
Pest is Laravel's recommended frontend for PHPUnit. New Laravel projects use Pest by default (Laravel 11+). Pest is a thin wrapper over PHPUnit with a more concise it()/test() syntax.
Laravel's Testing Foundation
Laravel comes pre-configured for testing. Out of the box you get:
- PHPUnit (or Pest) configured in
phpunit.xml - A
tests/directory withUnit/andFeature/subdirectories - A
.env.testingfile for test environment configuration Artisancommands to generate tests- HTTP testing helpers (
$this->get(),$this->post(), etc.) - Database testing helpers (
RefreshDatabase,DatabaseTransactions) - Model factories for generating test data
Project Structure
tests/
├── Unit/
│ ├── Services/
│ │ └── PricingServiceTest.php
│ └── Models/
│ └── OrderTest.php
└── Feature/
├── Auth/
│ ├── LoginTest.php
│ └── RegistrationTest.php
└── Api/
├── ProductsTest.php
└── OrdersTest.phpCreate a test with Artisan:
# Feature test (in tests/Feature)
php artisan make:<span class="hljs-built_in">test UserRegistrationTest
<span class="hljs-comment"># Unit test (in tests/Unit)
php artisan make:<span class="hljs-built_in">test Services/PricingServiceTest --unit
<span class="hljs-comment"># Pest test
php artisan make:<span class="hljs-built_in">test UserRegistrationTest --pestUnit Tests
Unit tests verify isolated PHP classes without the Laravel container:
// tests/Unit/Services/PricingServiceTest.php
<?php
namespace Tests\Unit\Services;
use App\Services\PricingService;
use PHPUnit\Framework\TestCase; // NOT Tests\TestCase (no Laravel!)
class PricingServiceTest extends TestCase
{
public function test_apply_discount_reduces_price_by_percentage(): void
{
$service = new PricingService();
$result = $service->applyDiscount(100.00, 20);
$this->assertEquals(80.00, $result);
}
public function test_apply_discount_with_zero_percent_returns_original(): void
{
$service = new PricingService();
$result = $service->applyDiscount(100.00, 0);
$this->assertEquals(100.00, $result);
}
}Use PHPUnit\Framework\TestCase directly for pure unit tests — it's faster because it doesn't boot the Laravel framework.
Feature Tests
Feature tests boot the full Laravel application and make real HTTP requests:
// tests/Feature/Auth/LoginTest.php
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class LoginTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_login_with_valid_credentials(): void
{
$user = User::factory()->create([
'email' => 'alice@example.com',
'password' => bcrypt('secret123'),
]);
$response = $this->post('/login', [
'email' => 'alice@example.com',
'password' => 'secret123',
]);
$response->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
}
public function test_login_fails_with_invalid_password(): void
{
User::factory()->create(['email' => 'alice@example.com']);
$response = $this->post('/login', [
'email' => 'alice@example.com',
'password' => 'wrong-password',
]);
$response->assertSessionHasErrors('email');
$this->assertGuest();
}
}HTTP Testing Helpers
// GET request
$response = $this->get('/api/products');
$response = $this->getJson('/api/products'); // sets Accept: application/json
// POST, PUT, DELETE
$response = $this->post('/api/products', ['name' => 'Widget', 'price' => 9.99]);
$response = $this->put('/api/products/1', ['price' => 12.99]);
$response = $this->delete('/api/products/1');
// Authenticated requests
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/dashboard');
// API token authentication
$response = $this->withToken($user->createToken('test')->plainTextToken)
->getJson('/api/orders');Response Assertions
$response->assertOk(); // 200
$response->assertCreated(); // 201
$response->assertNoContent(); // 204
$response->assertNotFound(); // 404
$response->assertForbidden(); // 403
$response->assertUnauthorized(); // 401
$response->assertStatus(429); // custom status code
$response->assertRedirect('/dashboard');
// JSON assertions
$response->assertJson(['name' => 'Widget']); // partial match
$response->assertExactJson([...]); // exact match
$response->assertJsonPath('data.user.email', 'alice@example.com');
$response->assertJsonCount(10, 'data'); // 10 items in data array
$response->assertJsonMissing(['password']); // field not present
$response->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'price']
],
'meta' => ['total', 'per_page', 'current_page'],
]);
// View assertions
$response->assertViewIs('products.index');
$response->assertViewHas('products');
$response->assertSee('Widget');
$response->assertDontSee('error');
// Session assertions
$response->assertSessionHas('success');
$response->assertSessionHasErrors(['email', 'password']);Database Testing
RefreshDatabase Trait
class OrderTest extends TestCase
{
use RefreshDatabase; // migrate fresh before each test, wrap in transaction
}RefreshDatabase runs migrations before your first test and wraps each test in a transaction that's rolled back after — making each test start with a clean database without re-running migrations for every test.
Factories
// Create and persist to database
$user = User::factory()->create();
$users = User::factory(10)->create();
// Create with specific attributes
$admin = User::factory()->create(['role' => 'admin']);
// Make (no persistence)
$user = User::factory()->make();
// States (defined in your factory)
$admin = User::factory()->admin()->create();
$unverified = User::factory()->unverified()->create();
// Relationships
$userWithOrders = User::factory()
->has(Order::factory(3))
->create();Database Assertions
$this->assertDatabaseHas('users', [
'email' => 'alice@example.com',
'role' => 'admin',
]);
$this->assertDatabaseMissing('users', [
'email' => 'deleted@example.com',
]);
$this->assertDatabaseCount('products', 10);
$this->assertSoftDeleted('orders', ['id' => 1]);Mocking Facades
Laravel facades can be mocked/faked without constructor injection:
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderConfirmation;
public function test_checkout_sends_confirmation_email(): void
{
Mail::fake();
$response = $this->actingAs($this->user)
->post('/checkout', $this->validOrder);
$response->assertRedirect('/order-confirmation');
Mail::assertSent(OrderConfirmation::class, function ($mail) {
return $mail->hasTo($this->user->email)
&& $mail->order->id === $this->orderId;
});
}Events
use Illuminate\Support\Facades\Event;
use App\Events\OrderPlaced;
public function test_placing_order_fires_order_placed_event(): void
{
Event::fake();
$this->actingAs($this->user)->post('/orders', $this->orderData);
Event::assertDispatched(OrderPlaced::class, function ($event) {
return $event->order->total === 99.99;
});
Event::assertNotDispatched(OrderCancelled::class);
}Queue (Jobs)
use Illuminate\Support\Facades\Queue;
use App\Jobs\ProcessPayment;
public function test_checkout_dispatches_payment_job(): void
{
Queue::fake();
$this->actingAs($this->user)->post('/checkout', $this->orderData);
Queue::assertPushed(ProcessPayment::class);
Queue::assertPushedOn('payments', ProcessPayment::class);
Queue::assertNotPushed(SendRefund::class);
}Storage
use Illuminate\Support\Facades\Storage;
public function test_upload_profile_picture_stores_file(): void
{
Storage::fake('public');
$file = UploadedFile::fake()->image('avatar.jpg', 100, 100);
$response = $this->actingAs($this->user)
->post('/profile/avatar', ['avatar' => $file]);
$response->assertOk();
Storage::disk('public')->assertExists("avatars/{$this->user->id}.jpg");
}Cache
use Illuminate\Support\Facades\Cache;
public function test_product_list_is_cached(): void
{
Cache::spy();
$this->get('/api/products');
Cache::shouldHaveReceived('remember')
->once()
->with('products.index', 3600, Mockery::any());
}Testing APIs with JSON
public function test_api_returns_paginated_products(): void
{
Product::factory(25)->create();
$response = $this->getJson('/api/products?per_page=10');
$response->assertOk()
->assertJsonStructure([
'data' => [['id', 'name', 'price', 'category']],
'links' => ['first', 'last', 'prev', 'next'],
'meta' => ['current_page', 'total', 'per_page'],
])
->assertJsonPath('meta.total', 25)
->assertJsonPath('meta.per_page', 10)
->assertJsonCount(10, 'data');
}Testing Middleware
public function test_unauthenticated_user_cannot_access_dashboard(): void
{
$response = $this->get('/dashboard');
$response->assertRedirect('/login');
}
public function test_non_admin_cannot_access_admin_panel(): void
{
$user = User::factory()->create(['role' => 'user']);
$response = $this->actingAs($user)->get('/admin');
$response->assertForbidden();
}Pest for Laravel
Laravel 11+ ships with Pest by default. The same tests in Pest syntax:
// tests/Feature/Auth/LoginTest.php (Pest)
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('user can login with valid credentials', function () {
$user = User::factory()->create([
'email' => 'alice@example.com',
'password' => bcrypt('secret123'),
]);
$response = $this->post('/login', [
'email' => 'alice@example.com',
'password' => 'secret123',
]);
$response->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
});
test('login fails with invalid password', function () {
User::factory()->create(['email' => 'alice@example.com']);
$this->post('/login', [
'email' => 'alice@example.com',
'password' => 'wrong',
])->assertSessionHasErrors('email');
$this->assertGuest();
});Pest's it() and test() functions, expect() assertions, and uses() for traits make tests more concise. The behavior is identical — Pest compiles to PHPUnit under the hood.
Running Tests
# All tests
php artisan <span class="hljs-built_in">test
<span class="hljs-comment"># or
./vendor/bin/phpunit
<span class="hljs-comment"># Specific file
php artisan <span class="hljs-built_in">test tests/Feature/Auth/LoginTest.php
<span class="hljs-comment"># Filter by test name
php artisan <span class="hljs-built_in">test --filter test_user_can_login
<span class="hljs-comment"># Parallel execution
php artisan <span class="hljs-built_in">test --parallel
<span class="hljs-comment"># Coverage
php artisan <span class="hljs-built_in">test --coverage
<span class="hljs-comment"># Stop on failure
php artisan <span class="hljs-built_in">test --stop-on-failureCI/CD Integration
GitHub Actions
name: Laravel Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: laravel_test
MYSQL_ROOT_PASSWORD: secret
options: --health-cmd="mysqladmin ping" --health-interval=10s
ports:
- 3306:3306
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_mysql, xdebug
coverage: xdebug
- run: composer install --no-interaction --prefer-dist
- name: Set up test environment
run: |
cp .env.testing.example .env.testing
php artisan key:generate --env=testing
- name: Run tests
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: laravel_test
DB_USERNAME: root
DB_PASSWORD: secret
run: php artisan test --parallel --coverage-clover coverage.xmlBeyond PHPUnit: End-to-End Testing
Laravel feature tests test your app in-process — they don't run a real browser. For testing JavaScript interactions, multi-page flows, and visual regressions, you need a browser layer.
HelpMeTest runs Robot Framework + Playwright tests against your deployed Laravel application. It catches bugs that feature tests miss: broken JavaScript, misconfigured redirects, CSS hiding submit buttons, third-party script failures.
Pair php artisan test for fast feedback in CI with HelpMeTest for continuous live monitoring. The free tier handles 10 tests. Pro is $100/month flat.