Tech Verse Logo
Enable dark mode
Testing Laravel APIs with Pest: A Practical Guide

Testing Laravel APIs with Pest: A Practical Guide

Tech Verse Daily

Tech Verse Daily

4 min read

Configuring Pest for Laravel 12 JSON APIs

PHPUnit tests work fine, but Pest 3.x makes Laravel API testing significantly cleaner. In Laravel 12 running on PHP 8.3, Pest gives you expressive syntax without sacrificing PHPUnit's execution engine. If you're building single-page applications or Next.js 16 frontends powered by React 19, your API needs test coverage for status codes, headers, JSON schemas, and database state mutations.

Start by installing Pest alongside the Laravel plugin if you haven't already:

composer require pestphp/pest pestphp/pest-plugin-laravel --dev
php artisan pest:install

In your tests/Pest.php file, bind the RefreshDatabase trait to your Feature directory. Don't skip this step or your tests will leak state across runs and throw duplicate key errors in MySQL or PostgreSQL.

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

Testing Authenticated API Endpoints

Authentication failures should fail fast. When building JSON APIs for client apps using Laravel Sanctum, unauthenticated requests must return an HTTP 401 status with a standard JSON error message. Let's look at testing a protected endpoint located at /api/v1/projects.

Here's how you test both guest denial and authenticated success using Pest syntax:

<?php

use App\Models\User;
use App\Models\Project;

test('guests cannot create projects', function () {
    $this->postJson('/api/v1/projects', [
        'name' => 'Internal Analytics Dashboard',
    ])->assertStatus(401);
});

test('authenticated users can create projects', function () {
    $user = User::factory()->create();

    $payload = [
        'name' => 'Customer Portal',
        'slug' => 'customer-portal',
        'is_public' => true,
    ];

    $response = $this->actingAs($user, 'sanctum')
        ->postJson('/api/v1/projects', $payload);

    $response->assertStatus(201)
        ->assertJson([
            'data' => [
                'name' => 'Customer Portal',
                'slug' => 'customer-portal',
            ],
        ]);

    $this->assertDatabaseHas('projects', [
        'name' => 'Customer Portal',
        'user_id' => $user->id,
    ]);
});

Notice the call to postJson() instead of post(). This sets the Accept: application/json and Content-Type: application/json request headers automatically. If you use standard post(), Laravel's router won't return JSON validation errors on failure; instead, it redirects, which causes your API test to fail with an unexpected 302 status code instead of a 422.

Testing Validation Errors and JSON Schemas

JSON validation failure tests need to be exact. When a frontend client posts malformed data, Laravel's FormRequest catches it and emits a 422 Unprocessable Content payload.

Instead of manually parsing array keys in response bodies, use Pest's assertJsonValidationErrors method combined with structural assertions. Here's a real example testing validation rules including unique constraints and required array fields:

<?php

use App\Models\User;
use App\Models\Project;

test('validates project creation payload', function () {
    $user = User::factory()->create();
    Project::factory()->create(['slug' => 'taken-slug']);

    $response = $this->actingAs($user, 'sanctum')
        ->postJson('/api/v1/projects', [
            'name' => '',
            'slug' => 'taken-slug',
            'tags' => 'not-an-array',
        ]);

    $response->assertStatus(422)
        ->assertJsonValidationErrors(['name', 'slug', 'tags'])
        ->assertJsonPath('errors.slug.0', 'The slug has already been taken.');
});

test('returns exact API JSON structure for project details', function () {
    $user = User::factory()->create();
    $project = Project::factory()->create(['user_id' => $user->id]);

    $this->actingAs($user, 'sanctum')
        ->getJson("/api/v1/projects/{$project->id}")
        ->assertStatus(200)
        ->assertJsonStructure([
            'data' => [
                'id',
                'name',
                'slug',
                'created_at',
                'updated_at',
            ],
        ]);
});

assertJsonPath() extracts exact nested properties from your JSON response using dot notation. When asserting response structures, assertJsonStructure() verifies that keys exist without forcing you to hardcode volatile values like auto-incrementing primary keys or dynamic timestamps.

Deduplicating Edge Cases with Datasets

Testing boundary rules often leads to copy-paste bloat. If you have five different invalid email formats for a registration endpoint, writing five separate test functions is noisy. Pest's dataset feature solves this cleanly.

<?php

test('rejects invalid email formats on user registration', function (string $email) {
    $this->postJson('/api/v1/register', [
        'name' => 'Alex Rivera',
        'email' => $email,
        'password' => 'SecurePassword123!',
    ])
    ->assertStatus(422)
    ->assertJsonValidationErrors(['email']);
})->with([
    'plain text' => 'not-an-email',
    'missing domain' => 'alex@',
    'missing user' => '@domain.com',
    'double @' => 'alex@@domain.com',
    'spaces' => 'alex rivera@domain.com',
]);

This runs five distinct test iterations under the hood. If 'missing user' fails, Pest highlights that specific label in your console output without crashing the rest of your suite.

Database Assertions and State Mutators

Asserting JSON responses isn't enough when an API endpoint produces side effects. You must assert database records, soft deletes, or modified table rows. In Laravel 12, Eloquent model assertions in Pest are clear and explicit.

Suppose your DELETE endpoint soft-deletes a project record. Here is how you assert the database state after calling DELETE /api/v1/projects/{id}:

<?php

use App\Models\User;
use App\Models\Project;

test('deleting a project soft deletes record and updates state', function () {
    $user = User::factory()->create();
    $project = Project::factory()->create(['user_id' => $user->id]);

    $this->actingAs($user, 'sanctum')
        ->deleteJson("/api/v1/projects/{$project->id}")
        ->assertStatus(204);

    $this->assertSoftDeleted('projects', [
        'id' => $project->id,
    ]);

    $this->assertDatabaseCount('projects', 1);
});

Pay attention to assertSoftDeleted(). If you use standard assertDatabaseMissing() on a soft-deletable model, your test will fail because the row still exists in the database table with a populated deleted_at timestamp. That gotcha trips up developer teams frequently when migrating legacy controllers to soft deletes.

Execution Speed and CI Performance

Slow tests destroy developer momentum. A suite of 100 API feature tests spinning up database transactions sequentially can easily take 12 to 15 seconds. On GitHub Actions CI pipelines, that overhead compounds quickly.

Run Pest in parallel mode locally and in your CI scripts:

vendor/bin/pest --parallel --processes=4

On a test suite of 85 JSON API endpoints running against an in-memory SQLite database, switching to 4 parallel processes dropped total execution time from 11.8s down to 2.3s. If you're using real PostgreSQL or MySQL databases in CI, ensure your configuration handles dynamic database creation per worker cleanly.

    Latest Posts

    View All

    Testing Laravel APIs with Pest: A Practical Guide

    Testing Laravel APIs with Pest: A Practical Guide

    Detecting and Fixing N+1 Queries in Laravel 12

    Detecting and Fixing N+1 Queries in Laravel 12

    Model Caching Patterns in Laravel 12 for Production

    Model Caching Patterns in Laravel 12 for Production

    Ensuring Secure URLs in Laravel Applications

    Ensuring Secure URLs in Laravel Applications

    Simple Feature Flags in Laravel with Laravel Toggle

    Simple Feature Flags in Laravel with Laravel Toggle

    Laravel WhatsApp: A New Package That Combines the Cloud API and whatsapp-web.js in One Library

    Laravel WhatsApp: A New Package That Combines the Cloud API and whatsapp-web.js in One Library

    Laravel diffForHumans() Guide: Display Human-Readable Time Like a Pro

    Laravel diffForHumans() Guide: Display Human-Readable Time Like a Pro

    Handling Large Datasets with Pagination and Cursors in Laravel MongoDB: Offset vs Cursor Pagination

    Handling Large Datasets with Pagination and Cursors in Laravel MongoDB: Offset vs Cursor Pagination

    A Complete Guide: Detecting and Fixing Race Conditions in Laravel Applications

    A Complete Guide: Detecting and Fixing Race Conditions in Laravel Applications

    PestPHP Intellisense in Laravel VS Code Extension v1.7.0

    PestPHP Intellisense in Laravel VS Code Extension v1.7.0