Тестирование REST API на PHP: от юнит-тестов до контрактного тестирования с Pact
Введение: пирамида тестирования применительно к 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: именно потребитель определяет, что он ожидает от провайдера. Рабочий процесс выглядит так:
- Consumer пишет тест, описывающий ожидаемое взаимодействие с API.
- Pact генерирует JSON-файл контракта и поднимает мок-сервер для теста.
- Контракт публикуется в Pact Broker.
- 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. Это особенно ценно в командах, где над разными сервисами работают разные разработчики — контракты становятся живой документацией и защитным барьером от регрессий.
Ключевые рекомендации на основе изложенного:
- Следуйте пирамиде тестирования: больше юнит-тестов, меньше E2E-тестов.
- Используйте Docker для воспроизводимой тестовой среды с PostgreSQL.
- Автоматизируйте запуск всех тестов в CI/CD — каждый PR должен проходить полный прогон.
- Внедряйте Pact постепенно: начните с одной пары consumer-provider.
- Регулярно анализируйте отчёты покрытия, но не гонитесь за 100% — важно качество, а не цифра.
- Документируйте тестовые сценарии: хорошие названия тестов — это лучшая документация API.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →