Testing REST APIs in PHP: From Unit Tests to Contract Testing with Pact
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:
- The consumer writes a test describing the expected interaction with the API.
- Pact generates a JSON contract file and spins up a mock server for the test.
- The contract is published to the Pact Broker.
- 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
--processesin 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(). UseCache::flush()at the start of tests where this is critical. - Test configs in
.env.testing: never use production environment variables in tests. The.env.testingfile 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 orMockHandlerin 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:
- Follow the testing pyramid: more unit tests, fewer E2E tests.
- Use Docker for a reproducible test environment with PostgreSQL.
- Automate running all tests in CI/CD — every PR should go through a full test run.
- Introduce Pact gradually: start with a single consumer-provider pair.
- Review coverage reports regularly, but don't chase 100% — quality matters more than the number.
- 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 →