Pruebas de REST API en PHP: desde pruebas unitarias hasta pruebas de contrato con Pact
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:
- El consumer escribe una prueba que describe la interacción esperada con la API.
- Pact genera un archivo JSON de contrato y levanta un servidor mock para la prueba.
- El contrato se publica en el Pact Broker.
- 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
--processesen 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(). UseCache::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.testingdebe 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 oMockHandleren 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,ETagy 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:
- Siga la pirámide de pruebas: más pruebas unitarias, menos pruebas E2E.
- Use Docker para un entorno de pruebas reproducible con PostgreSQL.
- Automatice la ejecución de todas las pruebas en CI/CD — cada PR debe pasar el ciclo completo.
- Implemente Pact de forma gradual: comience con un único par consumer-provider.
- Analice regularmente los informes de cobertura, pero no persiga el 100% — lo importante es la calidad, no el número.
- 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í →