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:installIn 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=4On 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.










