Laravel Testing Guide: PHPUnit and Pest for Laravel Applications

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 with Unit/ and Feature/ subdirectories
  • A .env.testing file for test environment configuration
  • Artisan commands 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.php

Create 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 --pest

Unit 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:

Mail

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-failure

CI/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.xml

Beyond 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.

Read more