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.phpBasic 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')4. Group Related Tests
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);
});Related Documentation
- Testing Overview - General testing concepts
- Unit Testing - Unit testing patterns
- Feature Testing - Feature testing guide
- API Design - API design standards
- Authentication - API authentication
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