REST API Development

Advanced API Testing

20 min Lesson 21 of 35

Advanced API Testing

Testing is crucial for building reliable APIs. In this lesson, we'll explore advanced testing strategies including contract testing, integration testing, using Pest PHP for API testing, and mocking external services.

Why Advanced Testing Matters

Basic unit tests aren't enough for APIs. You need to verify:

  • Contract Compliance: Does your API meet its documented specifications?
  • Integration Behavior: Do all components work together correctly?
  • External Dependencies: How does your API behave when third-party services fail?
  • Performance: Can your API handle expected load?
  • Security: Are authentication and authorization working correctly?
Testing Pyramid: Follow the testing pyramid approach: many unit tests, some integration tests, few end-to-end tests. This ensures fast feedback while maintaining comprehensive coverage.

Contract Testing with OpenAPI

Contract testing ensures your API implementation matches its OpenAPI specification. This prevents breaking changes and maintains API consistency.

<?php // tests/Feature/ContractTest.php namespace Tests\Feature; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; class ContractTest extends TestCase { use RefreshDatabase; /** @test */ public function users_endpoint_matches_openapi_contract() { // Create test data User::factory()->count(3)->create(); // Make request $response = $this->getJson('/api/v1/users'); // Validate against OpenAPI schema $response->assertStatus(200) ->assertJsonStructure([ 'data' => [ '*' => [ 'id', 'type', 'attributes' => [ 'name', 'email', 'created_at', 'updated_at' ], 'links' => [ 'self' ] ] ], 'links' => [ 'self', 'first', 'last' ], 'meta' => [ 'total', 'per_page', 'current_page' ] ]); // Validate response types $data = $response->json('data.0'); $this->assertIsInt($data['id']); $this->assertEquals('users', $data['type']); $this->assertIsString($data['attributes']['email']); } /** @test */ public function validation_errors_match_openapi_contract() { $response = $this->postJson('/api/v1/users', [ 'email' => 'invalid-email' ]); $response->assertStatus(422) ->assertJsonStructure([ 'errors' => [ '*' => [ 'status', 'title', 'detail', 'source' => [ 'pointer' ] ] ] ]); } }
Automated Contract Validation: Use tools like Spectator (Laravel package) to automatically validate responses against your OpenAPI specification without writing manual assertions.

Integration Testing Strategies

Integration tests verify that multiple components work together correctly. They test the entire request/response cycle including database operations, middleware, and external services.

<?php // tests/Feature/OrderIntegrationTest.php namespace Tests\Feature; use Tests\TestCase; use App\Models\User; use App\Models\Product; use App\Events\OrderPlaced; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; use Illuminate\Foundation\Testing\RefreshDatabase; class OrderIntegrationTest extends TestCase { use RefreshDatabase; /** @test */ public function complete_order_workflow_executes_successfully() { // Fake events and queues Event::fake([OrderPlaced::class]); Queue::fake(); // Create test data $user = User::factory()->create(); $product = Product::factory()->create([ 'price' => 99.99, 'stock' => 10 ]); // Authenticate $token = $user->createToken('test')->plainTextToken; // Place order $response = $this->withToken($token) ->postJson('/api/v1/orders', [ 'items' => [ [ 'product_id' => $product->id, 'quantity' => 2 ] ], 'shipping_address' => [ 'street' => '123 Main St', 'city' => 'New York', 'postal_code' => '10001', 'country' => 'US' ] ]); // Assert response $response->assertStatus(201) ->assertJsonStructure([ 'data' => [ 'id', 'type', 'attributes' => [ 'status', 'total_amount', 'items_count', 'created_at' ] ] ]); // Assert database changes $this->assertDatabaseHas('orders', [ 'user_id' => $user->id, 'status' => 'pending', 'total_amount' => 199.98 ]); $this->assertDatabaseHas('order_items', [ 'product_id' => $product->id, 'quantity' => 2, 'unit_price' => 99.99 ]); // Assert stock was decremented $this->assertEquals(8, $product->fresh()->stock); // Assert event was dispatched Event::assertDispatched(OrderPlaced::class, function ($event) use ($response) { return $event->order->id === $response->json('data.id'); }); // Assert notification was queued Queue::assertPushed(SendOrderConfirmationNotification::class); } /** @test */ public function order_fails_with_insufficient_stock() { $user = User::factory()->create(); $product = Product::factory()->create([ 'price' => 99.99, 'stock' => 1 ]); $token = $user->createToken('test')->plainTextToken; $response = $this->withToken($token) ->postJson('/api/v1/orders', [ 'items' => [ [ 'product_id' => $product->id, 'quantity' => 2 ] ] ]); $response->assertStatus(422) ->assertJsonPath('errors.0.detail', 'Insufficient stock for product'); // Assert no order was created $this->assertDatabaseCount('orders', 0); // Assert stock unchanged $this->assertEquals(1, $product->fresh()->stock); } }

Pest PHP for API Testing

Pest PHP is a modern testing framework that makes tests more readable and enjoyable to write. It's particularly well-suited for API testing with its expressive syntax.

<?php // tests/Feature/UserApiTest.php use App\Models\User; it('lists all users with pagination', function () { User::factory()->count(25)->create(); $response = $this->getJson('/api/v1/users?page=2&per_page=10'); expect($response->status())->toBe(200) ->and($response->json('data'))->toHaveCount(10) ->and($response->json('meta.current_page'))->toBe(2) ->and($response->json('meta.total'))->toBe(25); }); it('creates a user successfully', function () { $userData = [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'Password123!', 'password_confirmation' => 'Password123!' ]; $response = $this->postJson('/api/v1/users', $userData); expect($response->status())->toBe(201) ->and($response->json('data.attributes.email'))->toBe('john@example.com'); }); it('validates required fields', function () { $response = $this->postJson('/api/v1/users', []); expect($response->status())->toBe(422) ->and($response->json('errors'))->toBeArray() ->and($response->json('errors.0.source.pointer'))->toContain('name'); }); test('authenticated user can update their profile', function () { $user = User::factory()->create(); $token = $user->createToken('test')->plainTextToken; $response = $this->withToken($token) ->patchJson("/api/v1/users/{$user->id}", [ 'name' => 'Updated Name' ]); expect($response->status())->toBe(200) ->and($user->fresh()->name)->toBe('Updated Name'); }); test('user cannot update another user's profile', function () { $user1 = User::factory()->create(); $user2 = User::factory()->create(); $token = $user1->createToken('test')->plainTextToken; $response = $this->withToken($token) ->patchJson("/api/v1/users/{$user2->id}", [ 'name' => 'Hacked Name' ]); expect($response->status())->toBe(403); }); // Using datasets for multiple test cases it('validates email format', function (string $email, bool $valid) { $response = $this->postJson('/api/v1/users', [ 'name' => 'John Doe', 'email' => $email, 'password' => 'Password123!', 'password_confirmation' => 'Password123!' ]); if ($valid) { expect($response->status())->toBe(201); } else { expect($response->status())->toBe(422); } })->with([ ['valid@example.com', true], ['invalid-email', false], ['missing@domain', false], ['@nodomain.com', false], ['spaces in@email.com', false] ]);
Pest Benefits: Pest's expect() syntax is more readable than PHPUnit's assertions, and features like datasets reduce code duplication significantly.

Mocking External Services

When your API depends on external services (payment gateways, email providers, third-party APIs), you need to mock these dependencies to ensure fast, reliable tests that don't depend on external availability.

<?php // tests/Feature/PaymentProcessingTest.php namespace Tests\Feature; use Tests\TestCase; use App\Models\Order; use App\Services\StripeService; use Illuminate\Support\Facades\Http; use Illuminate\Foundation\Testing\RefreshDatabase; class PaymentProcessingTest extends TestCase { use RefreshDatabase; /** @test */ public function payment_processes_successfully_with_stripe() { // Mock Stripe API responses Http::fake([ 'api.stripe.com/v1/payment_intents' => Http::response([ 'id' => 'pi_test123', 'status' => 'succeeded', 'amount' => 10000, 'currency' => 'usd' ], 200) ]); $order = Order::factory()->create([ 'total_amount' => 100.00, 'status' => 'pending' ]); $response = $this->postJson("/api/v1/orders/{$order->id}/payments", [ 'payment_method' => 'card', 'payment_method_id' => 'pm_test_card' ]); $response->assertStatus(200) ->assertJson([ 'data' => [ 'attributes' => [ 'status' => 'paid', 'payment_intent_id' => 'pi_test123' ] ] ]); // Verify the HTTP request was made Http::assertSent(function ($request) { return $request->url() === 'https://api.stripe.com/v1/payment_intents' && $request['amount'] === 10000; }); } /** @test */ public function payment_fails_gracefully_when_stripe_is_down() { // Mock network failure Http::fake([ 'api.stripe.com/*' => Http::response(null, 500) ]); $order = Order::factory()->create([ 'total_amount' => 100.00, 'status' => 'pending' ]); $response = $this->postJson("/api/v1/orders/{$order->id}/payments", [ 'payment_method' => 'card', 'payment_method_id' => 'pm_test_card' ]); $response->assertStatus(503) ->assertJsonPath('errors.0.title', 'Payment service temporarily unavailable'); // Verify order status unchanged $this->assertEquals('pending', $order->fresh()->status); } } // Mocking with a service interface interface PaymentGateway { public function charge(float $amount, string $paymentMethod): array; } // tests/Feature/PaymentWithMockServiceTest.php class PaymentWithMockServiceTest extends TestCase { use RefreshDatabase; /** @test */ public function payment_uses_mocked_gateway() { // Create a mock $mockGateway = $this->createMock(PaymentGateway::class); // Set expectations $mockGateway->expects($this->once()) ->method('charge') ->with(100.00, 'pm_test_card') ->willReturn([ 'success' => true, 'transaction_id' => 'txn_123' ]); // Bind mock to container $this->app->instance(PaymentGateway::class, $mockGateway); // Execute test $order = Order::factory()->create(['total_amount' => 100.00]); $response = $this->postJson("/api/v1/orders/{$order->id}/payments", [ 'payment_method_id' => 'pm_test_card' ]); $response->assertStatus(200); } }

Testing Best Practices

Test Organization:
  • Use descriptive test names that explain what is being tested
  • Follow the Arrange-Act-Assert pattern
  • One assertion per test (when possible)
  • Keep tests independent and isolated
  • Use factories for test data generation
<?php // Good test structure example /** @test */ public function authenticated_user_can_create_post() { // Arrange: Set up test data and conditions $user = User::factory()->create(); $token = $user->createToken('test')->plainTextToken; $postData = [ 'title' => 'Test Post', 'content' => 'This is test content', 'status' => 'published' ]; // Act: Perform the action being tested $response = $this->withToken($token) ->postJson('/api/v1/posts', $postData); // Assert: Verify the expected outcome $response->assertStatus(201) ->assertJsonPath('data.attributes.title', 'Test Post'); $this->assertDatabaseHas('posts', [ 'user_id' => $user->id, 'title' => 'Test Post' ]); }

Performance Testing

Test your API's performance under load to identify bottlenecks:

<?php // tests/Performance/ApiPerformanceTest.php namespace Tests\Performance; use Tests\TestCase; use App\Models\User; class ApiPerformanceTest extends TestCase { /** @test */ public function api_responds_within_acceptable_time() { User::factory()->count(100)->create(); $startTime = microtime(true); $response = $this->getJson('/api/v1/users'); $duration = microtime(true) - $startTime; $response->assertStatus(200); // Assert response time is under 500ms $this->assertLessThan(0.5, $duration, "API response took {$duration}s, expected under 0.5s"); } /** @test */ public function database_queries_are_optimized() { User::factory()->count(50)->create(); $this->assertQueryCount(2, function () { $this->getJson('/api/v1/users'); }); } protected function assertQueryCount(int $expected, callable $callback) { $queries = 0; DB::listen(function ($query) use (&$queries) { $queries++; }); $callback(); $this->assertEquals($expected, $queries, "Expected {$expected} queries, but {$queries} were executed"); } }
Exercise:
  1. Write integration tests for a complete user registration flow including email verification
  2. Convert an existing PHPUnit test to Pest PHP syntax
  3. Mock an external weather API and test error handling when it fails
  4. Create a performance test that ensures your API can handle 100 concurrent requests
  5. Write contract tests that validate your API responses against an OpenAPI schema
Common Testing Mistakes:
  • Not isolating tests - tests that depend on each other are fragile
  • Testing implementation details instead of behavior
  • Not cleaning up test data properly
  • Making real HTTP requests to external services
  • Ignoring edge cases and error scenarios