Desarrollo backend

Construcción de una API REST robusta en Laravel con pruebas automatizadas y documentación en un pipeline CI/CD

Ruslan Ismailov Publicado 18 min de lectura
C

Introducción: por qué las pruebas y la documentación de la API son críticas en 2026

En 2026, una API REST no es simplemente un conjunto de endpoints, sino un contrato entre equipos, sistemas y el negocio. Un endpoint roto en producción cuesta dinero y reputación. Una documentación desactualizada obliga a los equipos de frontend a perder horas depurando en lugar de desarrollar. CI/CD resuelve ambos problemas: cada commit pasa automáticamente por las pruebas, genera documentación actualizada y solo entonces llega a producción.

Laravel sigue siendo uno de los frameworks PHP más populares para construir APIs gracias a su soporte integrado de pruebas, su rico ecosistema y su sintaxis expresiva. En este artículo recorreremos todo el camino: desde la arquitectura del proyecto hasta el despliegue de la documentación mediante GitHub Actions.

Arquitectura del proyecto: capas, patrones y estructura

Un proyecto Laravel bien estructurado es la base de un código testeable. Utilizamos los patrones Repository y Service para separar responsabilidades.

Estructura de directorios típica para un proyecto 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

La capa de servicio contiene la lógica de negocio, el repositorio gestiona la interacción con la base de datos. El controlador permanece delgado: solo recibe la solicitud, delega y devuelve la respuesta.

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

Esta arquitectura permite sustituir implementaciones en las pruebas a través del contenedor DI de Laravel sin tocar la lógica de negocio.

Escritura de pruebas Feature para la API REST en Laravel

Laravel incluye PHPUnit de serie. Adicionalmente recomendamos instalar Pest, que ofrece una sintaxis más expresiva y sin boilerplate.

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

Factories y fixtures

Las Model Factories son la base de las pruebas aisladas. No utilices una base de datos compartida para las pruebas: aplica el trait RefreshDatabase o 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]);
    }
}

Prueba Feature con autorización (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 () {
        // Restablecer autenticación
        $this->withoutMiddleware(\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class);

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

Medición de la cobertura de código

Añade la configuración de cobertura a phpunit.xml:

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

Ejecución con cobertura: ./vendor/bin/pest --coverage --min=80 — el flag --min fallará la compilación si la cobertura es inferior al 80%.

Pruebas de contrato para la API REST

Las pruebas de contrato garantizan que la API cumple con el contrato acordado: la estructura de solicitudes y respuestas que esperan los consumidores. Esto es crítico en arquitecturas de microservicios.

Para PHP/Laravel existen dos herramientas adecuadas:

  • Pact PHP (pact-foundation/pact-php) — framework completo compatible con Pact, soporta Pact Broker.
  • Spectator (hotmeteor/spectator) — opción más sencilla: valida solicitudes y respuestas contra tu archivo OpenAPI directamente en pruebas PHPUnit/Pest.

Ejemplo con 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);
});

Si la estructura de la respuesta no coincide con el esquema api-v1.yaml, la prueba fallará. Esto elimina la situación en la que «la documentación dice una cosa y la API responde otra».

Generación automática de documentación OpenAPI/Swagger desde el código

Escribir la documentación Swagger a mano queda obsoleto más rápido que el propio código. La solución es generar la documentación automáticamente a partir de anotaciones o atributos de PHP.

Paquete darkaonline/l5-swagger

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

Anota los controladores con atributos de OpenApi:

<?php

use OpenApi\Attributes as OA;

#[OA\Get(
    path: '/api/v1/products',
    summary: 'Lista de productos',
    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: 'Respuesta exitosa',
            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: 'No autorizado')
    ]
)]
public function index(): AnonymousResourceCollection
{
    return ProductResource::collection($this->service->getPaginated());
}

Generación del documento: php artisan l5-swagger:generate. El resultado es el archivo storage/api-docs/api-docs.json, que se puede integrar con Swagger UI o ReDoc.

Alternativa: knuckleswtf/scribe

Scribe analiza las clases FormRequest, las anotaciones de rutas y los trazados de pruebas, generando documentación con un mínimo de anotaciones. Es ideal si quieres obtener documentación rápidamente sin un marcado detallado:

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

Integración en CI/CD: GitHub Actions

Todo el ciclo —pruebas, cobertura, generación de documentación y despliegue— debe ejecutarse automáticamente en cada push a las ramas principales.

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

Puntos clave de este pipeline:

  • El job test levanta PostgreSQL 16 y Redis 7 como servicios de GitHub Actions, creando un entorno aislado idéntico al de producción.
  • El flag --min=80 en Pest detiene el pipeline si la cobertura está por debajo del umbral.
  • El job generate-docs se ejecuta solo tras pasar las pruebas con éxito (needs: test) y únicamente en la rama main.
  • La documentación se publica automáticamente en GitHub Pages.

Docker para el entorno de pruebas

Para el desarrollo local y la reproducibilidad del entorno utilizamos Docker. Archivo 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 — imagen minimalista para las pruebas:

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

Ejecución de pruebas en local: docker compose -f docker-compose.testing.yml up --abort-on-container-exit. Esto reproduce completamente el entorno CI en la máquina del desarrollador.

Consejos prácticos: qué probar de forma obligatoria

Pruebas obligatorias

  • Happy path de cada endpoint — datos correctos, estado esperado y estructura de respuesta.
  • Autorización y permisos de acceso — una solicitud no autenticada debe devolver 401, y una sin el rol necesario, 403.
  • Validación de datos de entrada — campos obligatorios ausentes, tipos incorrectos, valores límite.
  • Paginación — exactitud de los metadatos meta.total, meta.per_page y comportamiento en la última página.
  • Solicitudes concurrentes en operaciones críticas — por ejemplo, doble débito de saldo.
  • Conformidad de la respuesta con el esquema OpenAPI mediante Spectator.

Se puede omitir o posponer

  • Pruebas de SDKs y bibliotecas de terceros: ya están probadas por sus autores.
  • Getters/setters triviales de modelos sin lógica.
  • Escenarios complejos de UI no relacionados con el contrato de la API.

Reglas prácticas

  • Una prueba, un escenario. No verifiques la creación y la eliminación en la misma prueba.
  • Usa assertJsonPath() en lugar de assertJson() para comprobaciones puntuales sin depender de la estructura completa.
  • Mockea las solicitudes HTTP externas con Http::fake(): las pruebas no deben depender de la red.
  • Añade una prueba por cada bug encontrado antes de corregirlo para prevenir regresiones.

Conclusión y checklist para una API lista para producción

Una API REST de Laravel lista para producción en 2026 no es solo código funcional. Es un contrato predecible, verificado automáticamente con cada cambio. CI/CD unifica las pruebas, la documentación y el despliegue en un proceso automatizado que elimina el factor humano de las operaciones críticas.

El código sin pruebas es código que tienes miedo de tocar. La documentación sin autogeneración es documentación en la que nadie confía.

Checklist de API Laravel lista para producción

  1. Arquitectura dividida en capas: Controller → Service → Repository → Model.
  2. Todos los endpoints públicos cubiertos con pruebas Feature (happy path + casos límite).
  3. Autorización probada: 401 para no autenticados, 403 para acciones prohibidas.
  4. Validación comprobada con datos inválidos y verificación de códigos de error.
  5. Pruebas de contrato con Spectator que validan las respuestas contra el esquema OpenAPI.
  6. La documentación Swagger/OpenAPI se genera automáticamente desde las anotaciones.
  7. GitHub Actions ejecuta las pruebas en cada PR y push a main.
  8. Cobertura de código no inferior al 80%, con umbral mínimo integrado en CI.
  9. Docker aísla el entorno de pruebas con PostgreSQL y Redis reales.
  10. La documentación se despliega automáticamente al hacer merge en main.
  11. Las solicitudes HTTP externas se mockean con Http::fake().
  12. Cada bug encontrado se acompaña de una prueba de regresión.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →