Skip to main content
Technology & EngineeringPhp Laravel338 lines

Testing

Testing Laravel applications with PHPUnit and Pest, including feature tests, unit tests, mocking, and database testing

Quick Summary18 lines
You are an expert in testing Laravel applications with PHPUnit and Pest. You write tests that verify behavior through HTTP responses and database state rather than implementation details, and you structure test suites to remain fast and deterministic as the application grows.

## Key Points

- Use `RefreshDatabase` trait (runs migrations once, wraps each test in a transaction) rather than `DatabaseMigrations` for speed.
- Write feature tests for HTTP endpoints and unit tests for isolated logic (services, value objects).
- Use model factories extensively; avoid hardcoding test data.
- Fake external services (`Mail::fake()`, `Http::fake()`, `Queue::fake()`) to isolate tests from side effects.
- Prefer `assertJson` and `assertJsonStructure` over exact JSON matching so tests are resilient to new fields.
- Run tests in parallel with `php artisan test --parallel` to speed up the suite.
- Use Pest's `describe()` blocks or PHPUnit test classes to group related tests logically.
- **Forgetting `RefreshDatabase`**: Tests that mutate the database without this trait contaminate each other.
- **Over-mocking**: Mocking every dependency makes tests pass even when real code is broken. Mock boundaries (HTTP, mail, filesystem), not internal classes.
- **Testing implementation details**: Assert behavior (HTTP response, database state), not that a specific method was called, unless the call is the behavior (e.g., dispatching a job).
- **Slow test suites**: Minimize use of `seed()` in each test; create only the records each test needs.
- **Not testing validation**: Validation rules are logic. Dedicate tests to invalid inputs and confirm the correct error keys are returned.
skilldb get php-laravel-skills/TestingFull skill: 338 lines
Paste into your CLAUDE.md or agent config

Testing — PHP/Laravel

You are an expert in testing Laravel applications with PHPUnit and Pest. You write tests that verify behavior through HTTP responses and database state rather than implementation details, and you structure test suites to remain fast and deterministic as the application grows.

Core Philosophy

Tests are the executable specification of what the application does. A well-written test suite describes every endpoint's expected behavior, every validation rule's boundary, and every business rule's edge case. When a test fails, it should tell the developer exactly what behavior broke, not which internal method changed. This means asserting on observable outcomes -- HTTP status codes, JSON response structures, database records, dispatched jobs -- rather than on which methods were called internally. Tests that mirror the implementation break every time the code is refactored, even when behavior is unchanged.

Laravel's testing utilities are designed to make feature tests fast enough to replace the need for most unit tests. RefreshDatabase wraps each test in a transaction and rolls back, actingAs() eliminates authentication boilerplate, Queue::fake() and Mail::fake() isolate side effects, and model factories create test data expressively. These tools make it practical to test the full request lifecycle -- routing, middleware, validation, controller, service, database -- in a single test method that runs in milliseconds. Use this capability aggressively; a feature test that hits the real application stack catches integration bugs that unit tests with mocks cannot.

Test isolation is non-negotiable. Every test must set up its own data, exercise the code, and assert results without depending on state left by other tests. Shared fixtures, global seeders, and test ordering dependencies produce intermittent failures that erode trust in the test suite. When a test passes in isolation but fails when run with the full suite (or vice versa), the problem is always shared mutable state. RefreshDatabase, per-test factory data, and Carbon::setTestNow() for time-dependent logic are the tools for maintaining isolation.

Overview

Laravel is built with testing in mind. It ships with PHPUnit support and first-class HTTP testing helpers. Pest is a popular alternative test runner that offers an expressive, minimal syntax on top of PHPUnit. Both frameworks have full access to Laravel's testing utilities including database transactions, fakes, and assertions.

Core Concepts

PHPUnit Feature Test

namespace Tests\Feature;

use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PostControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_can_view_published_posts(): void
    {
        $posts = Post::factory()->published()->count(3)->create();
        Post::factory()->draft()->create(); // should not appear

        $response = $this->getJson('/api/posts');

        $response->assertOk()
            ->assertJsonCount(3, 'data')
            ->assertJsonStructure([
                'data' => [['id', 'title', 'slug', 'author', 'created_at']],
                'meta' => ['current_page', 'last_page', 'total'],
            ]);
    }

    public function test_authenticated_user_can_create_post(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->postJson('/api/posts', [
            'title' => 'My New Post',
            'body'  => 'This is the body of the post.',
            'tags'  => [1, 2],
        ]);

        $response->assertCreated()
            ->assertJsonPath('data.title', 'My New Post');

        $this->assertDatabaseHas('posts', [
            'title'   => 'My New Post',
            'user_id' => $user->id,
        ]);
    }

    public function test_guest_cannot_create_post(): void
    {
        $response = $this->postJson('/api/posts', [
            'title' => 'Unauthorized Post',
            'body'  => 'Body content.',
        ]);

        $response->assertUnauthorized();
    }

    public function test_validation_rejects_missing_title(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->postJson('/api/posts', [
            'body' => 'Body without a title.',
        ]);

        $response->assertUnprocessable()
            ->assertJsonValidationErrors(['title']);
    }
}

Pest Syntax

use App\Models\Post;
use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

it('shows published posts', function () {
    Post::factory()->published()->count(3)->create();

    $this->getJson('/api/posts')
        ->assertOk()
        ->assertJsonCount(3, 'data');
});

it('allows authenticated users to create posts', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->postJson('/api/posts', [
            'title' => 'New Post',
            'body'  => 'Post body content here.',
        ])
        ->assertCreated();

    expect(Post::where('title', 'New Post')->exists())->toBeTrue();
});

it('rejects guests from creating posts')
    ->postJson('/api/posts', ['title' => 'Test', 'body' => 'Test'])
    ->assertUnauthorized();

Model Factories

namespace Database\Factories;

use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class PostFactory extends Factory
{
    protected $model = Post::class;

    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'title'   => fake()->sentence(),
            'slug'    => fake()->unique()->slug(),
            'body'    => fake()->paragraphs(3, asText: true),
            'status'  => 'draft',
        ];
    }

    public function published(): static
    {
        return $this->state(fn () => [
            'status'       => 'published',
            'published_at' => fake()->dateTimeBetween('-30 days'),
        ]);
    }

    public function draft(): static
    {
        return $this->state(fn () => [
            'status'       => 'draft',
            'published_at' => null,
        ]);
    }

    public function byAuthor(User $user): static
    {
        return $this->state(fn () => ['user_id' => $user->id]);
    }
}

Implementation Patterns

Mocking and Faking

use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;

// Mail fake
it('sends welcome email on registration', function () {
    Mail::fake();

    $this->postJson('/register', [
        'name'     => 'Jane Doe',
        'email'    => 'jane@example.com',
        'password' => 'password123',
        'password_confirmation' => 'password123',
    ])->assertCreated();

    Mail::assertSent(WelcomeEmail::class, function ($mail) {
        return $mail->hasTo('jane@example.com');
    });
});

// Queue fake
it('dispatches invoice job after order', function () {
    Queue::fake();

    $user = User::factory()->create();

    $this->actingAs($user)->postJson('/api/orders', [
        'items' => [['product_id' => 1, 'quantity' => 2]],
    ])->assertCreated();

    Queue::assertPushed(GenerateInvoice::class, fn ($job) =>
        $job->order->user_id === $user->id
    );
});

// Storage fake
it('uploads user avatar', function () {
    Storage::fake('s3');

    $user = User::factory()->create();
    $file = \Illuminate\Http\UploadedFile::fake()->image('avatar.jpg', 200, 200);

    $this->actingAs($user)
        ->postJson('/api/user/avatar', ['avatar' => $file])
        ->assertOk();

    Storage::disk('s3')->assertExists("avatars/{$user->id}.jpg");
});

Testing Jobs Directly

it('generates an invoice PDF', function () {
    Storage::fake('s3');

    $order = Order::factory()->create();

    (new GenerateInvoice($order))->handle(app(InvoiceGenerator::class));

    $order->refresh();

    expect($order->invoice_path)->not->toBeNull();
    Storage::disk('s3')->assertExists($order->invoice_path);
});

Testing with Pest Datasets

dataset('invalid_post_data', [
    'missing title' => [['body' => 'Body text'], 'title'],
    'missing body'  => [['title' => 'Title'], 'body'],
    'title too long' => [['title' => str_repeat('a', 256), 'body' => 'Body'], 'title'],
]);

it('rejects invalid post data', function (array $data, string $errorField) {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->postJson('/api/posts', $data)
        ->assertUnprocessable()
        ->assertJsonValidationErrors([$errorField]);
})->with('invalid_post_data');

Testing Middleware and Authorization

it('blocks non-admin users from admin routes', function () {
    $user = User::factory()->create(['role' => 'user']);

    $this->actingAs($user)
        ->getJson('/api/admin/users')
        ->assertForbidden();
});

it('allows admin access to admin routes', function () {
    $admin = User::factory()->admin()->create();

    $this->actingAs($admin)
        ->getJson('/api/admin/users')
        ->assertOk();
});

Database Assertions

$this->assertDatabaseHas('posts', [
    'title'   => 'My Post',
    'user_id' => $user->id,
]);

$this->assertDatabaseMissing('posts', [
    'title' => 'Deleted Post',
]);

$this->assertDatabaseCount('posts', 5);

$this->assertSoftDeleted('posts', [
    'id' => $post->id,
]);

Best Practices

  • Use RefreshDatabase trait (runs migrations once, wraps each test in a transaction) rather than DatabaseMigrations for speed.
  • Write feature tests for HTTP endpoints and unit tests for isolated logic (services, value objects).
  • Use model factories extensively; avoid hardcoding test data.
  • Fake external services (Mail::fake(), Http::fake(), Queue::fake()) to isolate tests from side effects.
  • Prefer assertJson and assertJsonStructure over exact JSON matching so tests are resilient to new fields.
  • Run tests in parallel with php artisan test --parallel to speed up the suite.
  • Use Pest's describe() blocks or PHPUnit test classes to group related tests logically.

Common Pitfalls

  • Forgetting RefreshDatabase: Tests that mutate the database without this trait contaminate each other.
  • Over-mocking: Mocking every dependency makes tests pass even when real code is broken. Mock boundaries (HTTP, mail, filesystem), not internal classes.
  • Testing implementation details: Assert behavior (HTTP response, database state), not that a specific method was called, unless the call is the behavior (e.g., dispatching a job).
  • Slow test suites: Minimize use of seed() in each test; create only the records each test needs.
  • Not testing validation: Validation rules are logic. Dedicate tests to invalid inputs and confirm the correct error keys are returned.
  • Ignoring flaky tests: Time-dependent or order-dependent tests must be fixed immediately. Use Carbon::setTestNow() for time-sensitive logic.

Anti-Patterns

  • Testing the framework — writing tests that verify Route::get returns 200, that Eloquent's find() returns a model, or that validation rules reject invalid data when called directly. The framework is already tested by its maintainers. Focus testing effort on the application's custom behavior: business rules, authorization logic, and integration between components.

  • Mock-heavy unit tests — mocking every dependency of a service class and only verifying that mocks were called in the right order. These tests pass even when the real code is broken because they test the interaction script, not the behavior. Use real dependencies where practical and mock only external boundaries (HTTP clients, mail, filesystem).

  • Shared test database state — using DatabaseMigrations with a global seeder that populates the database once, then relying on that data across all tests. When one test modifies the shared data, other tests fail depending on execution order. Use RefreshDatabase and create only the data each test needs.

  • Missing negative tests — testing only the happy path ("user can create a post") without testing failure modes ("guest cannot create a post," "post with missing title returns 422," "user cannot edit another user's post"). Validation and authorization bugs are among the most common production issues, and they are only caught by testing invalid inputs and unauthorized access.

  • Ignoring test performance — letting the test suite slow to 5+ minutes without investigation. Slow suites discourage frequent local testing, pushing bug discovery to CI where the feedback loop is slower. Profile the suite with --log-junit, identify the slowest tests, and refactor them to use lighter-weight approaches (fewer database records, targeted fakes, parallel execution).

Install this skill directly: skilldb add php-laravel-skills

Get CLI access →