Tests de integración en PHP con PHPUnit: base de datos real y transacciones

Los tests de integración verifican que tu código funciona correctamente con sus dependencias reales: la base de datos, el sistema de ficheros, servicios externos… A diferencia de los tests unitarios (que usan mocks), los tests de integración ejecutan queries reales contra una BD de test. Son más lentos pero detectan errores que los mocks no pueden capturar: queries incorrectas, restricciones de integridad, índices que faltan.

Preparar una BD de test en PHPUnit

La clave es usar una BD separada (o SQLite en memoria para tests muy rápidos) y resetear su estado entre tests para garantizar el aislamiento:

<?php
// phpunit.xml
// <env name="DB_DSN"  value="mysql:host=127.0.0.1;dbname=mi_app_test"/>
// <env name="DB_USER" value="test"/>
// <env name="DB_PASS" value="test"/>

DatabaseTestCase: transacciones con rollback en cada test

La técnica más eficiente: envolver cada test en una transacción y hacer rollback al final. La BD nunca queda sucia y no necesitas truncar tablas entre tests:

<?php
namespace TestsIntegration;

use PHPUnitFrameworkTestCase;

abstract class DatabaseTestCase extends TestCase {
    protected PDO $pdo;

    protected function setUp(): void {
        $this->pdo = new PDO(
            $_ENV['DB_DSN'],
            $_ENV['DB_USER'],
            $_ENV['DB_PASS'],
            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
        );
        $this->pdo->beginTransaction(); // Envolver el test en una transacción
    }

    protected function tearDown(): void {
        $this->pdo->rollBack();         // Deshacer todos los cambios del test
    }
}

Fixtures en setUp()

<?php
class UsuarioRepositorioTest extends DatabaseTestCase {
    private UsuarioRepositorio $repositorio;

    protected function setUp(): void {
        parent::setUp(); // Inicia la transacción
        $this->repositorio = new UsuarioRepositorio($this->pdo);

        // Fixtures: datos de prueba para este test
        $this->pdo->exec(
            "INSERT INTO usuarios (id, nombre, email, activo) VALUES
             (1, 'Ana García', '[email protected]', 1),
             (2, 'Luis Pérez', '[email protected]', 0)"
        );
    }

    public function testBuscarPorEmailDevuelveUsuarioCorrecto(): void {
        $usuario = $this->repositorio->buscarPorEmail('[email protected]');

        $this->assertNotNull($usuario);
        $this->assertSame('Ana García', $usuario->nombre);
        $this->assertSame(1, $usuario->activo);
    }

    public function testBuscarPorEmailDevuelveNullSiNoExiste(): void {
        $usuario = $this->repositorio->buscarPorEmail('[email protected]');
        $this->assertNull($usuario);
    }
}

Ejemplo real: test de repositorio CRUD

<?php
class ProductoRepositorioIntTest extends DatabaseTestCase {
    private ProductoRepositorio $repo;

    protected function setUp(): void {
        parent::setUp();
        $this->repo = new ProductoRepositorio($this->pdo);
    }

    public function testCrearYRecuperarProducto(): void {
        $id = $this->repo->crear([
            'nombre'    => 'Teclado mecánico',
            'precio'    => 79.99,
            'categoria' => 'periféricos',
        ]);

        $this->assertIsInt($id);
        $this->assertGreaterThan(0, $id);

        $producto = $this->repo->buscarPorId($id);
        $this->assertSame('Teclado mecánico', $producto['nombre']);
        $this->assertSame(79.99, (float) $producto['precio']);
    }

    public function testActualizarPrecio(): void {
        $id = $this->repo->crear(['nombre' => 'Ratón', 'precio' => 25.0]);
        $this->repo->actualizarPrecio($id, 19.99);

        $producto = $this->repo->buscarPorId($id);
        $this->assertSame(19.99, (float) $producto['precio']);
    }

    public function testEliminarBorraElRegistro(): void {
        $id = $this->repo->crear(['nombre' => 'Monitor', 'precio' => 299.0]);
        $this->repo->eliminar($id);

        $this->assertNull($this->repo->buscarPorId($id));
    }

    public function testListarDevuelveOrdenadosPorPrecio(): void {
        $this->repo->crear(['nombre' => 'C', 'precio' => 300.0]);
        $this->repo->crear(['nombre' => 'A', 'precio' => 100.0]);
        $this->repo->crear(['nombre' => 'B', 'precio' => 200.0]);

        $lista = $this->repo->listarPorPrecio();
        $this->assertSame('A', $lista[0]['nombre']);
        $this->assertSame('B', $lista[1]['nombre']);
        $this->assertSame('C', $lista[2]['nombre']);
    }
}

Mock vs integración real: cuándo usar cada uno

Aspecto Mock Test de integración real
Velocidad Muy rápido (ms) Más lento (decenas de ms)
Detecta errores de SQL No
Detecta errores de índice No
Detecta problemas N+1 No
Requiere BD configurada No

La regla práctica: usa mocks para la lógica de negocio y tests de integración para los repositorios y servicios que interactúan con la BD.

Configurar la BD de test en CI/CD

# .github/workflows/tests.yml
jobs:
  test:
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE:      mi_app_test
          MYSQL_USER:          test
          MYSQL_PASSWORD:      test
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=5s

    steps:
      - run: php artisan migrate --env=testing
      - run: vendor/bin/phpunit --testsuite=integration

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP