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
@dependscom 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 slowpara 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
- PHPUnit Documentation - Database Testing — Guia oficial do PHPUnit para testes de banco de dados, incluindo DatabaseTestCase e extensões.
- PHP The Right Way - Testing — Seção sobre boas práticas de testes em PHP, com ênfase em integração.
- Laravel Docs - Database Testing — Documentação oficial do Laravel sobre testes de banco, com exemplos de factories, seeds e refresh.
- Symfony Docs - Testing with Doctrine — Guia do Symfony para testar a camada de persistência com Doctrine ORM.
- PHP Database TestCase on GitHub — Código-fonte do PHPUnit TestCase, base para implementações de testes de banco.
- SQLite Documentation - In-Memory Databases — Documentação oficial sobre bancos SQLite em memória, úteis para testes rápidos.
- Martin Fowler - UnitTest — Artigo clássico sobre a diferença entre testes unitários e de integração.