Skip to content

API Testing

Overview

API testing ensures that your RESTful endpoints work correctly, return proper responses, handle errors gracefully, and maintain security. API tests verify the contract between your backend and frontend/external consumers.


Why API Testing?

API tests provide several benefits:

  • Contract verification - Ensures API responses match expected structure
  • Integration testing - Tests how components work together
  • Security validation - Verifies authentication and authorization
  • Error handling - Confirms proper error responses
  • Documentation - Tests serve as living API documentation
  • Regression prevention - Catches breaking changes

Test Structure

Location

API tests are typically placed in tests/Feature/Api/ organized by version:

tests/Feature/Api/
├── V1/
│   ├── Auth/
│   │   ├── LoginTest.php
│   │   ├── RegisterTest.php
│   │   └── LogoutTest.php
│   ├── Users/
│   │   └── UserTest.php
│   ├── Posts/
│   │   └── PostTest.php
│   └── Orders/
│       └── OrderTest.php
└── V2/
    └── Users/
        └── UserTest.php

Basic API Test

php
<?php

use App\Models\User;

test('gets list of users', function () {
    User::factory()->count(3)->create();

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

    $response->assertStatus(200)
        ->assertJsonCount(3, 'data');
});

Testing HTTP Methods

GET Requests

php
<?php

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

test('gets single resource', function () {
    $post = Post::factory()->create([
        'title' => 'Test Post',
        'content' => 'Test content',
    ]);

    $response = $this->getJson("/api/v1/posts/{$post->id}");

    $response->assertStatus(200)
        ->assertJson([
            'data' => [
                'id' => $post->id,
                'title' => 'Test Post',
                'content' => 'Test content',
            ]
        ]);
});

test('gets collection of resources', function () {
    Post::factory()->count(5)->create();

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

    $response->assertStatus(200)
        ->assertJsonCount(5, 'data')
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'title', 'content', 'created_at']
            ]
        ]);
});

test('returns 404 when resource not found', function () {
    $response = $this->getJson('/api/v1/posts/999');

    $response->assertStatus(404)
        ->assertJson([
            'message' => 'Post not found'
        ]);
});

POST Requests

php
<?php

use App\Models\User;

test('creates new resource', function () {
    $user = User::factory()->create();

    $data = [
        'title' => 'New Post',
        'content' => 'Post content',
        'status' => 'published',
    ];

    $response = $this->actingAs($user)
        ->postJson('/api/v1/posts', $data);

    $response->assertStatus(201)
        ->assertJson([
            'data' => [
                'title' => 'New Post',
                'content' => 'Post content',
            ]
        ]);

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

test('validates required fields on create', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)
        ->postJson('/api/v1/posts', []);

    $response->assertStatus(422)
        ->assertJsonValidationErrors(['title', 'content']);
});

PUT/PATCH Requests

php
<?php

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

test('updates resource with PUT', function () {
    $user = User::factory()->create();
    $post = Post::factory()->create(['user_id' => $user->id]);

    $data = [
        'title' => 'Updated Title',
        'content' => 'Updated content',
        'status' => 'published',
    ];

    $response = $this->actingAs($user)
        ->putJson("/api/v1/posts/{$post->id}", $data);

    $response->assertStatus(200)
        ->assertJson([
            'data' => [
                'id' => $post->id,
                'title' => 'Updated Title',
            ]
        ]);

    expect($post->fresh()->title)->toBe('Updated Title');
});

test('partially updates resource with PATCH', function () {
    $user = User::factory()->create();
    $post = Post::factory()->create([
        'user_id' => $user->id,
        'title' => 'Original Title',
    ]);

    $response = $this->actingAs($user)
        ->patchJson("/api/v1/posts/{$post->id}", [
            'title' => 'Patched Title',
        ]);

    $response->assertStatus(200);
    expect($post->fresh()->title)->toBe('Patched Title');
});

DELETE Requests

php
<?php

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

test('deletes resource', function () {
    $user = User::factory()->create();
    $post = Post::factory()->create(['user_id' => $user->id]);

    $response = $this->actingAs($user)
        ->deleteJson("/api/v1/posts/{$post->id}");

    $response->assertStatus(204);

    $this->assertDatabaseMissing('posts', [
        'id' => $post->id,
        'deleted_at' => null,
    ]);
});

test('soft deletes resource', function () {
    $user = User::factory()->create();
    $post = Post::factory()->create(['user_id' => $user->id]);

    $response = $this->actingAs($user)
        ->deleteJson("/api/v1/posts/{$post->id}");

    $response->assertStatus(204);

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

Testing Authentication

Unauthenticated Requests

php
<?php

test('guest cannot access protected endpoint', function () {
    $response = $this->getJson('/api/v1/profile');

    $response->assertStatus(401)
        ->assertJson([
            'message' => 'Unauthenticated.'
        ]);
});

test('requires authentication for POST requests', function () {
    $response = $this->postJson('/api/v1/posts', [
        'title' => 'Test',
    ]);

    $response->assertStatus(401);
});

Authentication with Sanctum

php
<?php

use App\Models\User;
use Laravel\Sanctum\Sanctum;

test('authenticated user can access protected endpoint', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user, 'sanctum')
        ->getJson('/api/v1/profile');

    $response->assertStatus(200)
        ->assertJson([
            'data' => [
                'id' => $user->id,
                'email' => $user->email,
            ]
        ]);
});

test('authenticates with token', function () {
    $user = User::factory()->create();
    $token = $user->createToken('test-token')->plainTextToken;

    $response = $this->withHeader('Authorization', "Bearer {$token}")
        ->getJson('/api/v1/profile');

    $response->assertStatus(200);
});

test('rejects invalid token', function () {
    $response = $this->withHeader('Authorization', 'Bearer invalid-token')
        ->getJson('/api/v1/profile');

    $response->assertStatus(401);
});

Login/Logout Tests

php
<?php

use App\Models\User;

test('user can login with valid credentials', function () {
    $user = User::factory()->create([
        'email' => 'john@example.com',
        'password' => bcrypt('password123'),
    ]);

    $response = $this->postJson('/api/v1/login', [
        'email' => 'john@example.com',
        'password' => 'password123',
    ]);

    $response->assertStatus(200)
        ->assertJsonStructure([
            'token',
            'user' => ['id', 'name', 'email'],
        ]);
});

test('login fails with invalid credentials', function () {
    $response = $this->postJson('/api/v1/login', [
        'email' => 'wrong@example.com',
        'password' => 'wrongpassword',
    ]);

    $response->assertStatus(401)
        ->assertJson([
            'message' => 'Invalid credentials'
        ]);
});

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

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

    $response->assertStatus(200);
});

Testing Authorization

Resource Ownership

php
<?php

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

test('user can only update their own posts', function () {
    $user = User::factory()->create();
    $otherUser = User::factory()->create();
    $post = Post::factory()->create(['user_id' => $otherUser->id]);

    $response = $this->actingAs($user)
        ->putJson("/api/v1/posts/{$post->id}", [
            'title' => 'Hacked',
        ]);

    $response->assertStatus(403)
        ->assertJson([
            'message' => 'This action is unauthorized.'
        ]);
});

test('user can only delete their own posts', function () {
    $user = User::factory()->create();
    $post = Post::factory()->create(); // Different owner

    $response = $this->actingAs($user)
        ->deleteJson("/api/v1/posts/{$post->id}");

    $response->assertStatus(403);
    expect(Post::find($post->id))->not->toBeNull();
});

Role-Based Authorization

php
<?php

use App\Models\User;

test('only admins can access admin endpoints', function () {
    $user = User::factory()->create(['role' => 'user']);

    $response = $this->actingAs($user)
        ->getJson('/api/v1/admin/users');

    $response->assertStatus(403);
});

test('admin can access admin endpoints', function () {
    $admin = User::factory()->create(['role' => 'admin']);

    $response = $this->actingAs($admin)
        ->getJson('/api/v1/admin/users');

    $response->assertStatus(200);
});

test('moderators can access moderate endpoint', function (string $role, int $expectedStatus) {
    $user = User::factory()->create(['role' => $role]);

    $response = $this->actingAs($user)
        ->getJson('/api/v1/moderate/posts');

    $response->assertStatus($expectedStatus);
})->with([
    ['admin', 200],
    ['moderator', 200],
    ['user', 403],
    ['guest', 403],
]);

Testing JSON Responses

JSON Structure

php
<?php

use App\Models\Post;

test('response has correct JSON structure', function () {
    $post = Post::factory()->create();

    $response = $this->getJson("/api/v1/posts/{$post->id}");

    $response->assertStatus(200)
        ->assertJsonStructure([
            'data' => [
                'id',
                'title',
                'content',
                'status',
                'author' => [
                    'id',
                    'name',
                    'email',
                ],
                'created_at',
                'updated_at',
            ]
        ]);
});

test('collection has correct structure', function () {
    Post::factory()->count(3)->create();

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

    $response->assertJsonStructure([
        'data' => [
            '*' => [
                'id',
                'title',
                'content',
            ]
        ],
        'meta' => [
            'current_page',
            'total',
            'per_page',
        ],
        'links' => [
            'first',
            'last',
            'prev',
            'next',
        ]
    ]);
});

Exact JSON Matching

php
<?php

use App\Models\User;

test('response matches exact JSON', function () {
    $user = User::factory()->create([
        'name' => 'John Doe',
        'email' => 'john@example.com',
    ]);

    $response = $this->getJson("/api/v1/users/{$user->id}");

    $response->assertExactJson([
        'data' => [
            'id' => $user->id,
            'name' => 'John Doe',
            'email' => 'john@example.com',
            'created_at' => $user->created_at->toISOString(),
        ]
    ]);
});

Partial JSON Matching

php
<?php

use App\Models\Post;

test('response contains partial JSON', function () {
    $post = Post::factory()->create([
        'title' => 'Test Post',
    ]);

    $response = $this->getJson("/api/v1/posts/{$post->id}");

    $response->assertJson([
        'data' => [
            'title' => 'Test Post',
        ]
    ]);
    // Other fields are ignored
});

test('response has JSON path', function () {
    $response = $this->getJson('/api/v1/posts');

    $response->assertJsonPath('data.0.title', 'First Post Title');
});

JSON Count

php
<?php

use App\Models\Post;

test('returns correct number of items', function () {
    Post::factory()->count(10)->create();

    $response = $this->getJson('/api/v1/posts?per_page=10');

    $response->assertJsonCount(10, 'data');
});

test('filters return correct count', function () {
    Post::factory()->count(5)->create(['status' => 'published']);
    Post::factory()->count(3)->create(['status' => 'draft']);

    $response = $this->getJson('/api/v1/posts?status=published');

    $response->assertJsonCount(5, 'data');
});

Testing Validation

Required Fields

php
<?php

use App\Models\User;

test('validates required fields', function (array $data, array $errors) {
    $user = User::factory()->create();

    $response = $this->actingAs($user)
        ->postJson('/api/v1/posts', $data);

    $response->assertStatus(422)
        ->assertJsonValidationErrors($errors);
})->with([
    'missing all fields' => [[], ['title', 'content']],
    'missing title' => [['content' => 'test'], ['title']],
    'missing content' => [['title' => 'test'], ['content']],
]);

Field Format Validation

php
<?php

use App\Models\User;

test('validates email format', function () {
    $response = $this->postJson('/api/v1/register', [
        'name' => 'John',
        'email' => 'invalid-email',
        'password' => 'password123',
    ]);

    $response->assertStatus(422)
        ->assertJsonValidationErrors(['email'])
        ->assertJson([
            'errors' => [
                'email' => ['The email field must be a valid email address.']
            ]
        ]);
});

test('validates unique email', function () {
    User::factory()->create(['email' => 'existing@example.com']);

    $response = $this->postJson('/api/v1/register', [
        'name' => 'John',
        'email' => 'existing@example.com',
        'password' => 'password123',
    ]);

    $response->assertStatus(422)
        ->assertJsonValidationErrors(['email']);
});

test('validates password length', function () {
    $response = $this->postJson('/api/v1/register', [
        'name' => 'John',
        'email' => 'john@example.com',
        'password' => '123', // Too short
    ]);

    $response->assertStatus(422)
        ->assertJsonValidationErrors(['password']);
});

Custom Validation Rules

php
<?php

use App\Models\User;

test('validates custom business rules', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)
        ->postJson('/api/v1/posts', [
            'title' => 'a', // Too short
            'content' => 'Valid content',
        ]);

    $response->assertStatus(422)
        ->assertJsonValidationErrors(['title'])
        ->assertJson([
            'errors' => [
                'title' => ['The title field must be at least 3 characters.']
            ]
        ]);
});

test('validates file upload', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)
        ->postJson('/api/v1/posts', [
            'title' => 'Test',
            'content' => 'Content',
            'image' => 'not-a-file',
        ]);

    $response->assertStatus(422)
        ->assertJsonValidationErrors(['image']);
});

Testing Pagination

Basic Pagination

php
<?php

use App\Models\Post;

test('returns paginated results', function () {
    Post::factory()->count(25)->create();

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

    $response->assertStatus(200)
        ->assertJsonStructure([
            'data',
            'links' => ['first', 'last', 'prev', 'next'],
            'meta' => [
                'current_page',
                'from',
                'last_page',
                'per_page',
                'to',
                'total',
            ],
        ]);

    $response->assertJsonPath('meta.total', 25);
    $response->assertJsonPath('meta.per_page', 15);
    expect($response->json('data'))->toHaveCount(15);
});

test('navigates to specific page', function () {
    Post::factory()->count(30)->create();

    $response = $this->getJson('/api/v1/posts?page=2');

    $response->assertStatus(200)
        ->assertJsonPath('meta.current_page', 2);
});

Custom Per Page

php
<?php

use App\Models\Post;

test('respects per_page parameter', function () {
    Post::factory()->count(50)->create();

    $response = $this->getJson('/api/v1/posts?per_page=25');

    $response->assertStatus(200)
        ->assertJsonPath('meta.per_page', 25)
        ->assertJsonCount(25, 'data');
});

test('enforces maximum per_page limit', function () {
    Post::factory()->count(200)->create();

    $response = $this->getJson('/api/v1/posts?per_page=500');

    $response->assertStatus(200)
        ->assertJsonPath('meta.per_page', 100); // Max limit
});

Cursor Pagination

php
<?php

use App\Models\Post;

test('supports cursor pagination', function () {
    Post::factory()->count(20)->create();

    $response = $this->getJson('/api/v1/posts?cursor=true');

    $response->assertStatus(200)
        ->assertJsonStructure([
            'data',
            'meta' => ['next_cursor', 'prev_cursor'],
        ]);

    $nextCursor = $response->json('meta.next_cursor');
    expect($nextCursor)->not->toBeNull();
});

Testing Filtering and Sorting

Filtering

php
<?php

use App\Models\Post;

test('filters by status', function () {
    Post::factory()->count(5)->create(['status' => 'published']);
    Post::factory()->count(3)->create(['status' => 'draft']);

    $response = $this->getJson('/api/v1/posts?status=published');

    $response->assertStatus(200)
        ->assertJsonCount(5, 'data');

    $statuses = collect($response->json('data'))->pluck('status')->unique();
    expect($statuses->toArray())->toBe(['published']);
});

test('filters by multiple parameters', function () {
    $user = User::factory()->create();
    Post::factory()->count(3)->create([
        'user_id' => $user->id,
        'status' => 'published',
    ]);
    Post::factory()->count(2)->create(['status' => 'published']);

    $response = $this->getJson("/api/v1/posts?status=published&user_id={$user->id}");

    $response->assertJsonCount(3, 'data');
});

test('filters by date range', function () {
    Post::factory()->create(['created_at' => now()->subDays(10)]);
    Post::factory()->create(['created_at' => now()->subDays(5)]);
    Post::factory()->create(['created_at' => now()->subDay()]);

    $from = now()->subDays(7)->toDateString();
    $to = now()->toDateString();

    $response = $this->getJson("/api/v1/posts?from={$from}&to={$to}");

    $response->assertJsonCount(2, 'data');
});

Sorting

php
<?php

use App\Models\Post;

test('sorts by field ascending', function () {
    Post::factory()->create(['title' => 'Zebra']);
    Post::factory()->create(['title' => 'Alpha']);
    Post::factory()->create(['title' => 'Beta']);

    $response = $this->getJson('/api/v1/posts?sort=title&order=asc');

    $titles = collect($response->json('data'))->pluck('title')->toArray();
    expect($titles)->toBe(['Alpha', 'Beta', 'Zebra']);
});

test('sorts by field descending', function () {
    Post::factory()->create(['created_at' => now()->subDays(3)]);
    Post::factory()->create(['created_at' => now()->subDays(1)]);
    Post::factory()->create(['created_at' => now()]);

    $response = $this->getJson('/api/v1/posts?sort=created_at&order=desc');

    $dates = collect($response->json('data'))->pluck('created_at');
    expect($dates->first())->toBeGreaterThan($dates->last());
});

Searching

php
<?php

use App\Models\Post;

test('searches by keyword', function () {
    Post::factory()->create(['title' => 'Laravel Tutorial']);
    Post::factory()->create(['title' => 'PHP Best Practices']);
    Post::factory()->create(['content' => 'Laravel tips and tricks']);

    $response = $this->getJson('/api/v1/posts?search=Laravel');

    $response->assertJsonCount(2, 'data');
});

Testing Rate Limiting

php
<?php

use App\Models\User;

test('rate limits API requests', function () {
    $user = User::factory()->create();

    // Make requests up to the limit
    for ($i = 0; $i < 60; $i++) {
        $this->actingAs($user)->getJson('/api/v1/posts');
    }

    // Next request should be rate limited
    $response = $this->actingAs($user)->getJson('/api/v1/posts');

    $response->assertStatus(429)
        ->assertJson([
            'message' => 'Too Many Requests'
        ]);
});

test('rate limit headers are present', function () {
    $user = User::factory()->create();

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

    $response->assertHeader('X-RateLimit-Limit')
        ->assertHeader('X-RateLimit-Remaining');
});

Testing File Uploads

php
<?php

use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

test('uploads file successfully', function () {
    Storage::fake('public');
    $user = User::factory()->create();

    $file = UploadedFile::fake()->image('photo.jpg', 600, 600);

    $response = $this->actingAs($user)
        ->postJson('/api/v1/upload', [
            'file' => $file,
        ]);

    $response->assertStatus(201)
        ->assertJsonStructure([
            'data' => ['url', 'filename', 'size']
        ]);

    Storage::disk('public')->assertExists('uploads/' . $file->hashName());
});

test('validates file type', function () {
    $user = User::factory()->create();

    $file = UploadedFile::fake()->create('document.pdf', 1000);

    $response = $this->actingAs($user)
        ->postJson('/api/v1/upload', [
            'file' => $file,
        ]);

    $response->assertStatus(422)
        ->assertJsonValidationErrors(['file']);
});

test('validates file size', function () {
    $user = User::factory()->create();

    $file = UploadedFile::fake()->image('large.jpg')->size(5000); // 5MB

    $response = $this->actingAs($user)
        ->postJson('/api/v1/upload', [
            'file' => $file,
        ]);

    $response->assertStatus(422)
        ->assertJsonValidationErrors(['file']);
});

Testing API Versioning

php
<?php

use App\Models\User;

test('v1 endpoint returns v1 response format', function () {
    $user = User::factory()->create();

    $response = $this->getJson("/api/v1/users/{$user->id}");

    $response->assertStatus(200)
        ->assertJsonStructure([
            'data' => ['id', 'name', 'email']
        ]);
});

test('v2 endpoint returns enhanced response format', function () {
    $user = User::factory()->create();

    $response = $this->getJson("/api/v2/users/{$user->id}");

    $response->assertStatus(200)
        ->assertJsonStructure([
            'data' => [
                'id',
                'name',
                'email',
                'profile' => ['avatar', 'bio'],
                'metadata' => ['created_at', 'updated_at'],
            ]
        ]);
});

test('deprecated endpoint returns warning header', function () {
    $response = $this->getJson('/api/v1/deprecated-endpoint');

    $response->assertHeader('X-API-Deprecated', 'true')
        ->assertHeader('X-API-Sunset-Date');
});

Testing Error Responses

Validation Errors

php
<?php

test('returns validation error with 422 status', function () {
    $response = $this->postJson('/api/v1/posts', []);

    $response->assertStatus(422)
        ->assertJsonStructure([
            'message',
            'errors' => [
                'title',
                'content',
            ]
        ]);
});

Not Found Errors

php
<?php

test('returns 404 for non-existent resource', function () {
    $response = $this->getJson('/api/v1/posts/99999');

    $response->assertStatus(404)
        ->assertJson([
            'message' => 'Post not found'
        ]);
});

Server Errors

php
<?php

test('handles server errors gracefully', function () {
    // Simulate a server error
    $this->mock(SomeService::class)
        ->shouldReceive('process')
        ->andThrow(new \Exception('Server error'));

    $response = $this->postJson('/api/v1/process');

    $response->assertStatus(500)
        ->assertJson([
            'message' => 'Server Error'
        ]);
});

Testing Relationships

php
<?php

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

test('includes related data', function () {
    $user = User::factory()->create();
    $post = Post::factory()->create(['user_id' => $user->id]);

    $response = $this->getJson("/api/v1/posts/{$post->id}?include=author");

    $response->assertStatus(200)
        ->assertJson([
            'data' => [
                'id' => $post->id,
                'author' => [
                    'id' => $user->id,
                    'name' => $user->name,
                ]
            ]
        ]);
});

test('loads nested relationships', function () {
    $user = User::factory()->create();
    $post = Post::factory()->create(['user_id' => $user->id]);
    $post->comments()->create(['body' => 'Test comment']);

    $response = $this->getJson("/api/v1/posts/{$post->id}?include=author,comments");

    $response->assertStatus(200)
        ->assertJsonStructure([
            'data' => [
                'author' => ['id', 'name'],
                'comments' => [
                    '*' => ['id', 'body']
                ]
            ]
        ]);
});

Best Practices

1. Test Real Scenarios

php
<?php

// Good - Tests real user flow
test('user can create, update, and delete post', function () {
    $user = User::factory()->create();

    // Create
    $createResponse = $this->actingAs($user)
        ->postJson('/api/v1/posts', [
            'title' => 'My Post',
            'content' => 'Content',
        ]);

    $postId = $createResponse->json('data.id');

    // Update
    $updateResponse = $this->actingAs($user)
        ->putJson("/api/v1/posts/{$postId}", [
            'title' => 'Updated Post',
        ]);

    $updateResponse->assertStatus(200);

    // Delete
    $deleteResponse = $this->actingAs($user)
        ->deleteJson("/api/v1/posts/{$postId}");

    $deleteResponse->assertStatus(204);
});

2. Use Descriptive Test Names

php
<?php

// Good
test('authenticated user can update their own post')
test('returns 403 when user tries to update another users post')
test('validates title is required when creating post')

// Bad
test('update post')
test('test authorization')
test('validation')

3. Test Edge Cases

php
<?php

test('handles empty list')
test('handles large payloads')
test('handles special characters in input')
test('handles concurrent requests')
test('handles missing optional parameters')
php
<?php

describe('Post API', function () {
    describe('GET /api/v1/posts', function () {
        test('returns list of posts')
        test('returns paginated results')
        test('filters by status')
    });

    describe('POST /api/v1/posts', function () {
        test('creates post with valid data')
        test('validates required fields')
        test('requires authentication')
    });
});

5. Clean Test Data

php
<?php

use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

// Database is automatically reset between tests
test('creates user', function () {
    // Fresh database for each test
});

Testing Performance

php
<?php

test('responds within acceptable time', function () {
    $start = microtime(true);

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

    $duration = microtime(true) - $start;

    $response->assertStatus(200);
    expect($duration)->toBeLessThan(1.0); // 1 second max
});

test('handles large result sets efficiently', function () {
    Post::factory()->count(1000)->create();

    $start = microtime(true);
    $response = $this->getJson('/api/v1/posts');
    $duration = microtime(true) - $start;

    $response->assertStatus(200);
    expect($duration)->toBeLessThan(2.0);
});


Quick Reference

Common Assertions

php
// Status codes
$response->assertStatus(200)
$response->assertOk()
$response->assertCreated()
$response->assertNoContent()
$response->assertNotFound()
$response->assertForbidden()
$response->assertUnauthorized()
$response->assertUnprocessable()

// JSON
$response->assertJson(['key' => 'value'])
$response->assertJsonStructure(['data', 'meta'])
$response->assertJsonCount(5, 'data')
$response->assertJsonPath('data.0.id', 1)
$response->assertJsonFragment(['status' => 'active'])
$response->assertJsonMissing(['deleted' => true])

// Validation
$response->assertJsonValidationErrors(['email', 'password'])
$response->assertJsonValidationErrorFor('email')

// Database
$this->assertDatabaseHas('posts', ['title' => 'Test'])
$this->assertDatabaseMissing('posts', ['id' => 1])
$this->assertDatabaseCount('posts', 5)

Common Patterns

bash
# Run API tests
php artisan test tests/Feature/Api

# Run specific version tests
php artisan test tests/Feature/Api/V1

# Run with coverage
php artisan test --coverage tests/Feature/Api

CPR - Clinical Patient Records