Backend-разработка

Тестирование REST API на PHP: от юнит-тестов до контрактного тестирования с Pact

Ruslan Ismailov Опубликовано 14 мин чтения
Т

Введение: пирамида тестирования применительно к API

Разработка надёжного REST API невозможна без продуманной стратегии тестирования. Классическая пирамида тестирования остаётся актуальной в 2026 году: в основании — быстрые юнит-тесты, в середине — интеграционные и feature-тесты, на вершине — медленные end-to-end тесты. Для микросервисной архитектуры к этой пирамиде добавляется отдельный уровень — контрактное тестирование, которое позволяет командам согласовывать взаимодействие сервисов без поднятия всей инфраструктуры.

В этой статье мы пройдём весь путь: от написания первых юнит-тестов для бизнес-логики в Laravel до настройки Pact-брокера в CI/CD пайплайне. Целевая аудитория — PHP-разработчики, которые хотят выстроить зрелое тестовое покрытие своих API-сервисов.

Юнит-тесты для бизнес-логики в Laravel

Юнит-тесты проверяют изолированные части кода — сервисы, хелперы, value objects — без обращения к базе данных или HTTP-стеку. Laravel поставляется с PHPUnit из коробки, а Pest предлагает более выразительный синтаксис на его основе.

Рассмотрим пример сервиса расчёта скидки:

<?php

namespace App\Services;

class DiscountService
{
    public function calculate(float $price, int $discountPercent): float
    {
        if ($discountPercent < 0 || $discountPercent > 100) {
            throw new \InvalidArgumentException('Скидка должна быть от 0 до 100%');
        }
        return round($price * (1 - $discountPercent / 100), 2);
    }
}

Юнит-тест с 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);
    }
}

Тот же тест на Pest выглядит лаконичнее:

<?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);

Ключевое правило юнит-тестов: никаких реальных зависимостей. Репозитории и внешние сервисы мокируются через Mockery или встроенные возможности PHPUnit.

Feature-тесты HTTP-эндпоинтов в Laravel

Feature-тесты проверяют поведение всего HTTP-стека: роутинг, middleware, контроллеры, сериализацию ответов. Laravel предоставляет удобный тестовый клиент через класс Tests\TestCase, который наследует от Illuminate\Foundation\Testing\TestCase.

Пример эндпоинта создания продукта и его feature-теста:

<?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'  => 'Тестовый товар',
                'price' => 999.99,
                'sku'   => 'SKU-001',
            ]);

        $response
            ->assertStatus(201)
            ->assertJson([
                'data' => [
                    'name'  => 'Тестовый товар',
                    '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' => '', // пустое имя
            ]);

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

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

Обратите внимание на использование RefreshDatabase — трейт откатывает транзакции после каждого теста, сохраняя скорость. Методы assertJson, assertStatus, assertJsonStructure и assertJsonValidationErrors покрывают большинство сценариев валидации API-ответов.

Интеграционное тестирование с реальной базой PostgreSQL в Docker

Feature-тесты с RefreshDatabase работают с SQLite по умолчанию, что удобно, но не отражает поведение реального PostgreSQL — особенно при использовании JSON-полей, полнотекстового поиска или специфичных ограничений. Для интеграционных тестов нужна реальная база данных.

Поднимаем тестовую среду через 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  # хранение в памяти для скорости

Настраиваем phpunit.xml для тестового окружения:

<?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>

Для тестов, которым требуется специфика PostgreSQL, используем трейт DatabaseMigrations вместо RefreshDatabase — он полностью накатывает и откатывает миграции, гарантируя чистое состояние схемы.

<?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']);
        Product::factory()->create(['name' => 'Проводная гарнитура Sennheiser']);

        $response = $this->getJson('/api/products?search=беспроводные');

        $response
            ->assertStatus(200)
            ->assertJsonCount(1, 'data')
            ->assertJsonPath('data.0.name', 'Беспроводные наушники Sony');
    }
}

Контрактное тестирование: что это и зачем нужно в микросервисах

В микросервисной архитектуре сервисы взаимодействуют через API. Классическая проблема: команда сервиса A изменила ответ эндпоинта, а сервис B, который его потребляет, узнаёт об этом только на проде. Интеграционные тесты с реальным запуском всех сервисов одновременно медленны, хрупки и сложны в поддержке.

Контрактное тестирование решает эту проблему иначе: каждый consumer (потребитель API) описывает свои ожидания в виде контракта, а provider (поставщик API) верифицирует, что выполняет эти контракты. Сервисы тестируются независимо, но договорённости гарантированы.

Контрактное тестирование — это не замена интеграционным тестам, а дополнение к ним. Оно фокусируется на согласовании интерфейса, а не на бизнес-логике взаимодействия.

Введение в Pact: consumer-driven contract testing

Pact — наиболее популярный фреймворк для контрактного тестирования. Подход называется consumer-driven: именно потребитель определяет, что он ожидает от провайдера. Рабочий процесс выглядит так:

  1. Consumer пишет тест, описывающий ожидаемое взаимодействие с API.
  2. Pact генерирует JSON-файл контракта и поднимает мок-сервер для теста.
  3. Контракт публикуется в Pact Broker.
  4. Provider верифицирует контракт, прогоняя реальный сервис против ожиданий consumer-а.

Установка PHP-клиента Pact:

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

Тест на стороне consumer (сервис заказов, который вызывает сервис продуктов):

<?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('Тестовый товар'),
                    '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);

        // Выполняем реальный HTTP-запрос к мок-серверу Pact
        $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);

        // Финализируем контракт и записываем pact-файл
        $builder->verify();
    }
}

Верификация на стороне provider (сервис продуктов):

<?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');
    }
}

Интеграция тестов в CI/CD пайплайн

Правильно выстроенный CI/CD пайплайн запускает тесты на каждый pull request и блокирует деплой при их падении. Пример конфигурации для 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 }}

Разделение на отдельные джобы позволяет запускать контрактные тесты только после успешного прохождения юнит и feature-тестов — это экономит ресурсы и ускоряет обратную связь.

Советы по организации тестовой среды и изоляции данных

Надёжность тестов напрямую зависит от правильной изоляции данных. Придерживайтесь следующих принципов:

  • Используйте фабрики Laravel (Model::factory()) для создания тестовых данных — они декларативны и легко настраиваются. Избегайте хардкода реальных ID или email-адресов.
  • Разделяйте тестовые наборы (test suites) в phpunit.xml: Unit, Feature, Integration, ContractConsumer, ContractProvider. Это позволяет запускать только нужный уровень.
  • Отдельная база для каждого CI-воркера: при параллельном запуске тестов используйте --processes в Pest или ParaTest, с разными именами баз данных (app_test_1, app_test_2).
  • Не шарьте состояние между тестами: статические переменные, синглтоны и кеш Redis должны сбрасываться в setUp()/tearDown(). Используйте Cache::flush() в начале тестов, где это критично.
  • Тестовые конфиги в .env.testing: никогда не используйте продакшн-переменные окружения в тестах. Файл .env.testing должен быть в репозитории (без секретов).
  • Мокируйте внешние сервисы: платёжные шлюзы, почтовые провайдеры и сторонние API должны мокироваться через Http::fake() в Laravel или MockHandler в Guzzle.

Антипаттерны тестирования API

Даже при наличии большого количества тестов покрытие может давать ложное чувство безопасности. Вот типичные антипаттерны, которых стоит избегать:

  • Тест только счастливого пути: тестируется только успешный сценарий. Добавляйте тесты на невалидные данные, граничные значения, отсутствующие ресурсы (404) и ошибки авторизации (401/403).
  • Зависимость тестов друг от друга: тест B зависит от данных, созданных тестом A. Каждый тест должен создавать свои данные самостоятельно.
  • Игнорирование заголовков ответа: API — это не только тело ответа. Проверяйте Content-Type, заголовки пагинации, ETag и другие значимые метаданные.
  • Мокирование того, что нужно тестировать: если вы мокируете репозиторий в feature-тесте, вы не проверяете реальную работу с базой. Моки уместны в юнит-тестах, но не в интеграционных.
  • Медленные тесты без причины: использование sleep() в тестах, запросы к реальным внешним сервисам, отсутствие индексов в тестовой БД — всё это делает тесты медленными и нестабильными.
  • Игнорирование контрактных изменений: изменение структуры API-ответа без обновления контракта Pact — прямой путь к интеграционным ошибкам на проде.

Заключение и рекомендации

Построение надёжного тестового покрытия для REST API на PHP — это итеративный процесс. Начните с простого: добейтесь хорошего покрытия юнит-тестами бизнес-логики и feature-тестами основных эндпоинтов. Добавьте интеграционные тесты с реальным PostgreSQL для критичных сценариев, где важно поведение конкретной СУБД.

Когда ваши сервисы начнут активно взаимодействовать друг с другом, внедрите контрактное тестирование с Pact. Это особенно ценно в командах, где над разными сервисами работают разные разработчики — контракты становятся живой документацией и защитным барьером от регрессий.

Ключевые рекомендации на основе изложенного:

  1. Следуйте пирамиде тестирования: больше юнит-тестов, меньше E2E-тестов.
  2. Используйте Docker для воспроизводимой тестовой среды с PostgreSQL.
  3. Автоматизируйте запуск всех тестов в CI/CD — каждый PR должен проходить полный прогон.
  4. Внедряйте Pact постепенно: начните с одной пары consumer-provider.
  5. Регулярно анализируйте отчёты покрытия, но не гонитесь за 100% — важно качество, а не цифра.
  6. Документируйте тестовые сценарии: хорошие названия тестов — это лучшая документация API.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →