Database testing: isolamento e fixtures
1. Por que testar o banco de dados é diferente de testar código
Testar banco de dados apresenta desafios únicos que não existem no teste de código comum. Diferente de funções puras que operam apenas com entradas e saídas previsíveis, operações SQL interagem com um estado persistente que pode vazar entre execuções.
1.1 Estado compartilhado e efeitos colaterais entre testes
Quando múltiplos testes manipulam as mesmas tabelas, o resultado de um teste pode contaminar o próximo. Por exemplo:
-- Teste 1: insere um usuário
INSERT INTO usuarios (id, nome) VALUES (1, 'Alice');
-- Teste 2: espera que a tabela esteja vazia
SELECT COUNT(*) FROM usuarios; -- retorna 1, não 0
1.2 A natureza imperativa do SQL versus a idempotência desejada em testes
SQL é inerentemente imperativo: cada comando modifica o estado. Testes ideais são idempotentes (executados repetidamente produzem o mesmo resultado), mas operações como INSERT sem DELETE prévio quebram essa propriedade.
1.3 O desafio de concorrência e transações simultâneas
Testes concorrentes podem causar deadlocks, violações de chave estrangeira ou leituras sujas. Um teste que assume isolamento total pode falhar quando executado em paralelo com outro.
2. Estratégias de isolamento entre testes
2.1 Rollback automático via transações (teste por transação)
Cada teste é executado dentro de uma transação que é revertida ao final. Essa é a abordagem mais rápida e segura:
-- Configuração
BEGIN;
-- Teste
INSERT INTO pedidos (cliente_id, valor) VALUES (1, 100.00);
SELECT COUNT(*) FROM pedidos; -- esperado: 1
-- Limpeza automática
ROLLBACK;
2.2 Recriação completa do schema antes de cada suite
Útil quando o banco de dados não suporta transações aninhadas ou quando o teste precisa de um estado limpo absoluto:
-- Antes da suite
TRUNCATE TABLE pedidos, clientes, produtos RESTART IDENTITY CASCADE;
-- Testes executam normalmente
-- Após a suite (ou antes da próxima)
TRUNCATE TABLE pedidos, clientes, produtos RESTART IDENTITY CASCADE;
2.3 Sandboxes com schemas ou bancos de dados temporários por teste
Cada teste recebe seu próprio schema ou banco de dados, eliminando completamente o compartilhamento de estado:
CREATE SCHEMA test_12345;
SET search_path TO test_12345;
-- Cria tabelas e executa testes
DROP SCHEMA test_12345 CASCADE;
3. Fixtures: o que são e como estruturá-las
Fixtures são dados pré-definidos que estabelecem um estado inicial conhecido para os testes.
3.1 Dados estáticos (YAML/JSON) vs. dados gerados proceduralmente
Dados estáticos são previsíveis e fáceis de entender:
-- fixtures/clientes.yaml
clientes:
- id: 1
nome: "Alice"
email: "alice@teste.com"
- id: 2
nome: "Bob"
email: "bob@teste.com"
Dados gerados proceduralmente oferecem flexibilidade:
-- Gera 100 clientes para teste de performance
DO $$
BEGIN
FOR i IN 1..100 LOOP
INSERT INTO clientes (nome, email)
VALUES (
'Cliente_' || i,
'cliente_' || i || '@teste.com'
);
END LOOP;
END $$;
3.2 Fixtures por escopo: globais, por suite, por teste individual
-- Fixture global (executada uma vez)
INSERT INTO categorias (id, nome) VALUES (1, 'Eletrônicos');
-- Fixture por suite
BEGIN;
INSERT INTO produtos (id, nome, categoria_id) VALUES (1, 'Mouse', 1);
COMMIT;
-- Fixture por teste (dentro do próprio teste)
BEGIN;
INSERT INTO carrinho (produto_id, quantidade) VALUES (1, 2);
-- ... testa lógica do carrinho ...
ROLLBACK;
3.3 Boas práticas: evitar acoplamento entre fixtures e evitar “teste de dados”
Fixtures não devem depender umas das outras. Se o fixture A precisa do B, o teste fica frágil. Também evite criar testes que apenas verificam se os dados da fixture existem — isso não testa lógica de negócio.
4. Setup e teardown eficientes no SQL puro
4.1 Scripts de seed com INSERT controlado e DELETE/TRUNCATE no final
-- setup.sql
INSERT INTO clientes (id, nome) VALUES (1, 'Alice'), (2, 'Bob');
INSERT INTO pedidos (id, cliente_id, valor) VALUES (10, 1, 50.00);
-- teardown.sql
TRUNCATE TABLE pedidos, clientes RESTART IDENTITY;
4.2 Uso de BEGIN/ROLLBACK para isolar mutações sem limpar dados
Essa técnica é a mais eficiente, pois evita operações caras de TRUNCATE:
-- Teste 1
BEGIN;
INSERT INTO log_acesso (usuario_id, pagina) VALUES (1, '/home');
-- Verifica se o log foi inserido
SELECT COUNT(*) FROM log_acesso WHERE usuario_id = 1;
ROLLBACK;
-- Teste 2 (começa com o mesmo estado)
BEGIN;
INSERT INTO log_acesso (usuario_id, pagina) VALUES (2, '/admin');
-- Verifica
ROLLBACK;
4.3 Armadilhas com auto-incremento e constraints de chave estrangeira
-- Problema: após TRUNCATE, sequências reiniciam
CREATE TABLE teste (id SERIAL PRIMARY KEY, nome TEXT);
INSERT INTO teste (nome) VALUES ('A'); -- id = 1
TRUNCATE TABLE teste RESTART IDENTITY;
INSERT INTO teste (nome) VALUES ('B'); -- id = 1 novamente (OK)
-- Problema: chave estrangeira exige ordem de inserção
INSERT INTO pedidos (cliente_id) VALUES (1); -- ERRO: cliente 1 não existe
INSERT INTO clientes (id) VALUES (1); -- Deve vir primeiro
5. Ferramentas e frameworks para database testing
5.1 Exemplos com pytest + SQLAlchemy (Python) e dbUnit (Java)
Python com SQLAlchemy:
@pytest.fixture
def db_session():
engine = create_engine('postgresql://localhost/testdb')
connection = engine.connect()
transaction = connection.begin()
yield Session(bind=connection)
transaction.rollback()
connection.close()
def test_inserir_usuario(db_session):
usuario = Usuario(nome='Alice')
db_session.add(usuario)
db_session.flush()
assert usuario.id is not None
Java com dbUnit:
@Before
public void setUp() throws Exception {
IDatabaseConnection conn = new DatabaseConnection(dataSource.getConnection());
IDataSet dataSet = new FlatXmlDataSetBuilder()
.build(getClass().getResourceAsStream("/fixture.xml"));
DatabaseOperation.CLEAN_INSERT.execute(conn, dataSet);
}
5.2 Testcontainers: bancos efêmeros em contêineres Docker
@Testcontainers
class DatabaseTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Test
void testConectividade() {
String jdbcUrl = postgres.getJdbcUrl();
// Executa testes contra o banco temporário
}
}
5.3 Comparação: banco em memória (SQLite) vs. banco real (PostgreSQL/MySQL)
| Característica | SQLite (em memória) | PostgreSQL real |
|---|---|---|
| Velocidade | Muito rápido | Mais lento |
| Fidelidade SQL | Diferenças em funções e tipos | Idêntico à produção |
| Concorrência | Limitada | Suporte completo |
| Setup | Nenhum | Requer container |
6. Testando cenários complexos: concorrência, locks e integridade
6.1 Simulação de conflitos de transação e deadlocks
-- Sessão 1
BEGIN;
UPDATE contas SET saldo = saldo - 100 WHERE id = 1;
-- Sessão 2 (concorrente)
BEGIN;
UPDATE contas SET saldo = saldo - 50 WHERE id = 2;
UPDATE contas SET saldo = saldo + 50 WHERE id = 1; -- Aguarda sessão 1
-- Sessão 1
UPDATE contas SET saldo = saldo + 100 WHERE id = 2; -- Deadlock!
6.2 Testes de constraints (UNIQUE, CHECK, FOREIGN KEY) e triggers
-- Teste de UNIQUE
BEGIN;
INSERT INTO emails (email) VALUES ('teste@teste.com'); -- OK
INSERT INTO emails (email) VALUES ('teste@teste.com'); -- Deve lançar erro
ROLLBACK;
-- Teste de CHECK
BEGIN;
INSERT INTO produtos (preco) VALUES (-10); -- Deve falhar CHECK
ROLLBACK;
6.3 Validação de migrações e rollbacks em ambiente de teste
-- Teste de migração para frente
CREATE TABLE temp_usuarios (LIKE usuarios INCLUDING ALL);
ALTER TABLE usuarios ADD COLUMN telefone TEXT;
-- Verifica se a coluna existe
SELECT column_name FROM information_schema.columns
WHERE table_name = 'usuarios' AND column_name = 'telefone';
-- Rollback
ALTER TABLE usuarios DROP COLUMN telefone;
7. Integração com CI/CD e boas práticas finais
7.1 Paralelização segura de testes de banco de dados
Para executar testes em paralelo, cada worker deve ter seu próprio banco ou schema:
# pytest.ini
[pytest]
testpaths = tests
addopts = -n 4 --dist loadscope
# Cada worker recebe um schema diferente
export PGTEST_SCHEMA="test_${PYTEST_XDIST_WORKER}"
7.2 Gerenciamento de segredos e conexões em pipelines
# .env.ci
DATABASE_URL=postgresql://test_user:${DB_PASSWORD}@localhost:5432/testdb
DB_PASSWORD=vars/secrets/db_password
# No pipeline, use variáveis de ambiente seguras
docker run -e DATABASE_URL="$DATABASE_URL" my-test-image
7.3 Monitoramento de lentidão e vazamento de conexões
-- Verificar conexões ativas no PostgreSQL
SELECT count(*) FROM pg_stat_activity WHERE state = 'active';
-- Log de queries lentas (ativar no postgresql.conf)
log_min_duration_statement = 200 -- ms
-- No teste, medir tempo de execução
EXPLAIN ANALYZE SELECT * FROM clientes WHERE email = 'teste@teste.com';
Testes de banco de dados bem estruturados combinam isolamento rigoroso com fixtures enxutas e ferramentas modernas como Testcontainers. O resultado é uma suíte de testes confiável que valida não apenas a lógica da aplicação, mas também a integridade e performance do banco de dados real.
Referências
- PostgreSQL Documentation: Testing with pgTAP — Framework completo para testes unitários de banco de dados diretamente em SQL, com suporte a fixtures e rollback automático.
- Testcontainers for Java: Database Testing — Guia oficial para criar bancos de dados efêmeros em contêineres Docker para testes de integração.
- SQLAlchemy Documentation: Testing with Fixtures — Exemplos de como usar transações e fixtures em testes com SQLAlchemy e pytest.
- DbUnit: Database Testing Framework — Tutorial oficial do dbUnit para Java, demonstrando inserção de datasets e operações de limpeza.
- Martin Fowler: Test Data — Artigo clássico sobre boas práticas na criação e gerenciamento de dados de teste em bancos relacionais.
- Docker PostgreSQL: Official Image for Testing — Documentação oficial da imagem PostgreSQL no Docker Hub, útil para configurar ambientes de teste isolados.