Testes de integração com banco de dados

1. Fundamentos dos Testes de Integração com Banco de Dados

Testes de integração com banco de dados verificam se o código interage corretamente com a camada de persistência, validando consultas, transações e restrições do schema. Diferentemente dos testes unitários, que isolam uma única unidade de código usando mocks, os testes de integração executam operações reais no banco, garantindo que o SQL gerado, as constraints e o comportamento transacional funcionem como esperado.

A necessidade de testar a persistência é crítica porque erros nessa camada podem corromper dados ou causar falhas silenciosas. Os principais desafios incluem o estado compartilhado entre testes (dados residuais), concorrência ao acessar o mesmo banco e a lentidão quando comparados a testes unitários.

2. Configuração do Ambiente de Teste

A primeira decisão é escolher o banco de dados de teste. SQLite em memória oferece velocidade e isolamento, mas pode não suportar todas as funcionalidades do banco de produção (MySQL/PostgreSQL). Uma alternativa é usar um banco MySQL dedicado com schema recriado a cada execução.

<?php
// config/test_database.php
return [
    'sqlite_memory' => [
        'dsn' => 'sqlite::memory:',
        'username' => null,
        'password' => null,
    ],
    'mysql_test' => [
        'dsn' => 'mysql:host=127.0.0.1;dbname=test_db;charset=utf8mb4',
        'username' => 'test_user',
        'password' => 'test_pass',
    ],
];

Gerenciar o schema é essencial. A estratégia mais comum é executar as migrações no setUpBeforeClass() e reverter ao final da classe de teste. Seeds podem ser inseridos antes de cada teste individual.

<?php
use PHPUnit\Framework\TestCase;

class DatabaseTestCase extends TestCase
{
    protected static PDO $pdo;

    public static function setUpBeforeClass(): void
    {
        $config = require 'config/test_database.php';
        self::$pdo = new PDO($config['sqlite_memory']['dsn']);
        self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        self::runMigrations();
    }

    private static function runMigrations(): void
    {
        $schema = "
            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                email TEXT NOT NULL UNIQUE,
                created_at DATETIME DEFAULT CURRENT_TIMESTAMP
            );
            CREATE TABLE IF NOT EXISTS orders (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                total DECIMAL(10,2) NOT NULL,
                FOREIGN KEY (user_id) REFERENCES users(id)
            );
        ";
        self::$pdo->exec($schema);
    }
}

3. PHPUnit e Database TestCase

O PHPUnit fornece hooks como setUp() e tearDown() que são executados antes e depois de cada teste. Uma abordagem poderosa é usar transações com rollback automático: iniciar uma transação no setUp() e reverter no tearDown(), garantindo que cada teste comece com o banco limpo.

<?php
class UserRepositoryTest extends DatabaseTestCase
{
    private UserRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();
        self::$pdo->beginTransaction();
        $this->repository = new UserRepository(self::$pdo);
    }

    protected function tearDown(): void
    {
        self::$pdo->rollBack();
        parent::tearDown();
    }
}

4. Estratégias de Isolamento de Dados

Além das transações com rollback, podemos usar truncate em lote ou fixtures controladas. Fixtures são dados pré-definidos que garantem um estado conhecido. Podem ser arrays PHP, arquivos YAML ou JSON.

<?php
// fixtures/users.php
return [
    ['name' => 'Alice', 'email' => 'alice@example.com'],
    ['name' => 'Bob', 'email' => 'bob@example.com'],
];

// No setUp()
protected function setUp(): void
{
    parent::setUp();
    self::$pdo->beginTransaction();
    $this->loadFixtures('users');
}

private function loadFixtures(string $fixture): void
{
    $data = require "fixtures/{$fixture}.php";
    $stmt = self::$pdo->prepare('INSERT INTO users (name, email) VALUES (:name, :email)');
    foreach ($data as $row) {
        $stmt->execute($row);
    }
}

5. Escrevendo Testes de Integração Eficazes

Testes de CRUD verificam cada operação individualmente, incluindo constraints e chaves estrangeiras.

<?php
class UserRepositoryTest extends DatabaseTestCase
{
    public function testInsertUser(): void
    {
        $user = new User(name: 'Charlie', email: 'charlie@example.com');
        $id = $this->repository->save($user);

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

    public function testFindById(): void
    {
        $id = $this->repository->save(new User(name: 'Diana', email: 'diana@example.com'));
        $found = $this->repository->findById($id);

        $this->assertNotNull($found);
        $this->assertEquals('Diana', $found->name);
    }

    public function testUniqueEmailConstraint(): void
    {
        $this->repository->save(new User(name: 'Eve', email: 'eve@example.com'));

        $this->expectException(PDOException::class);
        $this->expectExceptionMessageMatches('/UNIQUE|duplicate/');
        $this->repository->save(new User(name: 'Eve2', email: 'eve@example.com'));
    }

    public function testForeignKeyConstraint(): void
    {
        $this->expectException(PDOException::class);
        $this->expectExceptionMessageMatches('/FOREIGN KEY|constraint/');

        $orderRepository = new OrderRepository(self::$pdo);
        $orderRepository->save(new Order(userId: 9999, total: 100.00));
    }
}

6. Lidando com Dependências Externas

Para testar serviços que dependem de APIs externas, use mocks apenas para essas chamadas, mantendo o banco real. Para testar transações, force um erro após uma operação bem-sucedida e verifique se o rollback ocorre.

<?php
public function testTransactionalBehavior(): void
{
    self::$pdo->beginTransaction();

    try {
        $this->repository->save(new User(name: 'Test', email: 'test@example.com'));
        // Simula falha após inserção
        throw new \RuntimeException('Forçando rollback');
    } catch (\RuntimeException $e) {
        self::$pdo->rollBack();
    }

    $result = $this->repository->findByEmail('test@example.com');
    $this->assertNull($result, 'Usuário não deve existir após rollback');
}

7. Boas Práticas e Armadilhas Comuns

  • Evite dependência de ordem: Use @depends com cuidado; prefira fixtures independentes.
  • Gerencie seeds vs. dados de teste: Seeds são para desenvolvimento, não para testes. Crie fixtures específicas.
  • Otimize fixtures: Use poucos registros e evite carregar dados desnecessários.
  • Monitore tempo: Testes de integração são mais lentos; use @group slow para separá-los.

8. Exemplo Prático: Testando um Repositório de Usuários

Implementação completa do repositório e seus testes:

<?php
// UserRepository.php
class UserRepository
{
    private PDO $pdo;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    public function save(User $user): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO users (name, email) VALUES (:name, :email)'
        );
        $stmt->execute([
            'name' => $user->name,
            'email' => $user->email,
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function findById(int $id): ?User
    {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
        $stmt->execute(['id' => $id]);
        $data = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$data) {
            return null;
        }

        return new User(name: $data['name'], email: $data['email'], id: $data['id']);
    }

    public function update(User $user): bool
    {
        $stmt = $this->pdo->prepare(
            'UPDATE users SET name = :name, email = :email WHERE id = :id'
        );
        return $stmt->execute([
            'name' => $user->name,
            'email' => $user->email,
            'id' => $user->id,
        ]);
    }

    public function delete(int $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM users WHERE id = :id');
        return $stmt->execute(['id' => $id]);
    }
}

// Testes completos
class UserRepositoryFullTest extends DatabaseTestCase
{
    private UserRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();
        self::$pdo->beginTransaction();
        $this->repository = new UserRepository(self::$pdo);
    }

    protected function tearDown(): void
    {
        self::$pdo->rollBack();
        parent::tearDown();
    }

    public function testFullCrud(): void
    {
        // Create
        $id = $this->repository->save(new User(name: 'John', email: 'john@example.com'));
        $this->assertIsInt($id);

        // Read
        $user = $this->repository->findById($id);
        $this->assertNotNull($user);
        $this->assertEquals('John', $user->name);

        // Update
        $user->name = 'John Updated';
        $updated = $this->repository->update($user);
        $this->assertTrue($updated);

        $userAfterUpdate = $this->repository->findById($id);
        $this->assertEquals('John Updated', $userAfterUpdate->name);

        // Delete
        $deleted = $this->repository->delete($id);
        $this->assertTrue($deleted);

        $userAfterDelete = $this->repository->findById($id);
        $this->assertNull($userAfterDelete);
    }

    public function testIntegrityViolation(): void
    {
        $this->repository->save(new User(name: 'Unique', email: 'unique@example.com'));

        $this->expectException(PDOException::class);
        $this->repository->save(new User(name: 'Duplicate', email: 'unique@example.com'));
    }
}

Referências