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