Backend development

Testing REST APIs in PHP: From Unit Tests to Contract Testing with Pact

Ruslan Ismailov Published 14 min read
T

Introduction: The Testing Pyramid Applied to APIs

Building a reliable REST API is impossible without a well-thought-out testing strategy. The classic testing pyramid remains relevant in 2026: fast unit tests at the base, integration and feature tests in the middle, and slow end-to-end tests at the top. For microservice architectures, an additional layer is added — contract testing — which allows teams to agree on service interactions without spinning up the entire infrastructure.

In this article, we'll walk through the entire journey: from writing your first unit tests for business logic in Laravel to configuring a Pact broker in a CI/CD pipeline. This guide is aimed at PHP developers who want to build mature test coverage for their API services.

Unit Tests for Business Logic in Laravel

Unit tests verify isolated pieces of code — services, helpers, value objects — without touching the database or HTTP stack. Laravel ships with PHPUnit out of the box, while Pest offers a more expressive syntax built on top of it.

Let's look at an example discount calculation service:

<?php

namespace App\Services;

class DiscountService
{
    public function calculate(float $price, int $discountPercent): float
    {
        if ($discountPercent < 0 || $discountPercent > 100) {
            throw new \InvalidArgumentException('Discount must be between 0 and 100%');
        }
        return round($price * (1 - $discountPercent / 100), 2);
    }
}

A unit test with PHPUnit:

<?php

use App\Services\DiscountService;
use PHPUnit\Framework\TestCase;

class DiscountServiceTest extends TestCase
{
    private DiscountService $service;

    protected function setUp(): void
    {
        $this->service = new DiscountService();
    }

    public function test_calculates_discount_correctly(): void
    {
        $result = $this->service->calculate(1000.00, 20);
        $this->assertEquals(800.00, $result);
    }

    public function test_throws_exception_for_invalid_discount(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->service->calculate(1000.00, 150);
    }
}

The same test written in Pest is more concise:

<?php

use App\Services\DiscountService;

beforeEach(function () {
    $this->service = new DiscountService();
});

it('calculates discount correctly', function () {
    expect($this->service->calculate(1000.00, 20))->toBe(800.00);
});

it('throws exception for invalid discount', function () {
    $this->service->calculate(1000.00, 150);
})->throws(\InvalidArgumentException::class);

The key rule for unit tests: no real dependencies. Repositories and external services should be mocked using Mockery or PHPUnit's built-in mocking capabilities.

Feature Tests for HTTP Endpoints in Laravel

Feature tests verify the behavior of the entire HTTP stack: routing, middleware, controllers, and response serialization. Laravel provides a convenient test client via the Tests\TestCase class, which extends Illuminate\Foundation\Testing\TestCase.

An example of a product creation endpoint and its feature test:

<?php

// routes/api.php
Route::post('/products', [ProductController::class, 'store']);
Route::get('/products/{id}', [ProductController::class, 'show']);
<?php

namespace Tests\Feature;

use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ProductApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_authenticated_user_can_create_product(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user, 'sanctum')
            ->postJson('/api/products', [
                'name'  => 'Test Product',
                'price' => 999.99,
                'sku'   => 'SKU-001',
            ]);

        $response
            ->assertStatus(201)
            ->assertJson([
                'data' => [
                    'name'  => 'Test Product',
                    'price' => 999.99,
                ],
            ])
            ->assertJsonStructure([
                'data' => ['id', 'name', 'price', 'sku', 'created_at'],
            ]);

        $this->assertDatabaseHas('products', ['sku' => 'SKU-001']);
    }

    public function test_returns_422_for_invalid_payload(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user, 'sanctum')
            ->postJson('/api/products', [
                'name' => '', // empty name
            ]);

        $response
            ->assertStatus(422)
            ->assertJsonValidationErrors(['name', 'price', 'sku']);
    }

    public function test_unauthenticated_request_returns_401(): void
    {
        $this->postJson('/api/products', [])
            ->assertStatus(401);
    }
}

Note the use of RefreshDatabase — this trait rolls back transactions after each test, keeping execution fast. The methods assertJson, assertStatus, assertJsonStructure, and assertJsonValidationErrors cover the majority of API response validation scenarios.

Integration Testing with a Real PostgreSQL Database in Docker

Feature tests with RefreshDatabase use SQLite by default, which is convenient but doesn't reflect the behavior of real PostgreSQL — especially when using JSON fields, full-text search, or database-specific constraints. Integration tests require a real database.

Setting up the test environment with Docker Compose:

# docker-compose.test.yml
version: '3.9'
services:
  postgres_test:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app_test
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    ports:
      - '5433:5432'
    tmpfs:
      - /var/lib/postgresql/data  # in-memory storage for speed

Configuring phpunit.xml for the test environment:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php">
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="DB_CONNECTION" value="pgsql"/>
        <env name="DB_HOST" value="127.0.0.1"/>
        <env name="DB_PORT" value="5433"/>
        <env name="DB_DATABASE" value="app_test"/>
        <env name="DB_USERNAME" value="app"/>
        <env name="DB_PASSWORD" value="secret"/>
    </php>
</phpunit>

For tests that require PostgreSQL-specific behavior, use the DatabaseMigrations trait instead of RefreshDatabase — it fully runs and rolls back migrations, guaranteeing a clean schema state.

<?php

namespace Tests\Integration;

use App\Models\Product;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Tests\TestCase;

class ProductSearchIntegrationTest extends TestCase
{
    use DatabaseMigrations;

    public function test_fulltext_search_works_with_postgres(): void
    {
        Product::factory()->create(['name' => 'Sony Wireless Headphones']);
        Product::factory()->create(['name' => 'Sennheiser Wired Headset']);

        $response = $this->getJson('/api/products?search=wireless');

        $response
            ->assertStatus(200)
            ->assertJsonCount(1, 'data')
            ->assertJsonPath('data.0.name', 'Sony Wireless Headphones');
    }
}

Contract Testing: What It Is and Why It Matters in Microservices

In a microservice architecture, services communicate via APIs. The classic problem: the team behind Service A changes an endpoint's response, while Service B — which consumes it — only finds out in production. Integration tests that spin up all services simultaneously are slow, brittle, and hard to maintain.

Contract testing solves this problem differently: each consumer (API consumer) describes its expectations as a contract, and the provider (API provider) verifies that it fulfills those contracts. Services are tested independently, but their agreements are guaranteed.

Contract testing is not a replacement for integration tests — it complements them. It focuses on interface agreement rather than interaction business logic.

Introduction to Pact: Consumer-Driven Contract Testing

Pact is the most popular framework for contract testing. The approach is called consumer-driven: the consumer defines what it expects from the provider. The workflow looks like this:

  1. The consumer writes a test describing the expected interaction with the API.
  2. Pact generates a JSON contract file and spins up a mock server for the test.
  3. The contract is published to the Pact Broker.
  4. The provider verifies the contract by running the real service against the consumer's expectations.

Installing the PHP Pact client:

composer require --dev pact-foundation/pact-php

Test on the consumer side (an order service calling the product service):

<?php

namespace Tests\Contract\Consumer;

use PhpPact\Consumer\InteractionBuilder;
use PhpPact\Consumer\Model\ConsumerRequest;
use PhpPact\Consumer\Model\ProviderResponse;
use PhpPact\Consumer\MockServer\MockServerEnvConfig;
use PhpPact\Consumer\Matcher\Matcher;
use Tests\TestCase;

class ProductServiceConsumerTest extends TestCase
{
    public function test_get_product_by_id(): void
    {
        $config = new MockServerEnvConfig();
        $builder = new InteractionBuilder($config);
        $matcher = new Matcher();

        $request = new ConsumerRequest();
        $request
            ->setMethod('GET')
            ->setPath('/api/products/1')
            ->addHeader('Accept', 'application/json');

        $response = new ProviderResponse();
        $response
            ->setStatus(200)
            ->addHeader('Content-Type', 'application/json')
            ->setBody([
                'data' => [
                    'id'    => $matcher->integer(1),
                    'name'  => $matcher->like('Test Product'),
                    'price' => $matcher->decimal(999.99),
                    'sku'   => $matcher->regex('SKU-001', '^SKU-\d+$'),
                ],
            ]);

        $builder
            ->given('product with id 1 exists')
            ->uponReceiving('a request to get product by id')
            ->with($request)
            ->willRespondWith($response);

        // Make a real HTTP request to the Pact mock server
        $mockServerBaseUrl = $config->getBaseUri();
        $httpClient = new \GuzzleHttp\Client(['base_uri' => $mockServerBaseUrl]);
        $apiResponse = $httpClient->get('/api/products/1', [
            'headers' => ['Accept' => 'application/json'],
        ]);

        $this->assertEquals(200, $apiResponse->getStatusCode());
        $body = json_decode($apiResponse->getBody(), true);
        $this->assertArrayHasKey('data', $body);

        // Finalize the contract and write the pact file
        $builder->verify();
    }
}

Verification on the provider side (the product service):

<?php

namespace Tests\Contract\Provider;

use PhpPact\Standalone\ProviderVerifier\Model\VerifierConfig;
use PhpPact\Standalone\ProviderVerifier\Verifier;
use Tests\TestCase;

class ProductServiceProviderTest extends TestCase
{
    public function test_verify_consumer_contracts(): void
    {
        $config = new VerifierConfig();
        $config
            ->setProviderName('ProductService')
            ->setProviderBaseUrl('http://localhost:8080')
            ->setPactBrokerUri('http://pact-broker:9292')
            ->setPublishResults(true)
            ->setProviderVersion(getenv('APP_VERSION') ?: 'local');

        $verifier = new Verifier($config);
        $verifier->addBroker();

        $result = $verifier->verify();
        $this->assertTrue($result, 'Provider contract verification failed');
    }
}

Integrating Tests into a CI/CD Pipeline

A properly configured CI/CD pipeline runs tests on every pull request and blocks deployments if they fail. Here's an example configuration for GitHub Actions:

# .github/workflows/tests.yml
name: API Tests

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  unit-and-feature-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: app_test
          POSTGRES_USER: app
          POSTGRES_PASSWORD: secret
        ports:
          - 5433:5432
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo_pgsql, pcov
          coverage: pcov

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress

      - name: Copy .env
        run: cp .env.testing .env

      - name: Run unit and feature tests
        run: ./vendor/bin/phpunit --testsuite=Unit,Feature --coverage-clover=coverage.xml

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: coverage.xml

  contract-tests:
    runs-on: ubuntu-latest
    needs: unit-and-feature-tests
    services:
      pact-broker:
        image: pactfoundation/pact-broker:latest
        env:
          PACT_BROKER_DATABASE_URL: sqlite:////tmp/pact_broker.sqlite3
        ports:
          - 9292:9292

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress

      - name: Run consumer contract tests
        run: ./vendor/bin/phpunit --testsuite=ContractConsumer
        env:
          PACT_BROKER_BASE_URL: http://localhost:9292

      - name: Run provider contract verification
        run: ./vendor/bin/phpunit --testsuite=ContractProvider
        env:
          PACT_BROKER_BASE_URL: http://localhost:9292
          APP_VERSION: ${{ github.sha }}

Splitting into separate jobs allows contract tests to run only after unit and feature tests have passed successfully — this saves resources and speeds up feedback.

Tips for Organizing Test Environments and Data Isolation

Test reliability directly depends on proper data isolation. Follow these principles:

  • Use Laravel factories (Model::factory()) to create test data — they are declarative and easy to configure. Avoid hardcoding real IDs or email addresses.
  • Separate test suites in phpunit.xml: Unit, Feature, Integration, ContractConsumer, ContractProvider. This lets you run only the level you need.
  • A separate database for each CI worker: when running tests in parallel, use --processes in Pest or ParaTest, with different database names (app_test_1, app_test_2).
  • Don't share state between tests: static variables, singletons, and Redis cache should be reset in setUp()/tearDown(). Use Cache::flush() at the start of tests where this is critical.
  • Test configs in .env.testing: never use production environment variables in tests. The .env.testing file should be committed to the repository (without secrets).
  • Mock external services: payment gateways, email providers, and third-party APIs should be mocked via Http::fake() in Laravel or MockHandler in Guzzle.

API Testing Anti-Patterns

Even with a large number of tests, coverage can give a false sense of security. Here are common anti-patterns to avoid:

  • Testing only the happy path: only the successful scenario is tested. Add tests for invalid data, boundary values, missing resources (404), and authorization errors (401/403).
  • Test interdependencies: Test B depends on data created by Test A. Each test should create its own data independently.
  • Ignoring response headers: an API is more than just the response body. Verify Content-Type, pagination headers, ETag, and other meaningful metadata.
  • Mocking what you're supposed to test: if you mock a repository in a feature test, you're not verifying real database interactions. Mocks are appropriate in unit tests, but not in integration tests.
  • Unnecessarily slow tests: using sleep() in tests, making requests to real external services, or missing indexes in the test database — all of these make tests slow and unstable.
  • Ignoring contract changes: changing the structure of an API response without updating the Pact contract is a direct path to integration failures in production.

Conclusion and Recommendations

Building reliable test coverage for a REST API in PHP is an iterative process. Start simple: achieve good unit test coverage of your business logic and feature test coverage of your core endpoints. Add integration tests with real PostgreSQL for critical scenarios where database-specific behavior matters.

Once your services begin actively communicating with each other, introduce contract testing with Pact. This is especially valuable in teams where different developers work on different services — contracts become living documentation and a protective barrier against regressions.

Key recommendations based on what we've covered:

  1. Follow the testing pyramid: more unit tests, fewer E2E tests.
  2. Use Docker for a reproducible test environment with PostgreSQL.
  3. Automate running all tests in CI/CD — every PR should go through a full test run.
  4. Introduce Pact gradually: start with a single consumer-provider pair.
  5. Review coverage reports regularly, but don't chase 100% — quality matters more than the number.
  6. Document test scenarios: good test names are the best API documentation.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →