API Testing Fundamentals
Testing is critical for building reliable, maintainable APIs. Comprehensive testing ensures your endpoints behave correctly, handle edge cases gracefully, and remain stable as your codebase evolves. In this lesson, we'll explore API testing using PHPUnit, Laravel's testing features, JSON assertions, test factories, and database testing strategies.
Why API Testing Matters
Untested APIs lead to production bugs, security vulnerabilities, and poor developer experience:
- Reliability: Automated tests catch bugs before they reach production
- Confidence: Tests enable safe refactoring and feature development
- Documentation: Tests serve as executable documentation for expected behavior
- Regression Prevention: Tests prevent old bugs from reappearing
- CI/CD Integration: Automated testing is essential for continuous deployment
Industry Standard: Companies like Google, Facebook, and Amazon maintain test coverage above 80% for their APIs. Laravel itself has over 6,000 tests ensuring framework stability.
PHPUnit for Laravel APIs
Laravel includes PHPUnit out of the box with powerful testing utilities specifically designed for API testing:
Installation and Setup
# PHPUnit is included with Laravel
# Run tests
php artisan test
# Or use PHPUnit directly
./vendor/bin/phpunit
# Run specific test class
php artisan test --filter ProductApiTest
# Run tests with coverage
php artisan test --coverage
</div>
Basic Test Structure
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use App\Models\User;
use App\Models\Product;
class ProductApiTest extends TestCase
{
use RefreshDatabase; // Reset database after each test
/**
* Test fetching all products
*/
public function test_can_fetch_all_products()
{
// Arrange: Create test data
Product::factory()->count(5)->create();
// Act: Make API request
$response = $this->getJson('/api/products');
// Assert: Verify response
$response->assertStatus(200)
->assertJsonCount(5, 'data')
->assertJsonStructure([
'data' => [
'*' => [
'id',
'name',
'price',
'description',
'created_at',
'updated_at'
]
]
]);
}
/**
* Test fetching single product
*/
public function test_can_fetch_single_product()
{
$product = Product::factory()->create([
'name' => 'Test Product',
'price' => 99.99
]);
$response = $this->getJson("/api/products/{$product->id}");
$response->assertStatus(200)
->assertJson([
'data' => [
'id' => $product->id,
'name' => 'Test Product',
'price' => 99.99
]
]);
}
/**
* Test 404 for non-existent product
*/
public function test_returns_404_for_non_existent_product()
{
$response = $this->getJson('/api/products/99999');
$response->assertStatus(404)
->assertJson([
'message' => 'Product not found'
]);
}
}
</div>
Testing POST Requests and Validation
Testing create operations ensures validation rules work correctly and data is stored properly:
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\User;
use App\Models\Category;
class ProductCreationTest extends TestCase
{
use RefreshDatabase;
/**
* Test creating product with valid data
*/
public function test_can_create_product_with_valid_data()
{
$user = User::factory()->create();
$category = Category::factory()->create();
$productData = [
'name' => 'New Product',
'description' => 'A great product',
'price' => 149.99,
'category_id' => $category->id,
'stock' => 100
];
$response = $this->actingAs($user)
->postJson('/api/products', $productData);
$response->assertStatus(201)
->assertJson([
'data' => [
'name' => 'New Product',
'price' => 149.99
]
]);
// Verify database record exists
$this->assertDatabaseHas('products', [
'name' => 'New Product',
'price' => 149.99
]);
}
/**
* Test validation errors
*/
public function test_validation_fails_with_invalid_data()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/products', [
'name' => '', // Required field empty
'price' => -10, // Negative price
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['name', 'price', 'category_id']);
}
/**
* Test unauthorized access
*/
public function test_requires_authentication()
{
$response = $this->postJson('/api/products', [
'name' => 'Product'
]);
$response->assertStatus(401);
}
/**
* Test price validation
*/
public function test_price_must_be_positive()
{
$user = User::factory()->create();
$category = Category::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/products', [
'name' => 'Product',
'price' => -50,
'category_id' => $category->id
]);
$response->assertStatus(422)
->assertJsonValidationErrorFor('price');
}
}
</div>
JSON Assertions
Laravel provides powerful JSON assertion methods for testing API responses:
<?php
// Assert exact JSON match
$response->assertExactJson([
'data' => [
'id' => 1,
'name' => 'Product'
]
]);
// Assert JSON structure
$response->assertJsonStructure([
'data' => [
'id',
'name',
'category' => [
'id',
'name'
]
],
'meta' => [
'current_page',
'total'
]
]);
// Assert JSON path value
$response->assertJsonPath('data.name', 'Product Name');
$response->assertJsonPath('data.price', 99.99);
// Assert JSON fragment exists
$response->assertJsonFragment([
'name' => 'Product Name',
'price' => 99.99
]);
// Assert JSON missing
$response->assertJsonMissing([
'secret_field' => 'value'
]);
// Assert JSON count
$response->assertJsonCount(10, 'data');
$response->assertJsonCount(5, 'data.reviews');
// Assert validation errors
$response->assertJsonValidationErrors(['name', 'email']);
$response->assertJsonValidationErrorFor('email');
// Assert JSON is array/object
$response->assertJsonIsArray('data');
$response->assertJsonIsObject('meta');
</div>
Testing with Factories
Model factories generate realistic test data efficiently:
<?php
namespace Database\Factories;
use App\Models\Product;
use App\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;
class ProductFactory extends Factory
{
protected $model = Product::class;
public function definition()
{
return [
'name' => $this->faker->words(3, true),
'description' => $this->faker->paragraph(),
'price' => $this->faker->randomFloat(2, 10, 1000),
'category_id' => Category::factory(),
'stock' => $this->faker->numberBetween(0, 500),
'is_featured' => $this->faker->boolean(20), // 20% chance
];
}
/**
* Factory state for expensive products
*/
public function expensive()
{
return $this->state(function (array $attributes) {
return [
'price' => $this->faker->randomFloat(2, 500, 5000),
];
});
}
/**
* Factory state for out-of-stock products
*/
public function outOfStock()
{
return $this->state(function (array $attributes) {
return [
'stock' => 0,
];
});
}
/**
* Factory state for featured products
*/
public function featured()
{
return $this->state(function (array $attributes) {
return [
'is_featured' => true,
];
});
}
}
</div>
Using factories in tests:
<?php
// Create single product
$product = Product::factory()->create();
// Create multiple products
$products = Product::factory()->count(10)->create();
// Create with custom attributes
$product = Product::factory()->create([
'name' => 'Specific Name',
'price' => 99.99
]);
// Use factory states
$expensiveProduct = Product::factory()->expensive()->create();
$featuredProducts = Product::factory()->featured()->count(5)->create();
$outOfStock = Product::factory()->outOfStock()->create();
// Chain multiple states
$product = Product::factory()
->expensive()
->featured()
->create();
// Create with relationships
$products = Product::factory()
->count(3)
->hasReviews(5) // Each product has 5 reviews
->create();
</div>
Database Testing Strategies
RefreshDatabase Trait
The most common approach - resets database after each test:
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ProductApiTest extends TestCase
{
use RefreshDatabase;
// Each test gets a fresh database
public function test_example()
{
// Database is empty at start
Product::factory()->create();
// After test, database is reset
}
}
</div>
DatabaseTransactions Trait
Faster than RefreshDatabase - wraps tests in transactions:
<?php
use Illuminate\Foundation\Testing\DatabaseTransactions;
class ProductApiTest extends TestCase
{
use DatabaseTransactions;
// Changes are rolled back after each test
// Faster than RefreshDatabase for large test suites
}
</div>
Database Assertions
<?php
// Assert record exists in database
$this->assertDatabaseHas('products', [
'name' => 'Product Name',
'price' => 99.99
]);
// Assert record missing from database
$this->assertDatabaseMissing('products', [
'name' => 'Deleted Product'
]);
// Assert record count
$this->assertDatabaseCount('products', 10);
// Assert soft deleted record exists
$this->assertSoftDeleted('products', [
'id' => $product->id
]);
// Assert model exists
$this->assertModelExists($product);
// Assert model missing (deleted)
$this->assertModelMissing($product);
</div>
Testing Authentication and Authorization
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\User;
use App\Models\Product;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
/**
* Test API requires authentication
*/
public function test_unauthenticated_users_cannot_create_products()
{
$response = $this->postJson('/api/products', [
'name' => 'Product'
]);
$response->assertStatus(401);
}
/**
* Test authenticated access with actingAs()
*/
public function test_authenticated_users_can_create_products()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/products', [
'name' => 'Product',
'price' => 50,
'category_id' => 1
]);
$response->assertStatus(201);
}
/**
* Test Bearer token authentication
*/
public function test_can_authenticate_with_bearer_token()
{
$user = User::factory()->create();
$token = $user->createToken('test-token')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $token,
])->getJson('/api/user');
$response->assertStatus(200)
->assertJson([
'data' => [
'id' => $user->id,
'email' => $user->email
]
]);
}
/**
* Test authorization policies
*/
public function test_users_can_only_update_own_products()
{
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$product = Product::factory()->create([
'user_id' => $owner->id
]);
// Other user tries to update
$response = $this->actingAs($otherUser)
->putJson("/api/products/{$product->id}", [
'name' => 'Updated Name'
]);
$response->assertStatus(403); // Forbidden
// Owner can update
$response = $this->actingAs($owner)
->putJson("/api/products/{$product->id}", [
'name' => 'Updated Name'
]);
$response->assertStatus(200);
}
}
</div>
Testing Pagination
<?php
public function test_products_are_paginated()
{
// Create 30 products
Product::factory()->count(30)->create();
// Request first page
$response = $this->getJson('/api/products?page=1&per_page=10');
$response->assertStatus(200)
->assertJsonCount(10, 'data')
->assertJsonStructure([
'data',
'links' => [
'first',
'last',
'prev',
'next'
],
'meta' => [
'current_page',
'from',
'last_page',
'per_page',
'to',
'total'
]
])
->assertJsonPath('meta.total', 30)
->assertJsonPath('meta.current_page', 1)
->assertJsonPath('meta.last_page', 3);
}
public function test_can_navigate_to_different_pages()
{
Product::factory()->count(25)->create();
$page1 = $this->getJson('/api/products?page=1&per_page=10');
$page2 = $this->getJson('/api/products?page=2&per_page=10');
// Verify different data on different pages
$this->assertNotEquals(
$page1->json('data'),
$page2->json('data')
);
}
</div>
Testing File Uploads
<?php
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
public function test_can_upload_product_image()
{
Storage::fake('public');
$user = User::factory()->create();
$product = Product::factory()->create();
$file = UploadedFile::fake()->image('product.jpg', 600, 400);
$response = $this->actingAs($user)
->postJson("/api/products/{$product->id}/image", [
'image' => $file
]);
$response->assertStatus(200);
// Assert file was stored
Storage::disk('public')->assertExists('products/' . $file->hashName());
}
public function test_image_upload_validates_file_type()
{
Storage::fake('public');
$user = User::factory()->create();
$product = Product::factory()->create();
$file = UploadedFile::fake()->create('document.pdf', 1000);
$response = $this->actingAs($user)
->postJson("/api/products/{$product->id}/image", [
'image' => $file
]);
$response->assertStatus(422)
->assertJsonValidationErrorFor('image');
}
</div>
Testing Rate Limiting
<?php
public function test_api_has_rate_limiting()
{
$user = User::factory()->create();
// Make 60 requests (assuming limit is 60 per minute)
for ($i = 0; $i < 60; $i++) {
$response = $this->actingAs($user)
->getJson('/api/products');
$response->assertStatus(200);
}
// 61st request should be rate limited
$response = $this->actingAs($user)
->getJson('/api/products');
$response->assertStatus(429) // Too Many Requests
->assertHeader('Retry-After');
}
</div>
Testing Error Handling
<?php
public function test_handles_server_errors_gracefully()
{
// Mock a service that throws exception
$this->mock(ProductService::class, function ($mock) {
$mock->shouldReceive('getProducts')
->andThrow(new \Exception('Database connection failed'));
});
$response = $this->getJson('/api/products');
$response->assertStatus(500)
->assertJson([
'message' => 'Server Error'
]);
}
public function test_validates_json_request_format()
{
$user = User::factory()->create();
// Send invalid JSON
$response = $this->actingAs($user)
->post('/api/products', [], [
'Content-Type' => 'application/json'
]);
$response->assertStatus(400); // Bad Request
}
</div>
Practice Exercise:
- Create a complete test suite for a Product API with at least 10 tests
- Test all CRUD operations (Create, Read, Update, Delete)
- Implement tests for validation rules on at least 5 fields
- Test authentication and authorization using actingAs()
- Create factory with 3 different states (featured, out of stock, expensive)
- Test pagination with multiple pages and verify meta data
- Add tests for filtering and searching functionality
- Test file upload validation (type, size, dimensions)
- Implement rate limiting tests
- Test error handling for 404, 422, and 500 responses
- Achieve at least 80% code coverage for your API controller
Testing Best Practices
- Follow AAA Pattern: Arrange (setup), Act (execute), Assert (verify)
- One Assertion Per Test: Test one behavior per test method
- Use Descriptive Names: Test names should explain what they test
- Test Edge Cases: Test boundaries, null values, empty arrays
- Use Factories: Generate realistic test data with factories
- Test Negative Cases: Test error conditions and validation failures
- Keep Tests Independent: Tests should not depend on each other
- Fast Tests: Use DatabaseTransactions for speed when possible
- Test Public API: Test the behavior, not implementation details
- Maintain Coverage: Aim for 80%+ coverage on critical code
CI/CD Integration: Run your test suite automatically on every commit using GitHub Actions, GitLab CI, or similar. Configure your pipeline to block merges if tests fail, ensuring code quality standards are maintained.
Summary
Comprehensive API testing using PHPUnit and Laravel's testing tools ensures your endpoints are reliable, secure, and maintainable. By combining unit tests, integration tests, JSON assertions, factories, and database testing strategies, you create a safety net that catches bugs early and enables confident refactoring. Well-tested APIs lead to better developer experience, fewer production incidents, and faster feature development.