O teste que mais me salvou roda em produção
Passou na suíte inteira, passou em homologação, e quebrou às 7h40 na tela de um cliente. A escolha nunca foi entre testar em produção ou não — é entre descobrir por instrumentação sua ou descobrir por telefone.
O relatório de fechamento de safra rodava em 1,2 segundo em homologação. Eu vi rodar. A suíte de regressão passou, o teste de integração passou, o cenário de BDD com três talhões e dois romaneios passou, e o deploy subiu numa quinta à noite sem nenhum drama.
Às 7h40 da sexta o suporte abriu chamado: relatório não abre.
Homologação tinha 4.187 romaneios. O cliente tinha 380 mil. A consulta fazia um join que crescia mais ou menos ao quadrado do volume, o gateway cortava em 30 segundos, e o usuário via uma tela branca com um "erro inesperado" que não dizia nada. Nenhum teste meu tinha como pegar isso, porque nenhum teste meu tinha 380 mil romaneios. E o motivo de não ter é chato de admitir: ninguém quer uma base de homologação de 40 GB que leva 25 minutos pra restaurar antes de cada execução da suíte.
"Testar em produção" virou piada de internet — o meme do cara sorrindo enquanto tudo pega fogo — e o efeito colateral foi que todo mundo passou a fingir que não faz. Faz. Todo deploy é um teste em produção. A diferença é se você está olhando.
O que homologação nunca vai ter
Não é preguiça de time, é limite estrutural. Existem cinco coisas que só existem no ambiente do cliente e que nenhum investimento razoável em staging replica:
Volume real. O caso acima. Plano de execução que muda quando o otimizador decide que seq scan é melhor que índice, paginação que ninguém testou além da página 3.
Distribuição real de dado. Pior que volume. Em homologação o CNPJ tem 14 dígitos, o nome da fazenda tem 20 caracteres e a data de emissão é sempre do ano corrente. No cliente tem razão social com 187 caracteres, talhão chamado "ÁREA 3 - (ANTIGA DO SR. JOÃO)", romaneio de 2019 sem cd_safra preenchido porque foi migrado de um sistema antigo. É a mesma coisa que eu já tinha aprendido no suporte: dado real é feio, e a feiura dele é criativa de um jeito que factory nenhuma imita.
Concorrência real. Dois operadores de balança fechando o mesmo romaneio no mesmo segundo. Isso não existe numa suíte que roda sequencial.
Integração de terceiro de verdade. O mock da SEFAZ sempre responde em 80 ms e sempre devolve o schema combinado. A SEFAZ de verdade, não.
A máquina e a rede do usuário. Notebook de 2017 com 47 abas abertas, 4G oscilando no meio da lavoura, proxy corporativo da cooperativa que faz cache de resposta com POST. Boa sorte reproduzindo isso no seu Docker.
| Classe de defeito | Homologação pega? | O que pega em produção |
|---|---|---|
| Regra de negócio implementada errada | Sim, e é onde ela é imbatível | — |
| Regressão em fluxo conhecido | Sim | — |
| Degradação por volume | Não | p99 por rota no canary, com cliente grande na primeira fatia |
| Dado fora do formato esperado | Quase nunca | Taxa de erro segmentada por cliente |
| Corrida entre usuários simultâneos | Raramente, e quando pega vira teste "instável" | Espera de lock e deadlock por minuto |
| Terceiro fora do contrato (timeout, campo novo, limite de taxa) | Não | Smoke sintético batendo no serviço real |
| Rede e hardware do usuário | Não | Telemetria de navegador (tempo real de carregamento por cliente) |
| Falha silenciosa: resposta 200, número errado | Só se você previu exatamente aquele caso | Métrica de negócio e reconciliação |
Repara na última linha da tabela. É a mesma classe de problema que faz pipeline de dados mentir: não tem exceção, não tem stack trace, não tem status 500. Tem número plausível. Monitoramento baseado em taxa de erro é cego pra isso, e eu volto nesse ponto.
Três tempos, e embolar eles é o erro comum
Quando alguém fala "a gente testa em produção", normalmente está misturando três coisas que pegam defeitos diferentes e têm custos diferentes.
Antes do deploy é onde mora a suíte: unitário, integração, contrato, E2E. Pega lógica errada e regressão. É barato, é rápido e é onde a maior parte do valor está. Nada neste texto diminui isso.
Durante o deploy é verificação: canary, smoke em produção logo depois de subir, comparação de métrica entre a versão nova e a antiga. Pega "a versão nova está pior que a anterior" numa janela em que ainda dá pra voltar barato.
Depois, pra sempre é observação: sonda sintética, alerta de p99, métrica de negócio. Pega o defeito que só acorda no dia 12 do mês, quando o volume de fechamento triplica, ou quando o terceiro muda o certificado.
Quem tenta usar canary pra pegar regra de negócio errada queima cliente. Quem tenta usar E2E pra pegar degradação por volume escreve um teste que não termina nunca.
Deploy não é release
A ideia que mais mudou como eu trabalho é boba de tão simples: colocar código em produção e ligar funcionalidade pro usuário são dois eventos separados, e podem ter semanas de distância.
Com essa separação, feature flag deixa de ser só ferramenta de produto e vira instrumento de teste. Sobe o código com a flag desligada — está em produção, compilando, no mesmo binário, sofrendo a mesma carga, sem executar. Ligo pra mim. Depois pra um cliente piloto que topou. Depois 10%, 50%, todos. Se algo der errado no cliente piloto, o rollback é uma linha numa tabela, não um redeploy às 23h.
Uma implementação sóbria disso cabe em pouca coisa. O que quase ninguém coloca — e é o que separa flag de bomba-relógio — são os metadados de morte.
src/plataforma/flags/feature-flag.service.tsimport { Injectable, Logger } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { REGISTRO_DE_FLAGS, RegistroDeFlag } from './registro-de-flags';
import { RegraDeLiberacaoRepository } from './regra-de-liberacao.repository';
export interface ContextoDeAvaliacao {
cdCliente: string;
cdUsuario: string;
cdModulo: string;
}
@Injectable()
export class FeatureFlagService {
private readonly log = new Logger(FeatureFlagService.name);
private readonly registro = new Map<string, RegistroDeFlag>(
REGISTRO_DE_FLAGS.map((flag) => [flag.chave, flag]),
);
constructor(private readonly regras: RegraDeLiberacaoRepository) {}
async ligadaPara(chave: string, ctx: ContextoDeAvaliacao): Promise<boolean> {
const meta = this.registro.get(chave);
if (!meta) {
// flag sem registro é flag sem dono. Desligada, sempre.
this.log.error(`flag desconhecida: ${chave}`);
return false;
}
this.avisarSeVencida(meta);
const regra = await this.regras.buscar(chave); // cache local de 30s
if (!regra || regra.estado === 'desligada') return false;
if (regra.estado === 'ligada_para_todos') return true;
if (regra.usuariosLiberados.includes(ctx.cdUsuario)) return true;
if (regra.clientesLiberados.includes(ctx.cdCliente)) return true;
return this.balde(chave, ctx.cdCliente) < regra.percentual;
}
/** Hash estável: o mesmo cliente cai sempre no mesmo balde. Sem isso o
* rollout pisca entre requisições e o usuário vê duas telas diferentes. */
private balde(chave: string, cdCliente: string): number {
const digest = createHash('sha1').update(`${chave}:${cdCliente}`).digest();
return digest.readUInt16BE(0) % 100;
}
private avisarSeVencida(meta: RegistroDeFlag): void {
const diasVencida = Math.floor(
(Date.now() - Date.parse(meta.removerAte)) / 86_400_000,
);
if (diasVencida > 0) {
this.log.warn(
`flag "${meta.chave}" venceu há ${diasVencida} dias — ` +
`dono ${meta.dono}, remoção em ${meta.ticketRemocao}`,
);
}
}
}
O registro é um arquivo versionado, revisado em pull request:
src/plataforma/flags/registro-de-flags.tsexport const REGISTRO_DE_FLAGS: RegistroDeFlag[] = [
{
chave: 'romaneio.fechamento-em-lote',
descricao: 'Fecha romaneios por talhão em vez de um a um',
dono: 'squad-colheita',
criadaEm: '2026-03-02',
removerAte: '2026-05-15', // data de morte acordada na criação
ticketRemocao: 'AGR-4471', // ticket já aberto, no backlog, com estimativa
},
];
Um passo do Jenkins lê esse arquivo e falha o build quando qualquer flag passa de 30 dias do removerAte. Não é rigor por rigor. Cada flag ativa dobra o número de caminhos possíveis do sistema: cinco flags são 32 combinações, e você testa uma ou duas delas. As outras 30 existem em produção, com clientes dentro, e ninguém nunca as executou de propósito.
Flag sem data de morte não é feature flag, é if permanente com um nome bonito. Trate a remoção como o passo final da entrega — a funcionalidade não está pronta quando liga pra todos, está pronta quando o if sai do código.
Smoke sintético: uma jornada de verdade, de minuto em minuto
Health check que responde {"status":"ok"} só prova que o processo Node subiu. Já vi API respondendo 200 no /health com o pool do banco esgotado e todo mundo tomando timeout na tela.
O que serve é executar a jornada crítica de ponta a ponta contra produção, com uma conta de serviço marcada como sintética, e assertar o resultado de negócio — não o código HTTP.
smoke/producao/emissao-de-romaneio.smoke.tsimport { strict as assert } from 'node:assert';
import { apiProducao, medirDuracao, publicarMetrica } from './infra';
// Conta de serviço criada com is_sintetico = true no banco.
// Esse flag no cliente é o que sustenta o filtro das métricas de negócio,
// do faturamento e de todo relatório entregue a cliente. Sem ele, não sobe.
const CLIENTE_SINTETICO = 'SINT-0001';
const TALHAO_SINTETICO = 'SINT-TALHAO-A';
export async function emissaoDeRomaneio(): Promise<void> {
const api = await apiProducao(CLIENTE_SINTETICO);
const saldoAntes = (await api.get(`/talhoes/${TALHAO_SINTETICO}/saldo`)).qtSacas;
const { resultado: romaneio, ms } = await medirDuracao(() =>
api.post('/romaneios', {
cdTalhao: TALHAO_SINTETICO,
pesoBrutoKg: 28_450,
taraKg: 14_200,
umidadePct: 13.4,
impurezaPct: 1.1,
}),
);
// 1. Asserção de verdade: o desconto de umidade foi aplicado?
// 28.450 - 14.200 = 14.250 kg líquidos = 237,5 sacas de 60kg.
// Com 13,4% de umidade e 1,1% de impureza, o esperado é 231,86.
assert.equal(romaneio.qtSacasLiquidas, 231.86, 'cálculo de desconto mudou');
assert.ok(romaneio.nrRomaneio > 0, 'romaneio saiu sem numeração');
// 2. O efeito colateral aconteceu de verdade? (200 não prova escrita)
const saldoDepois = (await api.get(`/talhoes/${TALHAO_SINTETICO}/saldo`)).qtSacas;
assert.equal(
arredondar(saldoDepois - saldoAntes),
231.86,
'romaneio emitido mas saldo do talhão não mexeu',
);
// 3. O documento chegou a existir para o usuário?
const pdf = await api.getBinario(`/romaneios/${romaneio.id}/pdf`);
assert.ok(pdf.byteLength > 8_000, 'PDF vazio ou template quebrado');
await api.delete(`/romaneios/${romaneio.id}`); // estorno, mesma rota do usuário
await publicarMetrica('smoke_emissao_romaneio_ms', ms);
}
Cada asserção ali existe porque a versão sem ela já me enganou. O 200 sem escrita foi um @Transactional que dava rollback sem propagar erro. O PDF vazio foi uma imagem de template que sumiu do bucket depois de uma limpeza. E o estorno usa a rota real de estorno, de propósito — assim o smoke exercita o caminho de desfazer, que é sempre o menos testado.
Canary, e por que taxa de erro sozinha é um sinal fraco
Canary é subir a versão nova pra uma fatia pequena e comparar com a antiga. A parte difícil não é rotear tráfego — é decidir o critério de volta.
Taxa de erro parece o critério óbvio e é o mais fraco dos três motivos: primeiro, porque o defeito que mais dói não gera exceção nenhuma — devolve 200 com o número errado. Segundo, porque erro de negócio (validação de CPF, saldo insuficiente) e erro de infra caem no mesmo balde se você só olha status 5xx agregado. Terceiro, e mais comum: amostra pequena mente com uma confiança impressionante.
Se o canary recebeu 40 requisições e 3 falharam, isso é 7,5% de erro — um número alarmante que, com essa amostra, é indistinguível de acaso. Se você configurar rollback automático em 5% de erro numa janela de 2 minutos, vai passar o dia revertendo deploy bom. Aí o time perde a confiança no automatismo e desliga. Já vi acontecer.
O que funciona: janela de 10 a 15 minutos, exigência de amostra mínima antes de a regra valer, comparação contra a versão estável no mesmo período (não contra um limiar fixo), e p99 como primeiro sinal em vez de média. Média esconde tudo — se 1 em cada 100 requisições leva 30 segundos e as outras 99 levam 90 ms, a média fica em 390 ms e parece ótima. O cliente que caiu naquela uma está no telefone.
O melhor sensor que existe é uma métrica de negócio
Numa terça-feira às 10h, a quantidade de ordens de compra emitidas por hora caiu 40%. Nenhum alerta técnico disparou. CPU normal, memória normal, taxa de erro em 0,3%, latência boa. A aplicação estava saudável e simplesmente não estava mais fazendo o que existe pra fazer — um campo passou a ser obrigatório numa tela por causa de uma validação nova, e os usuários desistiam no meio.
Volume de operação de negócio por unidade de tempo é a melhor sonda que existe, e é a mais barata. Ela é a única que mede a coisa certa: não "o servidor está de pé", mas "o trabalho está acontecendo".
observabilidade/alertas/agro-sinais.ymlgroups:
- name: sinais-que-antecipam
rules:
# Cauda, não média. E só vale com amostra suficiente na janela.
- alert: LatenciaCaudaEmissaoRomaneio
expr: |
histogram_quantile(0.99,
sum by (le) (rate(http_duracao_segundos_bucket{rota="/romaneios",metodo="POST"}[10m]))
) > 2.5
and
sum(rate(http_requisicoes_total{rota="/romaneios",metodo="POST"}[10m])) * 600 > 200
for: 10m
labels: { severidade: alta }
annotations:
resumo: "p99 da emissão acima de 2,5s com volume relevante"
# O sinal de negócio. Compara com a mesma faixa de horário da semana passada,
# porque terça 10h não se compara com domingo 3h. Exclui conta sintética.
- alert: QuedaNaEmissaoDeOrdemDeCompra
expr: |
sum(rate(ordem_compra_emitida_total{sintetico="false"}[30m]))
<
0.6 * sum(rate(ordem_compra_emitida_total{sintetico="false"}[30m] offset 1w))
and
sum(rate(ordem_compra_emitida_total{sintetico="false"}[30m] offset 1w)) * 1800 > 50
for: 15m
labels: { severidade: alta, acorda_alguem: "sim" }
annotations:
resumo: "emissão de OC 40% abaixo da mesma janela da semana passada"
acao: "conferir deploy e flags ligadas nas últimas 2h antes de olhar infra"
# Sonda sintética parada é tão grave quanto sonda falhando.
- alert: SmokeSinteticoSemExecucao
expr: time() - max(smoke_ultima_execucao_timestamp) > 300
for: 5m
labels: { severidade: alta }
Repara no sintetico="false". Ele não é decoração.
Onde isso vira tiro no pé
O dado sintético contaminando relatório é o risco mais concreto, e eu aprendi do jeito ruim. Meu smoke rodava de 5 em 5 minutos criando e estornando romaneio. O estorno funcionava — o registro ficava com status cancelado, como qualquer estorno de usuário. Só que o relatório de movimentação mensal contava registros cancelados numa linha de "documentos emitidos no período". Foram 8.640 romaneios sintéticos num relatório que um cliente abriu. Ninguém perdeu dinheiro, e mesmo assim foi uma das conversas mais constrangedoras que eu já tive. A correção não foi mexer no smoke: foi criar a coluna is_sintetico no cliente e caçar, uma a uma, as 14 consultas que precisavam filtrar.
A flag esquecida é o segundo. A romaneio.fechamento-em-lote ficou em 40% de rollout por quatro meses. Não por decisão — porque a pessoa que ia validar entrou de férias, voltou pra outra prioridade e a flag sumiu do radar. Durante quatro meses, dois clientes com perfil parecido tiveram comportamentos diferentes no fechamento, e quando um deles abriu chamado, ninguém do suporte tinha como saber por quê. Foi isso que me fez escrever o registro versionado.
Alerta demais é o terceiro, e é o mais traiçoeiro porque parece zelo. Alerta que dispara e não exige ação vira ruído, ruído vira regra de e-mail, e a regra de e-mail engole o alerta que importava. Meu critério hoje: se disparar às 3h da manhã e a resposta honesta for "vejo amanhã", isso não é alerta, é gráfico.
E o quarto, que é onde eu não tenho paciência nenhuma: usar produção como desculpa pra não escrever teste. "Pra que teste de integração se a gente tem canary?" Isso não é maturidade em entrega contínua, é preguiça com vocabulário de conferência. Canary detecta que a versão nova está pior; ele não te diz por quê, não roda em 40 segundos no seu pull request, e o custo de descobrir com ele é um cliente vendo a falha. Teste antes do deploy é a coisa mais barata da lista, por várias ordens de grandeza. Se o seu argumento pra não escrevê-lo é observabilidade, você trocou uma dívida de teste por uma dívida de incidente — e essa cobra juros. A conversa útil é onde vale gastar cada teste, não se vale ter.
"Isso é coisa de big tech. Meu time tem 8 pessoas e um Jenkins"
É o contra-argumento certo e eu concordo com metade dele. Service mesh com roteamento por peso, plataforma de flags paga, malha de tracing distribuído — nada disso cabe num time de 8 pessoas, e insistir nisso é como tentar montar uma fábrica pra fazer um pão.
Só que a versão barata pega quase tudo. Um cron de 5 em 5 minutos rodando um script de 40 linhas que executa a jornada crítica e falha na asserção de verdade — isso é a maior parte do valor do smoke sintético, e cabe num Jenkins que você já tem. Flag em variável de ambiente com uma lista de clientes liberados, lida a cada 30 segundos, resolve 90% dos casos sem nenhum SaaS; o que você precisa mesmo é da disciplina do registro com data de morte, e isso é um arquivo .ts. Canary sem service mesh é subir a versão nova em uma das três instâncias atrás do balanceador e olhar o gráfico por 15 minutos antes de subir o resto.
E o mais barato de todos: um gráfico de quantidade de registros criados por hora nas suas três tabelas principais, na parede da sala. Uma query, um cron, um gráfico. Isso já teria pego a queda de 40% na emissão de ordem de compra.
Não é sobre ferramenta. É sobre decidir que "está no ar" e "está funcionando" são afirmações diferentes, e instrumentar a segunda.
O telefone toca de um jeito ou de outro
Passei anos do outro lado dessa linha, atendendo cliente que descobriu o bug antes de nós. O que eu não entendia na época é que aquilo não era falha de teste — era ausência de qualquer sensor entre o deploy e o cliente. A gente entregava no escuro e usava o usuário como sonda.
Testar em produção não é abrir mão do rigor. É admitir que uma classe inteira de defeito só nasce lá, e decidir quem vai encontrá-la primeiro.
Se não for você, vai ser ele. E ele vai ligar.