Desarrollo backend

Pruebas de REST API en PHP: desde pruebas unitarias hasta pruebas de contrato con Pact

Ruslan Ismailov Publicado 14 min de lectura
P

Introducción: la pirámide de pruebas aplicada a las API

Desarrollar una REST API confiable es imposible sin una estrategia de pruebas bien pensada. La clásica pirámide de pruebas sigue siendo válida en 2026: en la base, pruebas unitarias rápidas; en el centro, pruebas de integración y de características; en la cima, pruebas end-to-end más lentas. Para arquitecturas de microservicios, se añade un nivel adicional: las pruebas de contrato, que permiten a los equipos acordar la interacción entre servicios sin necesidad de levantar toda la infraestructura.

En este artículo recorreremos todo el camino: desde la escritura de las primeras pruebas unitarias para la lógica de negocio en Laravel hasta la configuración del broker de Pact en un pipeline CI/CD. El público objetivo son desarrolladores PHP que desean construir una cobertura de pruebas madura para sus servicios API.

Pruebas unitarias para la lógica de negocio en Laravel

Las pruebas unitarias verifican partes aisladas del código —servicios, helpers, value objects— sin acceder a la base de datos ni al stack HTTP. Laravel incluye PHPUnit de serie, mientras que Pest ofrece una sintaxis más expresiva basada en él.

Veamos un ejemplo de un servicio de cálculo de descuentos:

<?php

namespace App\Services;

class DiscountService
{
    public function calculate(float $price, int $discountPercent): float
    {
        if ($discountPercent < 0 || $discountPercent > 100) {
            throw new \InvalidArgumentException('El descuento debe estar entre 0 y 100%');
        }
        return round($price * (1 - $discountPercent / 100), 2);
    }
}

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

La misma prueba con Pest resulta más concisa:

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

La regla clave de las pruebas unitarias: ninguna dependencia real. Los repositorios y servicios externos se simulan mediante Mockery o las capacidades integradas de PHPUnit.

Pruebas de características de endpoints HTTP en Laravel

Las pruebas de características verifican el comportamiento de todo el stack HTTP: enrutamiento, middleware, controladores y serialización de respuestas. Laravel proporciona un cómodo cliente de prueba a través de la clase Tests\TestCase, que hereda de Illuminate\Foundation\Testing\TestCase.

Ejemplo de un endpoint de creación de productos y su prueba de características:

<?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'  => 'Producto de prueba',
                'price' => 999.99,
                'sku'   => 'SKU-001',
            ]);

        $response
            ->assertStatus(201)
            ->assertJson([
                'data' => [
                    'name'  => 'Producto de prueba',
                    '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' => '', // nombre vacío
            ]);

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

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

Observe el uso de RefreshDatabase — este trait revierte las transacciones después de cada prueba, manteniendo la velocidad de ejecución. Los métodos assertJson, assertStatus, assertJsonStructure y assertJsonValidationErrors cubren la mayoría de los escenarios de validación de respuestas API.

Pruebas de integración con una base de datos PostgreSQL real en Docker

Las pruebas de características con RefreshDatabase utilizan SQLite por defecto, lo cual es conveniente, pero no refleja el comportamiento real de PostgreSQL —especialmente al usar campos JSON, búsqueda de texto completo o restricciones específicas. Para las pruebas de integración se necesita una base de datos real.

Levantamos el entorno de pruebas con 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  # almacenamiento en memoria para mayor velocidad

Configuramos phpunit.xml para el entorno de pruebas:

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

Para las pruebas que requieren características específicas de PostgreSQL, usamos el trait DatabaseMigrations en lugar de RefreshDatabase — este aplica y revierte completamente las migraciones, garantizando un esquema limpio.

<?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' => 'Auriculares inalámbricos Sony']);
        Product::factory()->create(['name' => 'Auriculares con cable Sennheiser']);

        $response = $this->getJson('/api/products?search=inalámbricos');

        $response
            ->assertStatus(200)
            ->assertJsonCount(1, 'data')
            ->assertJsonPath('data.0.name', 'Auriculares inalámbricos Sony');
    }
}

Pruebas de contrato: qué son y por qué son necesarias en microservicios

En una arquitectura de microservicios, los servicios se comunican a través de APIs. El problema clásico: el equipo del servicio A modifica la respuesta de un endpoint, y el servicio B, que lo consume, solo se entera en producción. Las pruebas de integración que requieren ejecutar todos los servicios simultáneamente son lentas, frágiles y difíciles de mantener.

Las pruebas de contrato resuelven este problema de otra manera: cada consumer (consumidor de la API) describe sus expectativas en forma de contrato, y el provider (proveedor de la API) verifica que cumple esos contratos. Los servicios se prueban de forma independiente, pero los acuerdos quedan garantizados.

Las pruebas de contrato no reemplazan a las pruebas de integración, sino que las complementan. Se centran en la alineación de interfaces, no en la lógica de negocio de la interacción.

Introducción a Pact: pruebas de contrato orientadas al consumidor

Pact es el framework más popular para pruebas de contrato. El enfoque se denomina consumer-driven: es el consumidor quien define qué espera del proveedor. El flujo de trabajo es el siguiente:

  1. El consumer escribe una prueba que describe la interacción esperada con la API.
  2. Pact genera un archivo JSON de contrato y levanta un servidor mock para la prueba.
  3. El contrato se publica en el Pact Broker.
  4. El provider verifica el contrato ejecutando el servicio real frente a las expectativas del consumer.

Instalación del cliente PHP de Pact:

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

Prueba en el lado del consumer (servicio de pedidos que llama al servicio de productos):

<?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('Producto de prueba'),
                    '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);

        // Realizamos la petición HTTP real al servidor mock de 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);

        // Finalizamos el contrato y escribimos el archivo pact
        $builder->verify();
    }
}

Verificación en el lado del provider (servicio de productos):

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

Integración de pruebas en el pipeline CI/CD

Un pipeline CI/CD bien configurado ejecuta las pruebas en cada pull request y bloquea el despliegue si alguna falla. Ejemplo de configuración para 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 }}

Separar en jobs independientes permite ejecutar las pruebas de contrato solo después de que las pruebas unitarias y de características hayan pasado exitosamente —lo que ahorra recursos y acelera la retroalimentación.

Consejos para organizar el entorno de pruebas y el aislamiento de datos

La confiabilidad de las pruebas depende directamente del correcto aislamiento de los datos. Siga los siguientes principios:

  • Use las factories de Laravel (Model::factory()) para crear datos de prueba — son declarativas y fácilmente configurables. Evite hardcodear IDs reales o direcciones de correo electrónico.
  • Separe los conjuntos de pruebas (test suites) en phpunit.xml: Unit, Feature, Integration, ContractConsumer, ContractProvider. Esto permite ejecutar solo el nivel necesario.
  • Base de datos separada para cada worker de CI: al ejecutar pruebas en paralelo, use --processes en Pest o ParaTest, con nombres de bases de datos distintos (app_test_1, app_test_2).
  • No comparta estado entre pruebas: las variables estáticas, singletons y la caché de Redis deben reiniciarse en setUp()/tearDown(). Use Cache::flush() al inicio de las pruebas donde sea crítico.
  • Configuración de pruebas en .env.testing: nunca use variables de entorno de producción en las pruebas. El archivo .env.testing debe estar en el repositorio (sin secretos).
  • Simule los servicios externos: las pasarelas de pago, proveedores de correo electrónico y APIs de terceros deben simularse mediante Http::fake() en Laravel o MockHandler en Guzzle.

Antipatrones en las pruebas de API

Incluso con una gran cantidad de pruebas, la cobertura puede dar una falsa sensación de seguridad. Estos son los antipatrones típicos que conviene evitar:

  • Probar solo el camino feliz: se prueba únicamente el escenario exitoso. Añada pruebas para datos inválidos, valores límite, recursos inexistentes (404) y errores de autorización (401/403).
  • Dependencia entre pruebas: la prueba B depende de datos creados por la prueba A. Cada prueba debe crear sus propios datos de forma independiente.
  • Ignorar los encabezados de respuesta: una API no es solo el cuerpo de la respuesta. Verifique Content-Type, encabezados de paginación, ETag y otros metadatos relevantes.
  • Simular lo que se debe probar: si simula el repositorio en una prueba de características, no está verificando el trabajo real con la base de datos. Los mocks son apropiados en pruebas unitarias, pero no en pruebas de integración.
  • Pruebas lentas sin motivo: el uso de sleep() en las pruebas, peticiones a servicios externos reales y la ausencia de índices en la base de datos de prueba hacen que las pruebas sean lentas e inestables.
  • Ignorar los cambios en los contratos: modificar la estructura de una respuesta API sin actualizar el contrato de Pact es un camino directo a errores de integración en producción.

Conclusiones y recomendaciones

Construir una cobertura de pruebas sólida para una REST API en PHP es un proceso iterativo. Empiece por lo más sencillo: logre una buena cobertura con pruebas unitarias de la lógica de negocio y pruebas de características de los endpoints principales. Añada pruebas de integración con PostgreSQL real para los escenarios críticos donde el comportamiento específico del motor de base de datos es importante.

Cuando sus servicios comiencen a interactuar activamente entre sí, implemente pruebas de contrato con Pact. Esto es especialmente valioso en equipos donde diferentes desarrolladores trabajan en distintos servicios — los contratos se convierten en documentación viva y en una barrera de protección contra regresiones.

Recomendaciones clave basadas en lo expuesto:

  1. Siga la pirámide de pruebas: más pruebas unitarias, menos pruebas E2E.
  2. Use Docker para un entorno de pruebas reproducible con PostgreSQL.
  3. Automatice la ejecución de todas las pruebas en CI/CD — cada PR debe pasar el ciclo completo.
  4. Implemente Pact de forma gradual: comience con un único par consumer-provider.
  5. Analice regularmente los informes de cobertura, pero no persiga el 100% — lo importante es la calidad, no el número.
  6. Documente los escenarios de prueba: los buenos nombres de las pruebas son la mejor documentación de una API.

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í →