← Todos os artigos

Seu dado de teste é dívida técnica

A fixture que ninguém mais entende é a maior fonte de teste instável que eu já vi de perto. E o pior é que ela parece barata.

Quinta-feira, 16h40. A regressão noturna já tinha rodado verde três vezes na semana, e de repente caem 3 dos 12 specs de ordem de compra no Jenkins. Rodo na minha máquina: passa. Rodo de novo no pipeline: quebra igual. Passo quarenta minutos lendo diff de commit que não tinha nada a ver com aquilo.

Aí alguém do produto aparece no canal do time e conta, de boa, que estava reproduzindo um bug em homologação e inativou um produtor pra testar uma regra de bloqueio. Aquele produtor. O que estava com o UUID chumbado em produtores.json desde 2023, e que quatro specs usavam como "o produtor com limite de crédito baixo".

A culpa não era dele. A culpa era minha — aquela fixture era minha, eu escrevi e defendi por uns oito meses com o argumento de que "assim o teste roda rápido e não depende de API".

Dado de teste não é anexo do teste. É infraestrutura. Precisa de versionamento, de dono, de contrato e — principalmente — de ser descartável. Enquanto não for, a sua suíte não está te dizendo se o sistema funciona. Está te dizendo se o ambiente ainda está do jeito que estava quando alguém escreveu aquilo.

Quatro jeitos de arrumar dado, nenhum deles inocente

Existem basicamente quatro modelos, e eu já apanhei de todos.

Dump de produção. É o mais realista e o mais perigoso. Você pega um backup, restaura em homologação e pronto: tem volume, tem caso esquisito, tem aquele cadastro de 2019 com inscrição estadual em formato antigo que nenhum dev imaginaria. O problema é que num sistema de gestão agro esse dump carrega CPF de produtor pessoa física, CNPJ de fazenda, e coordenada de propriedade rural. Isso é dado pessoal com nome, sobrenome e endereço no mapa. Voltamos nisso mais pra frente, porque merece espaço.

Fixture estática em arquivo. Rápido de escrever, roda offline, e é a que apodrece mais rápido. Todo campo novo que entra no domínio deixa o arquivo defasado. Ninguém atualiza os 60 objetos, então metade da fixture representa um estado que o sistema não produz mais.

Factory que gera sob demanda. Uma função que devolve um objeto coerente do domínio e aceita override do que interessa. Custa mais na primeira semana. Paga a partir da quarta.

Seed via API do próprio sistema. O teste cria o produtor chamando o mesmo endpoint que a tela chama. É o único modelo que garante que o dado é válido segundo as regras atuais do sistema, porque passou pela validação de verdade. Em compensação é lento, e acopla o setup do teste A à saúde do endpoint do teste B.

Modelo Velocidade de escrita Manutenção Isolamento Risco LGPD Paralelismo
Dump de produção Alta (já existe) Péssima Nenhum Altíssimo Ruim
Fixture estática Altíssima Ruim Médio Alto se veio de prod Razoável
Factory / builder Média Ótima Alto Baixo Ótimo
Seed via API Média Boa Alto Baixo Depende do throughput

Eu defendo factory como padrão, com seed via API na fronteira do sistema. Ou seja: a factory monta o payload, o seed via API materializa quando o teste precisa que o registro exista de fato no banco. Nos testes de componente e nos unitários, a factory sozinha resolve.

Uso fixture estática em exatamente um caso: contrato de integração congelado. Se eu recebo um XML de nota fiscal de insumo de um parceiro, aquele arquivo é uma amostra real de um contrato que não muda — ele deve ser estático mesmo, porque a graça é justamente ele não acompanhar minha refatoração. E uso dump de produção só para teste de carga e de performance de query, num ambiente separado, com dado já sintetizado.

A fixture que virou refém

Esse é o arquivo real do projeto que eu mencionei, resumido. Tinha 1.847 linhas.

cypress/fixtures/produtores.json
{
  "produtores": [
    {
      "id": "a3f9c2e1-4b7d-4c88-9f10-2d6e8b4a1c33",
      "nome": "Agropecuária Boa Vista Ltda",
      "cpfCnpj": "12.345.678/0001-90",
      "limiteCredito": 150000,
      "ativo": true,
      "propriedades": [
        /* 4 propriedades, 27 talhões, cada um com histórico de safra */
      ]
    },
    /* ... mais 62 produtores, ninguém lembra pra que serve cada um ... */
  ]
}

E o teste que dependia dele:

cypress/e2e/ordem-compra.cy.ts
it('bloqueia ordem de compra acima do limite', () => {
  cy.fixture('produtores').then(({ produtores }) => {
    const produtor = produtores[3]              // por que o 3?
    const propriedade = produtor.propriedades[0]
    const talhao = propriedade.talhoes.find(
      (t) => t.cultura === 'SOJA'
    )

    cy.criarOrdemCompra({
      produtorId: produtor.id,
      talhaoId: talhao.id,
      valorTotal: 190000                     // por que 190 mil?
    })

    cy.contains('Limite de crédito excedido').should('be.visible')
  })
})

Repare no que esse teste não conta. Ele não diz que o limite do produtor é R$ 150.000. Não diz que 190.000 foi escolhido porque é maior que 150.000. A regra de negócio inteira do cenário está escondida na linha 412 de um JSON. Quem lê o teste tem que abrir outro arquivo pra entender o assert. E quando o produto inativou aquele produtor em homologação, o índice 3 continuou existindo, o teste continuou rodando, e o resultado virou um "Produtor inativo" em vez de "Limite de crédito excedido".

Um teste que falha por motivo errado é pior que um teste que não existe, porque consome tempo de investigação e ainda gasta a confiança do time.

O mesmo cenário, com factory e override

cypress/e2e/ordem-compra.cy.ts
import { umProdutor, umTalhao, umaOrdemCompra } from '../factories/agro'

it('bloqueia ordem de compra acima do limite de crédito', () => {
  const produtor = umProdutor({ limiteCredito: 150_000 })
  const talhao   = umTalhao({ cultura: 'SOJA' })
  const ordem    = umaOrdemCompra({
    produtorId: produtor.id,
    talhaoId: talhao.id,
    valorTotal: 190_000
  })

  cy.semear({ produtores: [produtor], talhoes: [talhao] })
  cy.criarOrdemCompra(ordem)

  cy.contains('Limite de crédito excedido').should('be.visible')
})

Dez linhas, e a regra do cenário está toda visível: limite de 150 mil, pedido de 190 mil, esperado bloquear. O produtor tem nome, tem CNPJ, tem inscrição estadual — só que nada disso aparece, porque nada disso importa aqui. Esse é o ponto todo do override: o que está escrito no teste é exatamente a variável do cenário. Tudo que ficou implícito é ruído que o default cobre.

Isso muda como você lê uma suíte. Bate o olho num arquivo de 400 linhas e consegue mapear os cenários pelos overrides, sem abrir mais nada.

Como a factory é por dentro

cypress/factories/agro.ts
export type Cultura = 'SOJA' | 'MILHO' | 'ALGODAO'

export interface Produtor {
  id: string
  nome: string
  cpfCnpj: string
  inscricaoEstadual: string
  limiteCredito: number
  ativo: boolean
}

export interface Talhao {
  id: string
  propriedadeId: string
  nome: string
  areaHa: number
  cultura: Cultura
}

// IDs previsíveis e legíveis no log: prd-0001, tal-0002...
// Zerado no beforeEach para a numeração não vazar entre specs.
let sequencia = 0
export const resetarSequencia = () => { sequencia = 0 }
const proximoId = (prefixo: string) =>
  `${prefixo}-${String(++sequencia).padStart(4, '0')}`

export function umProdutor(over: Partial<Produtor> = {}): Produtor {
  return {
    id: proximoId('prd'),
    nome: 'Agropecuária Serra Azul Ltda',
    cpfCnpj: '11.222.333/0001-81',   // sintético, DV válido
    inscricaoEstadual: '10.987.654-3',
    limiteCredito: 500_000,
    ativo: true,
    ...over
  }
}

export function umTalhao(over: Partial<Talhao> = {}): Talhao {
  return {
    id: proximoId('tal'),
    propriedadeId: over.propriedadeId ?? umaPropriedade().id,
    nome: 'Talhão Sede',
    areaHa: 118.5,
    cultura: 'SOJA',
    ...over
  }
}

Três decisões nesse arquivo que valem explicar. A primeira: Partial<Produtor> e não any. Se alguém renomear limiteCredito na entidade, o TypeScript quebra em todos os testes que passam esse override, e isso é exatamente o que você quer — a suíte inteira te avisa em tempo de compilação, não às 3h da manhã no pipeline.

A segunda: o spread do override vem por último. Parece óbvio, mas eu já vi factory com o spread no começo, o que faz o override silenciosamente não funcionar. Passa um limiteCredito e nada acontece. Esse tipo de bug custa uma tarde.

A terceira: ID sequencial legível em vez de UUID aleatório. Quando o teste falha, você lê prd-0003 no log e sabe qual objeto era. Com UUID você lê 36 caracteres de nada.

Determinismo não é sorte

faker sem semente é o tipo de decisão que só cobra a conta meses depois. Você gera um nome aleatório, tudo lindo, até o dia em que o gerador cospe um nome com apóstrofo e o teste que valida busca por razão social quebra. Uma vez. Você roda de novo, passa, marca como flaky e segue a vida. Aí acontece de novo dali seis semanas.

Eu tenho pouca paciência com isso hoje. Ou o dado é fixo, ou a aleatoriedade tem semente registrada.

cypress/support/determinismo.ts
import { faker } from '@faker-js/faker/locale/pt_BR'
import { resetarSequencia } from '../factories/agro'

// Semente fixa no dia a dia; variável no job noturno de fuzzing.
export const SEMENTE = Number(Cypress.env('TEST_SEED') ?? 20260703)

// Data de referência do cenário. Meio da janela de plantio da soja
// na safra 2025/2026 — nunca new Date().
export const HOJE = new Date('2025-11-12T09:00:00-03:00')

beforeEach(() => {
  faker.seed(SEMENTE)
  resetarSequencia()
  cy.clock(HOJE.getTime(), ['Date'])
})

afterEach(function () {
  if (this.currentTest?.state === 'failed') {
    // Sem isso, fuzzing é só instabilidade com nome bonito.
    cy.log(`SEMENTE=${SEMENTE} DATA_REF=${HOJE.toISOString()}`)
  }
})

Aleatoriedade tem lugar, e eu não sou contra. Rodo um job semanal com TEST_SEED variável justamente pra encontrar o nome com apóstrofo, a área de talhão com sete casas decimais, o CNPJ com zero à esquerda. Só que aí é fuzzing consciente: a semente sai no log da falha e eu consigo reproduzir com um comando. Aleatoriedade que você não consegue reproduzir não é cobertura, é ruído com aparência de rigor.

Data é a variável mais traiçoeira que existe

No agro isso dói mais que na média, porque quase tudo tem janela. Plantio de soja tem calendário definido por zoneamento, vazio sanitário tem data de início e fim, e o ano-safra não começa em 1º de janeiro — vira no meio do ano. Uma suíte que usa new Date() pra montar cenário de safra funciona lindamente de agosto a maio e depois começa a cair em junho, quando a data de hoje deixa de pertencer à safra que o teste assume.

Já vi time inteiro perder dois dias procurando o commit que quebrou a suíte, quando o que tinha mudado era o calendário. Ninguém suspeita do relógio.

A regra é simples: HOJE é do cenário, não do sistema operacional. Se o teste precisa de "uma ordem de compra feita 45 dias antes do plantio", isso é subDays(HOJE, 45), não subDays(new Date(), 45). E a factory de safra recebe a janela explícita, com código do ano-safra e tudo, porque essa informação é parte do cenário.

Limpar depois: três caminhos, três dores

Rollback de transação é o mais elegante. Abre transação antes do teste, roda, dá rollback. Instantâneo, isolamento perfeito. Só funciona bem quando o teste roda no mesmo processo do banco — em teste de integração de serviço NestJS é excelente. Em teste end-to-end pelo navegador, não dá: a requisição HTTP abre a própria transação e faz commit antes de você conseguir desfazer.

Truncate seletivo é o pragmático. Uma lista de tabelas que você limpa entre suítes. Funciona, mas é o que mais quebra em paralelismo: dois workers rodando ao mesmo tempo, um deles trunca ordem_compra no meio do cenário do outro, e você ganha uma falha que não reproduz sozinha nunca. Se for usar truncate com paralelismo, cada worker precisa do próprio schema ou do próprio banco. Sem isso, você trocou lentidão por intermitência.

Namespace por execução é o que eu mais uso em E2E. Toda entidade criada leva um sufixo do ID da execução — SERRA-AZUL-r7f3c1 — e a limpeza é um delete por prefixo, que pode acontecer até depois, num job de manutenção. O custo é que o banco de homologação engorda e alguém precisa cuidar disso. O ganho é que dois pipelines simultâneos nunca se enxergam. Pra suíte que roda em 6 workers no Jenkins, esse trade-off é fácil de aceitar.

LGPD não é seção de compliance, é decisão de arquitetura

Aqui eu preciso ser chato de propósito.

Um dump de produção de sistema de gestão agro tem CPF de produtor pessoa física, CNPJ de fazenda, endereço, telefone, e frequentemente coordenada geográfica de propriedade rural. Isso é dado pessoal, e coordenada de propriedade é dado pessoal bom — identifica melhor que nome, porque tem uma pessoa só naquele polígono.

O que eu vejo com frequência é o time trocar o campo nome por um nome falso, achar que anonimizou e dormir tranquilo. Não anonimizou. Se o CPF continua lá, não anonimizou nada, só dificultou a leitura visual. E mesmo removendo o CPF, se sobram município, cultura, área plantada e ano-safra, dá pra reidentificar cruzando com dado público de cadastro rural. Município pequeno mais 2.400 hectares de algodão é praticamente um nome.

Na prática

Anonimização de verdade exige avaliar risco de reidentificação por cruzamento, não trocar campo por campo. Isso significa generalizar (município vira mesorregião), suprimir combinação rara, e provar que o resultado se sustenta. É trabalho de especialista, custa caro e precisa ser refeito toda vez que o schema muda. Pseudonimizar mal é pior que não anonimizar, porque cria uma falsa sensação de segurança e o dado circula com menos cuidado.

Por isso a conta quase sempre fecha do lado do dado sintético. Gerar produtor, propriedade e talhão do zero custa alguns dias de trabalho de engenharia, uma vez. Anonimizar um dump direito custa análise recorrente, revisão jurídica e um processo que se degrada a cada release. E o dado sintético tem uma vantagem que ninguém comenta: você consegue criar o caso extremo que produção não tem. Talhão com área zero, safra que atravessa a virada do ano, ordem de compra com 900 itens. Produção só te dá o que já aconteceu.

"Mas fixture é mais rápido de escrever"

É. E esse argumento é honesto, não é preguiça. Quem defende isso normalmente está sob prazo e precisa de cobertura hoje, não de arquitetura. Eu já fui essa pessoa e voltaria a ser em algumas situações.

O que eu discuto não é o custo inicial, é a curva. Na semana 1 a fixture ganha fácil: 20 minutos contra umas 4 horas pra montar as primeiras factories do domínio. Na semana 12 empata, porque a fixture já foi atualizada duas vezes na mão e alguém já perdeu meio dia entendendo qual objeto é usado por qual teste. Na semana 40 não tem comparação: cada campo novo na entidade é uma varredura no arquivo inteiro, e ninguém tem coragem de deletar um objeto porque não dá pra saber quem depende dele.

A fixture cobra juros compostos e não manda extrato. O custo dela aparece disfarçado de "teste instável", de "ambiente sujo", de "isso aí só o Fulano sabe rodar".

Tem um teste bem simples pra saber onde seu time está: pegue o spec mais crítico da suíte, delete o banco de homologação inteiro e tente rodar. Se você não consegue reconstruir o cenário só com o que está no repositório, o dado não é seu. Você está alugando ele de um servidor.


Todo teste que passa por sorte vai falhar por sorte também, e sempre na sexta à noite.

Luciano Martins

Luciano Martins da Silva Junior

Bacharel em Engenharia de Software, pós-graduado em Ciência de Dados e Machine Learning. 8+ anos entre suporte, automação de testes e engenharia de dados. Escreve sobre o que quebra em produção.

Tem um problema difícil de qualidade?

Suíte instável, pipeline lento, dado que mente. É esse tipo de coisa que eu gosto de desembaraçar.

Vamos conversar