REST API Development

API Testing Fundamentals

15 min Lesson 20 of 50

Introduction to API Testing

Testing your REST API is crucial for ensuring reliability, catching bugs early, and maintaining code quality as your application grows. In this comprehensive lesson, we'll explore how to write effective API tests using PHPUnit in Laravel, covering everything from basic endpoint testing to advanced database interactions and authentication testing.

API testing verifies that your endpoints return the correct responses, handle errors properly, validate input correctly, and maintain data integrity. Automated tests act as living documentation and provide confidence when refactoring or adding new features.

Setting Up the Test Environment

Laravel comes with PHPUnit pre-configured. Your test environment uses a separate configuration defined in phpunit.xml at your project root:

<?xml version="1.0" encoding="UTF-8"?> <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true"> <testsuites> <testsuite name="Feature"> <directory suffix="Test.php">./tests/Feature</directory> </testsuite> </testsuites> <php> <env name="APP_ENV" value="testing"/> <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/> <env name="CACHE_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/> <env name="QUEUE_DRIVER" value="sync"/> </php> </phpunit>
Test Environment Configuration: The test environment typically uses an in-memory SQLite database for speed and isolation. Each test runs in a fresh database state, ensuring tests don't affect each other.

Laravel provides two types of tests:

  • Unit Tests: Test individual methods or classes in isolation (stored in tests/Unit)
  • Feature Tests: Test complete features including HTTP requests, database interactions, and multiple components working together (stored in tests/Feature)

For API testing, we primarily use Feature tests because they test the entire request-response cycle.

Creating Your First API Test

Create a new test file using Artisan:

php artisan make:test Api/ProductTest

This creates tests/Feature/Api/ProductTest.php. Here's a basic structure:

<?php namespace Tests\Feature\Api; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ProductTest extends TestCase { use RefreshDatabase; public function test_can_retrieve_products_list() { // Arrange: Set up test data // Act: Make the API request // Assert: Verify the response } }
AAA Pattern: Structure your tests using the Arrange-Act-Assert pattern. First arrange your test data, then act by making the request, finally assert the expected outcome. This makes tests readable and maintainable.

The RefreshDatabase Trait

The RefreshDatabase trait is essential for database testing. It ensures each test starts with a clean database by running migrations before tests and rolling back changes after each test:

<?php namespace Tests\Feature\Api; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; use App\Models\Product; class ProductTest extends TestCase { use RefreshDatabase; public function test_database_is_fresh_for_each_test() { // This test runs with an empty database $this->assertDatabaseCount('products', 0); // Create a product Product::factory()->create(); $this->assertDatabaseCount('products', 1); } public function test_database_is_still_fresh() { // Even though previous test created a product, // this test starts fresh with zero products $this->assertDatabaseCount('products', 0); } }

Without RefreshDatabase, data from one test would persist and affect other tests, causing unpredictable failures. This trait ensures test isolation and repeatability.

Basic HTTP Assertions

Laravel provides intuitive methods for testing HTTP responses. The most fundamental are assertStatus() and assertJson():

<?php public function test_can_retrieve_product() { // Create a test product $product = Product::factory()->create([ 'name' => 'Test Product', 'price' => 99.99, 'stock' => 10 ]); // Make GET request to retrieve the product $response = $this->getJson("/api/products/{$product->id}"); // Assert the response status is 200 OK $response->assertStatus(200); // Assert the JSON response contains expected data $response->assertJson([ 'data' => [ 'id' => $product->id, 'name' => 'Test Product', 'price' => 99.99, 'stock' => 10 ] ]); }

Common HTTP status code assertions:

// Success responses $response->assertOk(); // 200 $response->assertCreated(); // 201 $response->assertNoContent(); // 204 // Client error responses $response->assertBadRequest(); // 400 $response->assertUnauthorized(); // 401 $response->assertForbidden(); // 403 $response->assertNotFound(); // 404 $response->assertUnprocessable(); // 422 // Server error responses $response->assertServerError(); // 500 // Custom status codes $response->assertStatus(418); // I'm a teapot

JSON Structure Assertions

The assertJsonStructure() method verifies the shape of your JSON response without checking exact values. This is useful for ensuring consistent API responses:

<?php public function test_products_list_has_correct_structure() { // Create multiple products Product::factory()->count(3)->create(); $response = $this->getJson('/api/products'); // Assert the response has the expected structure $response->assertJsonStructure([ 'data' => [ '*' => [ // * means each item in the array 'id', 'name', 'description', 'price', 'stock', 'created_at', 'updated_at' ] ], 'links' => [ 'first', 'last', 'prev', 'next' ], 'meta' => [ 'current_page', 'last_page', 'per_page', 'total' ] ]); }

You can also verify nested structures:

<?php public function test_product_with_category_structure() { $product = Product::factory() ->for(Category::factory()) ->create(); $response = $this->getJson("/api/products/{$product->id}"); $response->assertJsonStructure([ 'data' => [ 'id', 'name', 'price', 'category' => [ // Nested structure 'id', 'name', 'slug' ] ] ]); }

Advanced JSON Assertions

Laravel provides many specialized JSON assertions for different testing scenarios:

<?php public function test_advanced_json_assertions() { $product = Product::factory()->create([ 'name' => 'Premium Laptop', 'price' => 1299.99 ]); $response = $this->getJson("/api/products/{$product->id}"); // Assert response contains a specific JSON fragment $response->assertJsonFragment([ 'name' => 'Premium Laptop' ]); // Assert response is exactly this JSON $response->assertExactJson([ 'data' => [ 'id' => $product->id, 'name' => 'Premium Laptop', 'price' => 1299.99 ] ]); // Assert response does NOT contain something $response->assertJsonMissing([ 'secret_key' => 'should-not-be-exposed' ]); // Assert a JSON path has a specific value $response->assertJsonPath('data.name', 'Premium Laptop'); // Assert count of items in an array $response->assertJsonCount(1, 'data'); }
Be Careful with assertExactJson: This assertion requires the JSON to match exactly, including key order and all fields. It's very strict and can make tests brittle. Use assertJson() or assertJsonFragment() for more flexible testing.

Using Factories for Test Data

Factories allow you to generate fake data quickly. Define factories in database/factories:

<?php namespace Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; class ProductFactory extends Factory { public function definition(): array { return [ 'name' => fake()->words(3, true), 'description' => fake()->paragraph(), 'price' => fake()->randomFloat(2, 10, 1000), 'stock' => fake()->numberBetween(0, 100), 'category_id' => Category::factory(), 'is_active' => true ]; } // Custom factory state for out-of-stock products public function outOfStock(): static { return $this->state(fn (array $attributes) => [ 'stock' => 0, ]); } // Custom factory state for inactive products public function inactive(): static { return $this->state(fn (array $attributes) => [ 'is_active' => false, ]); } }

Use factories in your tests:

<?php public function test_using_factories() { // Create a single product with default attributes $product = Product::factory()->create(); // Create multiple products $products = Product::factory()->count(5)->create(); // Create with custom attributes $expensiveProduct = Product::factory()->create([ 'price' => 9999.99 ]); // Use custom factory states $outOfStock = Product::factory()->outOfStock()->create(); // Create without saving to database (for testing logic) $unsavedProduct = Product::factory()->make(); // Create with relationships $productWithCategory = Product::factory() ->for(Category::factory()) ->create(); }
Factory States: Define custom factory states for common scenarios like "out of stock", "discounted", or "featured". This makes your tests more expressive and easier to understand.

Testing CRUD Operations

Let's test all CRUD operations for a products API. I'll show you comprehensive examples for creating, reading, updating, and deleting products through your API.

<?php namespace Tests\Feature\Api; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; use App\Models\Product; use App\Models\Category; class ProductCrudTest extends TestCase { use RefreshDatabase; // CREATE (POST) public function test_can_create_product() { $category = Category::factory()->create(); $productData = [ 'name' => 'New Product', 'description' => 'Test description', 'price' => 49.99, 'stock' => 20, 'category_id' => $category->id ]; $response = $this->postJson('/api/products', $productData); $response->assertCreated() ->assertJsonFragment([ 'name' => 'New Product', 'price' => 49.99 ]); // Verify product was saved to database $this->assertDatabaseHas('products', [ 'name' => 'New Product', 'price' => 49.99 ]); } // READ (GET) public function test_can_retrieve_product() { $product = Product::factory()->create(); $response = $this->getJson("/api/products/{$product->id}"); $response->assertOk() ->assertJson([ 'data' => [ 'id' => $product->id, 'name' => $product->name ] ]); } public function test_can_retrieve_products_list() { Product::factory()->count(3)->create(); $response = $this->getJson('/api/products'); $response->assertOk() ->assertJsonCount(3, 'data'); } // UPDATE (PUT/PATCH) public function test_can_update_product() { $product = Product::factory()->create([ 'name' => 'Old Name', 'price' => 99.99 ]); $updateData = [ 'name' => 'Updated Name', 'price' => 149.99 ]; $response = $this->putJson("/api/products/{$product->id}", $updateData); $response->assertOk() ->assertJsonFragment([ 'name' => 'Updated Name', 'price' => 149.99 ]); // Verify database was updated $this->assertDatabaseHas('products', [ 'id' => $product->id, 'name' => 'Updated Name', 'price' => 149.99 ]); // Verify old data no longer exists $this->assertDatabaseMissing('products', [ 'id' => $product->id, 'name' => 'Old Name' ]); } // DELETE (DELETE) public function test_can_delete_product() { $product = Product::factory()->create(); $response = $this->deleteJson("/api/products/{$product->id}"); $response->assertNoContent(); // Verify product was deleted $this->assertDatabaseMissing('products', [ 'id' => $product->id ]); // If using soft deletes $this->assertSoftDeleted('products', [ 'id' => $product->id ]); } public function test_returns_404_for_non_existent_product() { $response = $this->getJson('/api/products/99999'); $response->assertNotFound(); } }

Testing Validation Rules

Testing that your API correctly validates input is crucial for security and data integrity:

<?php public function test_product_creation_requires_name() { $response = $this->postJson('/api/products', [ 'price' => 99.99, 'stock' => 10 // name is missing ]); $response->assertUnprocessable() ->assertJsonValidationErrors(['name']); } public function test_product_price_must_be_numeric() { $response = $this->postJson('/api/products', [ 'name' => 'Test Product', 'price' => 'not-a-number', 'stock' => 10 ]); $response->assertUnprocessable() ->assertJsonValidationErrors(['price']); } public function test_product_price_must_be_positive() { $response = $this->postJson('/api/products', [ 'name' => 'Test Product', 'price' => -50.00, 'stock' => 10 ]); $response->assertUnprocessable() ->assertJsonValidationErrors(['price']); } public function test_product_stock_must_be_integer() { $response = $this->postJson('/api/products', [ 'name' => 'Test Product', 'price' => 99.99, 'stock' => 10.5 // Should be integer ]); $response->assertUnprocessable() ->assertJsonValidationErrors(['stock']); } public function test_validates_all_fields_at_once() { $response = $this->postJson('/api/products', [ // All fields invalid or missing ]); $response->assertUnprocessable() ->assertJsonValidationErrors([ 'name', 'price', 'stock', 'category_id' ]); }
Test Validation Extensively: For each field, test: required/optional, data type, min/max values, format patterns, and unique constraints. This ensures your API rejects invalid data before it reaches your database.

Testing Authentication

Most APIs require authentication. Laravel Sanctum makes this easy to test:

<?php namespace Tests\Feature\Api; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; use App\Models\User; use App\Models\Product; class AuthenticationTest extends TestCase { use RefreshDatabase; public function test_unauthenticated_users_cannot_access_protected_routes() { $response = $this->getJson('/api/products'); $response->assertUnauthorized(); } public function test_authenticated_users_can_access_protected_routes() { $user = User::factory()->create(); $response = $this->actingAs($user) ->getJson('/api/products'); $response->assertOk(); } public function test_can_login_with_valid_credentials() { $user = User::factory()->create([ 'email' => 'test@example.com', 'password' => bcrypt('password123') ]); $response = $this->postJson('/api/login', [ 'email' => 'test@example.com', 'password' => 'password123' ]); $response->assertOk() ->assertJsonStructure([ 'token', 'user' => [ 'id', 'name', 'email' ] ]); } public function test_cannot_login_with_invalid_credentials() { $user = User::factory()->create([ 'email' => 'test@example.com', 'password' => bcrypt('password123') ]); $response = $this->postJson('/api/login', [ 'email' => 'test@example.com', 'password' => 'wrong-password' ]); $response->assertUnauthorized(); } public function test_can_logout() { $user = User::factory()->create(); $response = $this->actingAs($user) ->postJson('/api/logout'); $response->assertNoContent(); } public function test_users_can_only_access_own_resources() { $user1 = User::factory()->create(); $user2 = User::factory()->create(); $product = Product::factory()->create([ 'user_id' => $user1->id ]); // User 2 trying to access User 1's product $response = $this->actingAs($user2) ->getJson("/api/products/{$product->id}"); $response->assertForbidden(); } }

For API token authentication:

<?php public function test_can_access_api_with_token() { $user = User::factory()->create(); $token = $user->createToken('test-token'); $response = $this->withToken($token->plainTextToken) ->getJson('/api/products'); $response->assertOk(); } public function test_cannot_access_with_invalid_token() { $response = $this->withToken('invalid-token-string') ->getJson('/api/products'); $response->assertUnauthorized(); }

Database Assertions

Laravel provides many database assertions to verify data persistence:

<?php public function test_database_assertions() { $product = Product::factory()->create([ 'name' => 'Test Product', 'price' => 99.99 ]); // Assert record exists in database $this->assertDatabaseHas('products', [ 'name' => 'Test Product', 'price' => 99.99 ]); // Assert record does NOT exist $this->assertDatabaseMissing('products', [ 'name' => 'Non-existent Product' ]); // Assert exact count $this->assertDatabaseCount('products', 1); // Delete the product $product->delete(); // Assert soft delete (if using soft deletes) $this->assertSoftDeleted('products', [ 'id' => $product->id ]); // Assert model exists in database $this->assertModelExists($product); // Assert model was deleted $this->assertModelMissing($product); }

Testing Pagination

When your API returns paginated results, test the pagination structure:

<?php public function test_products_are_paginated() { // Create 25 products Product::factory()->count(25)->create(); $response = $this->getJson('/api/products?page=1'); $response->assertOk() ->assertJsonStructure([ 'data', 'links' => [ 'first', 'last', 'prev', 'next' ], 'meta' => [ 'current_page', 'from', 'last_page', 'per_page', 'to', 'total' ] ]) ->assertJsonPath('meta.total', 25) ->assertJsonPath('meta.per_page', 15) ->assertJsonCount(15, 'data'); } public function test_can_navigate_pagination() { Product::factory()->count(25)->create(); // First page $response = $this->getJson('/api/products?page=1'); $response->assertJsonPath('meta.current_page', 1); // Second page $response = $this->getJson('/api/products?page=2'); $response->assertJsonPath('meta.current_page', 2) ->assertJsonCount(10, 'data'); // Remaining items }

Testing Filters and Sorting

Test that query parameters work correctly:

<?php public function test_can_filter_products_by_category() { $category1 = Category::factory()->create(['name' => 'Electronics']); $category2 = Category::factory()->create(['name' => 'Books']); Product::factory()->count(3)->create(['category_id' => $category1->id]); Product::factory()->count(2)->create(['category_id' => $category2->id]); $response = $this->getJson("/api/products?category={$category1->id}"); $response->assertOk() ->assertJsonCount(3, 'data'); } public function test_can_search_products() { Product::factory()->create(['name' => 'Gaming Laptop']); Product::factory()->create(['name' => 'Office Laptop']); Product::factory()->create(['name' => 'Desktop Computer']); $response = $this->getJson('/api/products?search=laptop'); $response->assertOk() ->assertJsonCount(2, 'data'); } public function test_can_sort_products() { Product::factory()->create(['name' => 'Product C', 'price' => 50]); Product::factory()->create(['name' => 'Product A', 'price' => 100]); Product::factory()->create(['name' => 'Product B', 'price' => 75]); // Sort by price ascending $response = $this->getJson('/api/products?sort=price&order=asc'); $response->assertOk(); $data = $response->json('data'); $this->assertEquals(50, $data[0]['price']); $this->assertEquals(75, $data[1]['price']); $this->assertEquals(100, $data[2]['price']); }

Running Tests

Execute your test suite using PHPUnit through Artisan:

# Run all tests php artisan test # Run tests with verbose output php artisan test --verbose # Run a specific test file php artisan test tests/Feature/Api/ProductTest.php # Run a specific test method php artisan test --filter test_can_create_product # Run tests in parallel (faster) php artisan test --parallel # Generate code coverage report php artisan test --coverage
Continuous Testing: Run tests frequently during development. Use php artisan test --filter to run only the tests you're working on, then run the full suite before committing changes.
Practice Exercise:
  1. Create a test class tests/Feature/Api/OrderTest.php
  2. Write tests for creating an order with validation (required customer_id, at least one item, valid quantities)
  3. Test that orders can only be viewed by their owner
  4. Test that admins can view all orders
  5. Test order status transitions (pending → processing → shipped → delivered)
  6. Test that orders cannot be deleted once shipped
  7. Write factory states for different order statuses
  8. Test filtering orders by status and date range

Best Practices for API Testing

  • Test in Isolation: Each test should be independent and not rely on other tests. Use RefreshDatabase to ensure a clean state.
  • Test Edge Cases: Don't just test the happy path. Test with invalid data, missing fields, boundary values, and edge cases.
  • Use Descriptive Test Names: Name tests clearly so failures are easy to understand: test_cannot_create_product_with_negative_price
  • Keep Tests Fast: Use in-memory databases, avoid unnecessary HTTP calls, and use factories efficiently.
  • Test Business Logic: Focus on testing what your API does, not how frameworks work. Test your unique business rules.
  • Maintain Test Coverage: Aim for high coverage of critical paths. Use --coverage to identify untested code.
  • Test Security: Always test authentication, authorization, and input validation thoroughly.
  • Use Meaningful Assertions: Multiple specific assertions are better than one generic assertion.