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

Testing Laravel APIs with Pest: A Practical Guide

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

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.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3

    Laravel LLM Integration: Queues and Real-Time Frontend UI

    Laravel LLM Integration: Queues and Real-Time Frontend UI