Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 039 / 100

Testing Laravel with Pest: practical tips and trade-offs

Readable tests with a modern syntax.

Expona Admin7 min read

Introduction

When you're shipping features at pace in Laravel, your test suite becomes the safety net that keeps refactoring from becoming a nightmare. Testing Laravel with Pest: practical tips and trade-offs means moving beyond basic unit tests to building confidence in your API routes, database operations, and external integrations—all with a syntax that reads more like prose than boilerplate. We've found that teams adopting Pest across production codebases see faster test iteration and fewer false positives because the framework removes ceremony without sacrificing coverage.

This guide walks through the patterns we use daily: writing focused feature tests, managing database state between runs, faking external calls, and running your suite in parallel so the feedback loop stays tight. By the end, you'll have a practical testing strategy that scales from a side project to a multi-service platform.

Feature tests — Hit routes and assert responses

Feature tests are where most of the value lives. Unlike unit tests that verify isolated functions, feature tests exercise your actual HTTP layer: they boot the application, hit routes, and verify the response. This is where bugs hide in the real world—not in pure functions, but in the gaps between validation, authorization, and database writes.

With Pest, a feature test reads naturally. You set up a user, act as that user, make a POST request with JSON payload, and assert the HTTP status:

php
it('creates a product', function () {
    $user = User::factory()->create();
    $this->actingAs($user)
        ->postJson('/api/products', ['name' => 'Mug', 'price' => 1200])
        ->assertCreated();
});

That test does real work: it creates a test user, authenticates the HTTP request, sends JSON to your controller, and confirms a 201 response. If your controller returns 400 or 401, the test fails immediately. If the product was never written to the database, the assertion fails. No mocking of database layers, no dependency injection gymnastics—just the behavior you ship.

Expand this pattern for validation and relationships. When you need to verify that your API rejects invalid input, send bad data and assert a 422 response with specific error messages:

php
it('validates product price is positive', function () {
    $user = User::factory()->create();
    $this->actingAs($user)
        ->postJson('/api/products', ['name' => 'Mug', 'price' => -100])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['price']);
});

For retrieving and listing resources, verify both the status code and the response structure:

php
it('lists products for the authenticated user', function () {
    $user = User::factory()->create();
    $products = Product::factory(3)->for($user)->create();

    $response = $this->actingAs($user)
        ->getJson('/api/products')
        ->assertOk()
        ->json();

    expect($response['data'])->toHaveCount(3);
});

Notice the Pest syntax: expect() for assertions replaces the older $this->assert*() fluent methods. Both work, but expect() reads cleaner and composes well with Pest plugins like toHaveCount().

In a real project, you're testing authorization gates too. A user without permission should get a 403. A guest should get redirected to login. These are the edge cases that slip past code review:

php
it('forbids deleting another user\'s product', function () {
    $alice = User::factory()->create();
    $bob = User::factory()->create();
    $product = Product::factory()->for($alice)->create();

    $this->actingAs($bob)
        ->deleteJson("/api/products/{$product->id}")
        ->assertForbidden();
});

Database refresh — RefreshDatabase resets data between tests

Each test needs a clean slate. If one test creates a user and doesn't clean up, the next test might fail unexpectedly because that user already exists in a unique constraint. Pest's RefreshDatabase trait handles this by rolling back database changes after each test.

Add it to your test class or—more elegantly—apply it globally in your tests/Pest.php configuration file:

php
uses(
    Tests\TestCase::class,
    RefreshDatabase::class,
)->in('Feature');

This runs RefreshDatabase for all tests in the tests/Feature directory. Now each test starts with a blank database. Your factories can seed whatever data the test needs, and no cleanup code is required.

The trade-off: RefreshDatabase uses database transactions, which are fast but have a cost. On a team with hundreds of tests, running the full suite can take 30–60 seconds. That's still faster than real migrations, but it's a constraint. Some teams split their suite into "smoke" tests (critical paths only, runs in 10 seconds) and "full" tests (everything, runs on CI). We recommend running the full suite locally before pushing, but smoke tests on every commit.

If your tests need specific seed data across multiple tests, use a shared factory approach:

php
beforeEach(function () {
    $this->user = User::factory()->create(['email' => 'alice@example.com']);
});

This runs before each test in the file. Combine it with RefreshDatabase and each test still gets isolation—the shared setup just saves repetition.

Fakes — Mail::fake, Queue::fake and Http::fake

Your application doesn't live alone. It sends emails, queues background jobs, calls third-party APIs. Testing against real services is slow and brittle. Pest gives you fakes to intercept these calls without ever leaving your test process.

Mail::fake prevents emails from sending during tests and lets you assert they were queued:

php
it('sends a welcome email on signup', function () {
    Mail::fake();

    $response = $this->postJson('/api/auth/register', [
        'email' => 'alice@example.com',
        'password' => 'secret123',
        'password_confirmation' => 'secret123',
    ]);

    expect($response->status())->toBe(201);
    Mail::assertSent(WelcomeEmail::class, function ($mail) {
        return $mail->hasTo('alice@example.com');
    });
});

The fake intercepts the email before it hits your mail driver. You verify the class, recipient, and content without touching your real email provider. This test runs in milliseconds instead of seconds, and your inbox stays clean.

Queue::fake works similarly for background jobs. If your application dispatches a job to process an image, you can verify the dispatch without waiting for the worker:

php
it('queues an image processing job', function () {
    Queue::fake();

    $user = User::factory()->create();
    $this->actingAs($user)
        ->postJson('/api/uploads', ['image' => UploadedFile::fake()->image('avatar.jpg')])
        ->assertCreated();

    Queue::assertPushed(ProcessImage::class);
});

Http::fake mocks external API calls. If you integrate with Stripe or a shipping API, faking the HTTP response keeps tests isolated:

php
it('charges a payment method', function () {
    Http::fake([
        'api.stripe.com/*' => Http::response(['id' => 'charge_abc123', 'status' => 'succeeded'], 200),
    ]);

    $user = User::factory()->create();
    $response = $this->actingAs($user)
        ->postJson('/api/payments', ['amount' => 5000, 'currency' => 'usd'])
        ->assertOk();

    expect($response->json('charge_id'))->toBe('charge_abc123');
});

The fake responds to any request to api.stripe.com with the mocked response. Your code runs the full flow—validation, API call, response parsing—without touching Stripe. When you integrate with a new service, add an HTTP fake, write the test, then implement the integration. Test-driven integration.

Run in parallel — php artisan test --parallel

On a large codebase, your test suite can grow to thousands of tests. Running them sequentially means waiting two minutes for feedback. Pest supports parallel test execution with a single flag:

php
php artisan test --parallel

This spreads tests across multiple processes, typically one per CPU core. A 120-second sequential run can drop to 30 seconds on a 4-core machine. Local feedback gets tighter, and CI pipelines finish faster.

The catch: parallel execution exposes state leaks. If one test modifies a static variable or doesn't fully clean up its database transaction, another test running simultaneously might fail in random ways. The fix is ensuring each test is truly isolated. Use RefreshDatabase correctly, avoid static state, and test locally with --parallel before assuming it works on CI.

You can also control the number of processes:

php
php artisan test --parallel --processes=2

On a machine with limited memory, fewer processes prevent resource exhaustion. On CI, you might run with 8 processes if the runner is beefy. Adjust to your environment.

Common mistakes

Testing implementation instead of behavior. A test that verifies a model's private methods is too tightly coupled. Test the public API—the route, the response, the database state. Internal refactoring shouldn't break tests.

Insufficient database isolation. Forgetting RefreshDatabase or not using it globally leads to tests that pass alone but fail in a suite. Always run php artisan test --parallel locally to catch these.

Over-mocking external calls. Fakes are powerful, but they hide real integration bugs. For critical paths—payment processing, authentication—consider a small set of integration tests against staging environments or sandboxes. Catch issues before production.

Ignoring test maintenance. Tests are code. When you rename a route or change a response structure, update the tests. A broken test suite is worse than no suite—teams stop trusting it and skip running it.

Key takeaways

  • Feature tests are your workhorse: write tests that hit routes and assert responses. They catch the real bugs—authorization fails, validation gaps, database constraint violations.
  • Use RefreshDatabase globally to ensure test isolation. Each test starts clean, no cleanup code needed.
  • Fake external dependencies: Mail::fake, Queue::fake, Http::fake. Keep tests fast and focused on your code, not third-party integrations.
  • Parallel execution with --parallel slashes feedback time, but requires true test isolation. Enforce this locally before pushing.
  • Test behavior and public APIs, not implementation details. Refactor confidently when tests verify outcomes, not internals.
  • Maintain your tests as you maintain your code. A stale test suite erodes team confidence faster than shipping without tests.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles