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

Построение надёжного REST API на Laravel с автоматическим тестированием и документацией в CI/CD пайплайне

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

Введение: почему тестирование и документация API критичны в 2026 году

В 2026 году REST API — это не просто набор эндпоинтов, это контракт между командами, системами и бизнесом. Сломанный эндпоинт в продакшене стоит денег и репутации. Устаревшая документация заставляет фронтенд-команды тратить часы на отладку вместо разработки. CI/CD закрывает оба вопроса: каждый коммит автоматически проходит через тесты, генерирует актуальную документацию и только после этого попадает в продакшен.

Laravel остаётся одним из самых популярных PHP-фреймворков для построения API благодаря встроенной поддержке тестирования, богатой экосистеме и выразительному синтаксису. В этой статье мы пройдём весь путь: от архитектуры проекта до деплоя документации через GitHub Actions.

Архитектура проекта: слои, паттерны и структура

Хорошо структурированный Laravel-проект — основа тестируемого кода. Используем Repository и Service паттерны для разделения ответственности.

Типичная структура директорий для API-проекта:

app/
├── Http/
│   ├── Controllers/Api/V1/
│   │   ├── AuthController.php
│   │   └── ProductController.php
│   ├── Requests/
│   │   └── StoreProductRequest.php
│   └── Resources/
│       └── ProductResource.php
├── Services/
│   └── ProductService.php
├── Repositories/
│   ├── Contracts/
│   │   └── ProductRepositoryInterface.php
│   └── ProductRepository.php
└── Models/
    └── Product.php

Сервисный слой содержит бизнес-логику, репозиторий отвечает за взаимодействие с базой данных. Контроллер остаётся тонким — только принять запрос, делегировать и вернуть ответ.

<?php

namespace App\Services;

use App\Models\Product;
use App\Repositories\Contracts\ProductRepositoryInterface;
use Illuminate\Pagination\LengthAwarePaginator;

class ProductService
{
    public function __construct(
        private readonly ProductRepositoryInterface $repository
    ) {}

    public function getPaginated(int $perPage = 15): LengthAwarePaginator
    {
        return $this->repository->paginate($perPage);
    }

    public function create(array $data): Product
    {
        return $this->repository->create($data);
    }
}
<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Requests\StoreProductRequest;
use App\Http\Resources\ProductResource;
use App\Services\ProductService;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class ProductController extends ApiController
{
    public function __construct(
        private readonly ProductService $service
    ) {}

    public function index(): AnonymousResourceCollection
    {
        return ProductResource::collection(
            $this->service->getPaginated()
        );
    }

    public function store(StoreProductRequest $request): ProductResource
    {
        $product = $this->service->create($request->validated());
        return new ProductResource($product);
    }
}

Такая архитектура позволяет подменять реализации в тестах через DI-контейнер Laravel, не трогая бизнес-логику.

Написание Feature-тестов для REST API на Laravel

Laravel поставляется с PHPUnit из коробки. Дополнительно рекомендуем установить Pest — более выразительный синтаксис без бойлерплейта.

composer require pestphp/pest pestphp/pest-plugin-laravel --dev
./vendor/bin/pest --init

Фабрики и фикстуры

Model Factories — основа изолированных тестов. Не используйте общую базу данных для тестов: применяйте трейт RefreshDatabase или DatabaseTransactions.

<?php

namespace Database\Factories;

use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;

class ProductFactory extends Factory
{
    protected $model = Product::class;

    public function definition(): array
    {
        return [
            'name'        => $this->faker->words(3, true),
            'price'       => $this->faker->randomFloat(2, 10, 1000),
            'description' => $this->faker->paragraph(),
            'sku'         => strtoupper($this->faker->bothify('??-####')),
            'in_stock'    => true,
        ];
    }

    public function outOfStock(): static
    {
        return $this->state(['in_stock' => false]);
    }
}

Feature-тест с авторизацией (Pest)

<?php

use App\Models\Product;
use App\Models\User;
use Laravel\Sanctum\Sanctum;

uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class);

describe('Products API', function () {

    beforeEach(function () {
        $this->user = User::factory()->create();
        Sanctum::actingAs($this->user);
    });

    it('returns paginated list of products', function () {
        Product::factory()->count(20)->create();

        $this->getJson('/api/v1/products')
            ->assertOk()
            ->assertJsonStructure([
                'data' => [['id', 'name', 'price', 'sku']],
                'meta' => ['current_page', 'total', 'per_page'],
            ])
            ->assertJsonCount(15, 'data');
    });

    it('creates a product with valid data', function () {
        $payload = Product::factory()->make()->toArray();

        $this->postJson('/api/v1/products', $payload)
            ->assertCreated()
            ->assertJsonPath('data.name', $payload['name']);

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

    it('returns 422 when price is missing', function () {
        $this->postJson('/api/v1/products', ['name' => 'Test'])
            ->assertUnprocessable()
            ->assertJsonValidationErrors(['price']);
    });

    it('returns 401 for unauthenticated request', function () {
        // Сброс аутентификации
        $this->withoutMiddleware(\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class);

        $this->getJson('/api/v1/products', ['Authorization' => ''])
            ->assertUnauthorized();
    });
});

Измерение покрытия кода

Добавьте в phpunit.xml настройки покрытия:

<coverage>
    <include>
        <directory suffix=".php">./app</directory>
    </include>
    <report>
        <html outputDirectory="coverage-report"/>
        <clover outputFile="coverage.xml"/>
    </report>
</coverage>

Запуск с покрытием: ./vendor/bin/pest --coverage --min=80 — флаг --min завалит сборку, если покрытие ниже 80%.

Контрактное тестирование REST API

Контрактное тестирование гарантирует, что API соответствует согласованному контракту — структуре запросов и ответов, которую ожидают потребители. Это критично при микросервисной архитектуре.

Для PHP/Laravel подходят два инструмента:

  • Pact PHP (pact-foundation/pact-php) — полноценный Pact-совместимый фреймворк, поддерживает Pact Broker.
  • Spectator (hotmeteor/spectator) — более простой вариант: валидирует запросы и ответы против вашего OpenAPI-файла прямо в PHPUnit/Pest тестах.

Пример с Spectator:

composer require hotmeteor/spectator --dev
<?php

use Spectator\Spectator;

uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(fn() => Spectator::using('api-v1.yaml'));

it('GET /products matches OpenAPI spec', function () {
    Product::factory()->count(5)->create();

    $this->getJson('/api/v1/products')
        ->assertValidRequest()
        ->assertValidResponse(200);
});

Если структура ответа расходится со схемой api-v1.yaml, тест завалится. Это исключает ситуацию «документация говорит одно, API отвечает другое».

Автогенерация OpenAPI/Swagger документации из кода

Ручное написание Swagger-документации устаревает быстрее кода. Решение — генерировать документацию автоматически из аннотаций или атрибутов PHP.

Пакет darkaonline/l5-swagger

composer require darkaonline/l5-swagger
php artisan vendor:publish --provider="L5Swagger\L5SwaggerServiceProvider"

Аннотируйте контроллеры с помощью атрибутов OpenApi:

<?php

use OpenApi\Attributes as OA;

#[OA\Get(
    path: '/api/v1/products',
    summary: 'Список продуктов',
    tags: ['Products'],
    parameters: [
        new OA\Parameter(
            name: 'page',
            in: 'query',
            required: false,
            schema: new OA\Schema(type: 'integer', default: 1)
        )
    ],
    responses: [
        new OA\Response(
            response: 200,
            description: 'Успешный ответ',
            content: new OA\JsonContent(
                properties: [
                    new OA\Property(
                        property: 'data',
                        type: 'array',
                        items: new OA\Items(ref: '#/components/schemas/Product')
                    )
                ]
            )
        ),
        new OA\Response(response: 401, description: 'Не авторизован')
    ]
)]
public function index(): AnonymousResourceCollection
{
    return ProductResource::collection($this->service->getPaginated());
}

Генерация документа: php artisan l5-swagger:generate. Результат — файл storage/api-docs/api-docs.json, который можно подключить к Swagger UI или ReDoc.

Альтернатива: knuckleswtf/scribe

Scribe анализирует FormRequest-классы, Route-аннотации и тест-трассы, генерируя документацию с минимальными аннотациями. Подходит, если вы хотите получить документацию быстро без детальной разметки:

composer require knuckleswtf/scribe --dev
php artisan scribe:generate

Интеграция в CI/CD: GitHub Actions

Весь цикл — тесты, покрытие, генерация документации, деплой — должен запускаться автоматически при каждом пуше в основные ветки.

name: Laravel API CI/CD

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

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: api_test
          POSTGRES_USER: api_user
          POSTGRES_PASSWORD: secret
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: --health-cmd "redis-cli ping" --health-interval 10s

    steps:
      - uses: actions/checkout@v4

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

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist --optimize-autoloader

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

      - name: Generate app key
        run: php artisan key:generate --env=testing

      - name: Run migrations
        env:
          DB_CONNECTION: pgsql
          DB_HOST: 127.0.0.1
          DB_PORT: 5432
          DB_DATABASE: api_test
          DB_USERNAME: api_user
          DB_PASSWORD: secret
        run: php artisan migrate --env=testing --force

      - name: Run Pest tests with coverage
        env:
          DB_CONNECTION: pgsql
          DB_HOST: 127.0.0.1
          DB_PORT: 5432
          DB_DATABASE: api_test
          DB_USERNAME: api_user
          DB_PASSWORD: secret
          REDIS_HOST: 127.0.0.1
        run: ./vendor/bin/pest --coverage --min=80 --coverage-clover=coverage.xml

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

  generate-docs:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

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

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

      - name: Generate Swagger docs
        run: php artisan l5-swagger:generate

      - name: Deploy docs to GitHub Pages
        uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./storage/api-docs
          destination_dir: api-docs

Ключевые моменты этого пайплайна:

  • Job test поднимает PostgreSQL 16 и Redis 7 как сервисы GitHub Actions — это изолированное окружение, идентичное продакшену.
  • Флаг --min=80 в Pest останавливает пайплайн, если покрытие ниже порога.
  • Job generate-docs запускается только после успешного прохождения тестов (needs: test) и только в ветке main.
  • Документация автоматически публикуется на GitHub Pages.

Docker для тестовой среды

Для локальной разработки и воспроизводимости среды используем Docker. Файл docker-compose.testing.yml:

version: '3.9'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.testing
    volumes:
      - .:/var/www/html
    environment:
      APP_ENV: testing
      DB_CONNECTION: pgsql
      DB_HOST: postgres
      DB_DATABASE: api_test
      DB_USERNAME: api_user
      DB_PASSWORD: secret
      REDIS_HOST: redis
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    command: ./vendor/bin/pest --coverage

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: api_test
      POSTGRES_USER: api_user
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U api_user -d api_test"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

Dockerfile.testing — минималистичный образ для тестов:

FROM php:8.3-cli-alpine

RUN apk add --no-cache postgresql-dev \
    && docker-php-ext-install pdo_pgsql pcntl \
    && pecl install redis pcov \
    && docker-php-ext-enable redis pcov

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

Запуск тестов локально: docker compose -f docker-compose.testing.yml up --abort-on-container-exit. Это полностью воспроизводит CI-окружение на машине разработчика.

Практические советы: что тестировать обязательно

Обязательно тестируйте

  • Happy path каждого эндпоинта — корректные данные, ожидаемый статус и структура ответа.
  • Авторизацию и права доступа — неавторизованный запрос должен возвращать 401, запрос без нужной роли — 403.
  • Валидацию входных данных — отсутствующие обязательные поля, неверные типы, граничные значения.
  • Пагинацию — корректность метаданных meta.total, meta.per_page, поведение на последней странице.
  • Конкурентные запросы на критичные операции — например, двойное списание баланса.
  • Соответствие ответа OpenAPI-схеме через Spectator.

Можно пропустить или отложить

  • Тестирование сторонних SDK и библиотек — они уже протестированы их авторами.
  • Тривиальные геттеры/сеттеры моделей без логики.
  • Сложные UI-сценарии, не связанные с API-контрактом.

Практические правила

  • Один тест — один сценарий. Не проверяйте в одном тесте и создание, и удаление.
  • Используйте assertJsonPath() вместо assertJson() для точечных проверок без жёсткой привязки к полной структуре.
  • Мокайте внешние HTTP-запросы через Http::fake() — тесты не должны зависеть от сети.
  • Добавляйте тест на каждый найденный баг перед его исправлением — это предотвращает регрессии.

Заключение и чеклист для production-ready API

Production-ready Laravel REST API в 2026 году — это не просто рабочий код. Это предсказуемый контракт, автоматически верифицируемый при каждом изменении. CI/CD объединяет тестирование, документацию и деплой в единый автоматизированный процесс, который устраняет человеческий фактор из критических операций.

Код без тестов — это код, который вы боитесь трогать. Документация без автогенерации — это документация, которой никто не доверяет.

Чеклист production-ready Laravel API

  1. Архитектура разделена на слои: Controller → Service → Repository → Model.
  2. Все публичные эндпоинты покрыты Feature-тестами (happy path + edge cases).
  3. Авторизация протестирована: 401 для неавторизованных, 403 для запрещённых действий.
  4. Валидация проверена на невалидные данные с проверкой кодов ошибок.
  5. Контрактные тесты через Spectator валидируют ответы против OpenAPI-схемы.
  6. Swagger/OpenAPI документация генерируется автоматически из аннотаций.
  7. GitHub Actions запускает тесты на каждый PR и пуш в main.
  8. Покрытие кода не ниже 80%, минимальный порог встроен в CI.
  9. Docker изолирует тестовую среду с реальными PostgreSQL и Redis.
  10. Документация автоматически деплоится при мерже в main.
  11. Внешние HTTP-запросы мокируются через Http::fake().
  12. Каждый найденный баг сопровождается регрессионным тестом.

Технологии

Теги

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

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