Pipeline de dados não quebra. Ele mente.
Onze dias com a produtividade errada num relatório e nenhum alerta disparou. Não era bug de código — era o pipeline entregando um número plausível, que é o pior tipo de erro que existe.
O relatório de produtividade por talhão passou onze dias mostrando 47,3 sacas por hectare numa área que colheu 41,8. O job rodou verde todos os onze dias, sempre por volta das 4h10. O dashboard abria em menos de dois segundos. Os números tinham casa decimal e cara de número.
A divergência só apareceu porque um agrônomo foi cruzar o relatório com o mapa de colheita da máquina e os dois gráficos não fechavam. Ele achou que a máquina estava descalibrada. Não estava.
A causa era boba de tão simples. A tabela de aplicação de insumo passou a aceitar mais de um lançamento por talhão dentro da mesma safra — mudança de regra de negócio, discutida, aprovada, correta. Aí o join da camada de agregação, que antes trazia exatamente uma linha por talhão, passou a trazer duas para uma parte da base. Cada romaneio daqueles talhões foi somado duas vezes.
A produção total subiu 13%.
Repara no número: 13%. Se tivesse subido 400%, alguém teria visto no primeiro dia. Se o job tivesse estourado exceção, o alerta do Jenkins teria chegado antes do café. Mas 13% em cima de uma safra é exatamente a faixa em que a resposta natural de qualquer pessoa é "pois é, foi um ano bom". O erro passou não apesar de ser sutil, mas porque era sutil.
Software quebra fazendo barulho. Stack trace, HTTP 500, container reiniciando em loop, alguém sendo acordado. Pipeline de dados quebra em silêncio, e é por isso que a disciplina de teste que a gente traz de aplicação não cobre metade do problema.
O que a cultura de QA já resolveu — e onde ela quebra aqui
Vim de QA para dados e passei um bom tempo achando que era a mesma coisa apontada para outro alvo. Em parte é, e essa parte é a mais fácil de vender: asserção, pirâmide de teste, teste de contrato, ambiente de staging, suíte de regressão rodando no CI. Nada disso é ideia nova. Engenharia de dados está redescobrindo, com dez anos de atraso e nome diferente, coisa que time de QA discute desde sempre.
Teste de contrato de API é a mesma conversa que hoje chamam de data contract. Staging com massa controlada é warehouse de dev com amostra representativa. Regressão é rodar a query de ontem e comparar com o resultado de ontem. A pirâmide também traduz: muita checagem barata na borda, pouca checagem cara no topo.
Onde a analogia quebra é no que significa "esperado".
Num teste de aplicação, o esperado é um valor. Eu chamo o endpoint, mando o payload, espero 201 e um id. Determinístico. Ou passa ou falha, e falhou é falhou. Em dado, o esperado quase nunca é um valor — é uma faixa, uma distribuição, um comportamento em relação ao histórico. "A carga de hoje trouxe 3.412 romaneios" não é certo nem errado sozinho. É certo se ontem trouxe 3.100 e é suspeitíssimo se ontem trouxe 11.800. O mesmo número muda de status dependendo do contexto.
Isso tem uma consequência incômoda que a maior parte dos textos sobre o assunto varre pra debaixo do tapete: uma parte dos seus testes de dado vai ser probabilística, e teste probabilístico gera falso positivo por construção. Não dá pra fugir disso. Dá pra organizar em volta disso, e eu volto nesse ponto no fim.
Os jeitos silenciosos de errar
Depois de alguns sustos, parei de tratar isso como "bug de pipeline" e passei a tratar como catálogo. São poucos padrões, eles se repetem, e cada um tem sintoma próprio no dashboard.
| Modo de falha | Sintoma no dashboard | Checagem que pega |
|---|---|---|
| Fan-out de join | Total infla de forma proporcional; média por unidade parece normal | Contagem de linhas antes e depois do join; unicidade da chave de grão |
| Schema drift (coluna renomeada, decimal virando string) | Coluna zera, ou soma trunca em número inteiro | Contrato de schema na ingestão com tipo explícito e extra=forbid |
| Dado atrasado após fechar a janela | Últimos dias sempre "fracos", corrigem sozinhos depois | Comparar contagem por dt_evento vs dt_carga; janela móvel de reprocesso |
| Fuso horário | Movimento perto da meia-noite cai no dia errado; virada de mês não fecha | Reconciliação diária e mensal com origem; asserção de timestamptz no contrato |
NULL propagando em agregação |
Média sobe sem motivo (nulo é ignorado, não é zero) | Taxa de nulo por coluna versus baseline; contagem de denominador explícita |
| Deduplicação por chave errada | Total encolhe de mansinho; alguns talhões somem do relatório | Reconciliação bruto × agregado com full outer join |
O do NULL é o meu preferido, no sentido ruim. AVG ignora nulo. Se 18% dos romaneios chegam sem umidade, a média não fica errada por 18% — fica errada de um jeito que depende de quais registros vieram nulos. Se os nulos saem todos da balança velha de uma unidade, a média passou a descrever outra população.
Asserção: o teste que espera zero linha
A camada mais barata de teste de dado é a asserção de invariante, e ela tem um formato só: uma query que devolve as linhas que violam a regra. Resultado esperado é conjunto vazio. Voltou linha, falhou, e a própria falha já vem com o payload do incidente.
Isso é expect(x).toBe(y) escrito em SQL, e cabe direto num step do Jenkins.
-- Resultado esperado: zero linha. Qualquer retorno aqui é incidente.
with chave_duplicada as (
-- grão declarado da tabela: um romaneio por fazenda
select
'unicidade_grao_romaneio' as assercao,
cd_fazenda || '/' || nr_romaneio as chave,
count(*) as qt_ocorrencias
from silver.romaneio_colheita
where dt_emissao >= current_date - interval '7 days'
group by 1, 2
having count(*) > 1
),
fan_out_insumo as (
-- o join que me custou 11 dias: aplicacao_insumo deixou de ser 1:1
select
'fan_out_join_insumo' as assercao,
r.cd_fazenda || '/' || r.nr_romaneio as chave,
count(*) as qt_ocorrencias
from silver.romaneio_colheita r
join silver.aplicacao_insumo a
on a.cd_talhao = r.cd_talhao
and a.cd_safra = r.cd_safra
where r.dt_emissao >= current_date - interval '7 days'
group by 1, 2
having count(*) > 1
),
hectare_invalido as (
-- divisor de produtividade: se zerar ou faltar, o sc/ha explode ou some
select
'area_talhao_invalida' as assercao,
cd_fazenda || '/' || cd_talhao as chave,
1 as qt_ocorrencias
from silver.talhao
where qt_hectares is null
or qt_hectares <= 0
or qt_hectares > 30000
)
select * from chave_duplicada
union all select * from fan_out_insumo
union all select * from hectare_invalido;
A segunda CTE é a que interessa. Ela não testa o join — testa se o join ainda é 1:1. É a diferença entre verificar o código e verificar a premissa que o código assume. Se aquela regra de negócio tivesse mudado com essa asserção no lugar, o pipeline teria falhado na primeira madrugada, com o número do romaneio na mensagem de erro.
Contrato na fronteira: falhar alto em vez de aceitar quieto
Toda camada de ingestão tem uma escolha implícita: aceitar o que chegar e consertar depois, ou recusar o que não bate com o combinado. A primeira opção parece mais gentil e é a que corrompe o warehouse.
Quem produz o dado precisa se comprometer com um formato. E esse compromisso precisa ser executável, não um PDF de dicionário de dados que ninguém abre desde 2023.
ingestao/contratos/romaneio.pyfrom datetime import date
from decimal import Decimal
from typing import Iterable, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class RomaneioColheita(BaseModel):
# extra="forbid": coluna nova na origem estoura aqui, não seis camadas adiante
model_config = ConfigDict(extra="forbid")
nr_romaneio: int = Field(gt=0)
cd_fazenda: str = Field(min_length=1, max_length=12)
cd_talhao: str = Field(min_length=1, max_length=12)
cd_safra: str = Field(pattern=r"^\d{4}/\d{2}$")
dt_emissao: date
# Decimal, nunca float: soma de 3.412 pesos em float acumula erro visível
peso_liquido_kg: Decimal = Field(gt=0, lt=Decimal("80000"))
umidade_pct: Decimal = Field(ge=0, le=Decimal("40"))
# o dia que a origem mandar "t" em vez de "kg", eu quero saber às 4h10
unidade_peso: Literal["kg"]
class ContratoViolado(Exception):
pass
def validar_lote(
linhas: Iterable[dict],
limite_rejeito: float = 0.005,
) -> list[RomaneioColheita]:
validos: list[RomaneioColheita] = []
rejeitos: list[tuple[dict, str]] = []
for linha in linhas:
try:
validos.append(RomaneioColheita.model_validate(linha))
except ValidationError as erro:
rejeitos.append((linha, erro.json()))
total = len(validos) + len(rejeitos)
if total and len(rejeitos) / total > limite_rejeito:
gravar_quarentena(rejeitos)
raise ContratoViolado(
f"{len(rejeitos)} de {total} romaneios violaram o contrato "
f"({len(rejeitos) / total:.2%}). Carga abortada."
)
if rejeitos:
gravar_quarentena(rejeitos) # abaixo do limite: segue, mas fica rastro
return validos
Duas decisões aí valem discussão. O extra="forbid" é agressivo de propósito: coluna nova na origem vira evento que alguém aprova, não surpresa descoberta três meses depois. E o limite_rejeito existe porque abortar a carga inteira por um romaneio torto é o tipo de rigor que faz o time desligar a validação em duas semanas.
A pergunta que define onde colocar a validação é: se esse dado estiver errado, quantas tabelas ele contamina antes de alguém olhar? Se a resposta é "muitas", valida na entrada. Validar na saída de um pipeline de sete etapas significa descobrir o problema depois de sete tabelas já terem sido escritas — e reprocessar as sete.
Reconciliação: a soma bate entre as camadas?
Asserção pega dado inválido. Não pega dado válido e errado. Um total inflado por fan-out passa liso em toda checagem de unicidade da tabela de destino, porque na tabela de destino não tem duplicata nenhuma — tem uma soma maior.
O que pega isso é reconciliação: refazer a conta pelo caminho longo, direto do bruto, e comparar com o agregado. Se as duas contas não fecharem dentro de uma tolerância, alguma transformação no meio está mentindo.
tests/reconciliacao/producao_talhao.sql-- Tolerância: 0,5 saca ou 0,01% do volume, o que for maior.
-- Arredondamento de kg -> sacas cria diferença legítima na casa decimal;
-- erro de lógica cria diferença proporcional ao volume.
with bruto as (
select
cd_safra,
cd_talhao,
sum(peso_liquido_kg) / 60.0 as sacas_bruto
from bronze.romaneio_colheita
where fl_cancelado = false
and cd_safra = '2025/26'
group by 1, 2
),
agregado as (
select
cd_safra,
cd_talhao,
sum(qt_sacas) as sacas_agregado
from gold.producao_talhao
where cd_safra = '2025/26'
group by 1, 2
)
select
coalesce(b.cd_safra, a.cd_safra) as cd_safra,
coalesce(b.cd_talhao, a.cd_talhao) as cd_talhao,
b.sacas_bruto,
a.sacas_agregado,
coalesce(a.sacas_agregado, 0) - coalesce(b.sacas_bruto, 0) as diferenca
from bruto b
full outer join agregado a
on a.cd_safra = b.cd_safra
and a.cd_talhao = b.cd_talhao
where abs(coalesce(a.sacas_agregado, 0) - coalesce(b.sacas_bruto, 0))
> greatest(0.5, coalesce(b.sacas_bruto, 0) * 0.0001);
O full outer join não é preciosismo. Ele é o que denuncia talhão que existe no bruto e sumiu no agregado — deduplicação por chave errada, filtro esquecido, inner join que virou peneira. Com left join você só vê metade dos problemas, e é sempre a metade menos assustadora.
A tolerância mista é a parte que eu levei tempo pra acertar. Percentual puro deixa passar diferença grossa em talhão pequeno. Absoluto puro dispara em talhão grande por pura conversão de unidade. Os dois juntos, pegando o maior, separam ruído de arredondamento de erro de lógica.
Rodar duas vezes é um teste, e quase ninguém escreve
Reprocessamento não é exceção. A origem corrigiu um lançamento, a rede caiu no meio da carga, alguém pediu o histórico de novo. Todo pipeline vai rodar duas vezes sobre a mesma janela, e o resultado tem que ser idêntico.
Idempotência é a propriedade mais fácil de testar e a que eu menos vejo testada. O teste é literalmente: roda, tira a impressão digital, roda de novo, compara.
tests/pipeline/test_idempotencia.pyimport hashlib
from datetime import date
DATA_REF = date(2026, 3, 14)
def impressao_digital(conn, data_ref: date) -> str:
"""Hash estável do estado da tabela para uma data de referência."""
linhas = conn.execute(
"""
select cd_talhao, cd_safra, qt_sacas, qt_hectares
from gold.producao_talhao
where dt_referencia = %s
order by cd_talhao, cd_safra
""",
(data_ref,),
).fetchall()
corpo = "|".join(f"{t};{s};{sc};{ha}" for t, s, sc, ha in linhas)
return hashlib.sha256(corpo.encode()).hexdigest()
def test_carga_pode_rodar_duas_vezes(conn, pipeline_producao):
pipeline_producao.executar(data_ref=DATA_REF)
primeira = impressao_digital(conn, DATA_REF)
pipeline_producao.executar(data_ref=DATA_REF) # mesma janela, de novo
segunda = impressao_digital(conn, DATA_REF)
assert primeira == segunda, (
"carga não é idempotente: reprocessar a mesma data altera o resultado"
)
Quando esse teste falha, ele falha feio e a causa é quase sempre a mesma: o insert devia ser merge, ou o delete da partição está filtrando por data de carga em vez de data do evento. São duas linhas de correção e um bug que, sem esse teste, só aparece no dia em que alguém reprocessa a safra inteira.
As métricas que pegam mais bug que teste unitário
Vou dizer uma coisa que é meio herege pro meu passado de QA: teste unitário da função de transformação é, de longe, o teste de menor retorno num pipeline de dados. Ele verifica que a função converte kg em sacas. A função sempre converteu kg em sacas. O que muda é o dado que entra nela.
Quatro sinais monitorados continuamente pegam mais problema real que qualquer suíte unitária de transformação:
- Frescor — quanto tempo desde o último registro novo por origem. Origem que parou de mandar não gera erro, gera silêncio, e silêncio num pipeline parece sucesso.
- Volume — contagem de linhas da carga contra a mesma janela em semanas anteriores, com dia da semana como referência. Segunda-feira não se compara com domingo.
- Taxa de nulo por coluna — uma coluna que sempre teve 2% de nulo e hoje tem 31% é schema drift ou integração quebrada, sempre.
- Cardinalidade da chave — quantidade de valores distintos em chave e chave estrangeira. É o detector de fan-out mais rápido que existe, e é uma query de
count(distinct).
Nenhuma dessas quatro coisas testa código. Todas testam o dado.
"O analista percebe se o número estiver esquisito"
Esse é o contra-argumento que eu mais ouço, e ele não é burro. Ouço de gente boa, que conhece a operação, que de fato olha o relatório todo dia e que já pegou coisa errada assim. É verdade — em parte.
O analista percebe o absurdo. Produtividade de 900 sacas por hectare, ele vê na hora. Talhão zerado na safra inteira, ele vê. Para o erro escandaloso, o olho humano é um detector excelente e de graça.
Só que o erro escandaloso é o mais barato de todos, justamente porque é impossível agir em cima dele. Ninguém decide plantio com base num número absurdo — a pessoa levanta e pergunta.
O dano mora no plausível. 47,3 quando o certo era 41,8 não levanta ninguém da cadeira. Vira ranking de talhão, vira comparação entre unidades, vira argumento em reunião, vira decisão de plantio para a safra seguinte. E quando finalmente alguém descobre a diferença, a decisão já foi tomada e ninguém consegue reconstruir qual parte dela veio do número errado.
Depender do olho do analista é ter cobertura de teste boa exatamente onde o risco é baixo, e cobertura zero onde o risco é alto. Se eu escrevesse uma suíte de aplicação com esse perfil, seria reprovado em revisão.
O que isso custa — e onde eu já estraguei
Teste de dado não é de graça e quem vende como se fosse está mentindo junto com o pipeline. Cada asserção é código que alguém mantém. Regra de negócio muda, a asserção não acompanha, e ela passa a falhar por estar errada — não porque o dado está. Suíte de dado apodrece igual suíte de aplicação, só que mais rápido, porque o mundo muda mais que o código.
E tem o problema do alerta.
Eu montei, com muita convicção, um alerta de anomalia de volume em cima de dois desvios-padrão sobre uma janela de trinta dias. Elegante no papel. Em três semanas ele disparou 22 vezes, e das 22, umas 19 eram chuva, feriado ou fim de janela de colheita. O time criou uma regra pra silenciar o canal. Quando um problema de verdade apareceu, o alerta disparou direitinho, no canal que ninguém mais lia. A ideia era minha e era ruim.
É o alarme de incêndio da cozinha: se ele apita toda vez que alguém faz uma torrada, em uma semana ele vira o barulho que se ignora, e no dia do incêndio ele apita sozinho pra ninguém.
O que eu faço diferente hoje é separar duas coisas que eu tratava como uma. Existe checagem determinística — unicidade, tipo, integridade referencial, reconciliação fora da tolerância. Essa bloqueia o pipeline, acorda gente, e falso positivo dela é bug meu, pra corrigir na hora. E existe checagem estatística — volume, distribuição, cardinalidade. Essa nunca bloqueia nada e nunca acorda ninguém: abre um item na fila do time, com o gráfico junto, pra alguém olhar no horário comercial.
Junto disso, duas regras que eu levo a sério. Alerta sem dono nomeado é desligado, não "revisado depois". E alerta ignorado três vezes seguidas vira defeito do alerta: ou o limiar é recalibrado, ou a checagem morre.
Nada disso torna o pipeline confiável. Torna ele auditável, que é uma promessa bem menor e muito mais honesta.
Todo número num dashboard é uma afirmação sobre o mundo real. Se ninguém checou, é só um palpite muito bem formatado — e ele vai ser lido com a mesma confiança de um que foi checado.