testestamperlenstypescriptqualidadedocumentacao

O teste que deriva o número publicado

Douglas Haruo 12 min 23/08/2026

Existe uma classe de bug que nenhum type checker pega, nenhum teste de unidade normal pega e nenhum code review pega de forma confiável: o número que envelheceu na página pública.

A página diz “10 famílias de sinais”. O código tem 18. Ninguém mentiu — alguém escreveu 10 quando eram 10, o produto cresceu, e a string ficou. O código não referencia a string; a string não referencia o código. Nada os liga, então nada os mantém juntos.

A regra da casa, no Tamperlens, é uma frase: todo número impresso em superfície pública deriva da fonte de verdade por teste. Não “é revisado periodicamente”. Não “está documentado onde atualizar”. Derivado, em CI, por um teste que quebra.

Esta semana três PRs foram basicamente exercícios dessa regra. Duas coisas ficaram claras: quando o padrão está aplicado ele é chato e invisível, e quando não está o custo é sempre maior do que a mudança de string que você imagina.


O caso onde funciona: preços

O exemplo mais simples é o de preço, e é o que melhor explica o formato.

src/services/plans.ts é a fonte única de verdade tanto para os objetos de Price no Stripe quanto para a cota de cada plano. A página de preços não pode ter o número escrito à mão — se ela divergir, ela cota um valor que o cliente não vai ser cobrado, que é a única falha que uma página de preços não pode ter.

O teste não compara duas constantes. Ele busca o HTML servido e afirma que o valor renderizado é o valor derivado de plans.ts:

for (const id of PLAN_IDS) {
  const quoted = MARK[url](displayPrice(id, "USD"));
  assert.ok(
    html.includes(`<span data-price="${id}">${quoted}</span>`),
    `${url} does not quote ${id} at ${quoted} (amount from src/services/plans.ts)`,
  );
}

O MARK ali é a parte interessante, e é uma lição de i18n que veio de um susto real. O valor vem de plans.ts; a marca de moeda é convenção por idioma. Em inglês a página escreve $29 — o leitor já está em contexto de dólar. Em português precisa escrever US$ 29, porque um $ sozinho lê como reais para um brasileiro, e a cobrança real em BRL é cinco vezes o número impresso.

/pt/precos era a única superfície em português que ainda mandava o $ puro, enquanto /pt/, /pt/api e /pt/dashboard já escreviam US$ . E o script de preços só repinta com base no CF-IPCountry: BR — então um brasileiro atrás de VPN lia a forma ambígua exatamente na página de maior intenção de compra.

Hoje há dois testes: um que deriva o valor de plans.ts, e outro que varre todas as superfícies em português procurando um $ sem US. O segundo é uma varredura, não uma lista — porque a página seguinte que alguém criar também precisa obedecer.


Caso 1: o placar 82 que nunca existiu

Este é o mais caro dos três, porque não era um número em texto. Era um número calculado.

O produto publica um invariante em três lugares: uma família de sinais ou dispara uma vez, com todos os achados dela reunidos num único objeto de evidência, ou não aparece. A página pública diz isso, a gêmea em português diz isso, e a ARCHITECTURE.md repete. Além disso, o módulo que escolhe a explicação de cada família mapeia uma família para uma explicação confiando nisso.

Três consumidores construídos sobre a promessa, antes de qualquer coisa fazer valer a promessa.

Medido rodando o motor de Office sobre as fixtures do próprio repositório:

fixturefamíliasinais
office-doctype.docxoffice-structure-anomalies[medium] [low] [info]
office-macro-template.docxoffice-active-content[high] [medium] [medium]
office-injection-markers.docxoffice-hidden-content[medium] [low]

Três famílias violando o invariante em três fixtures diferentes — e o registro de sinais ainda podia acrescentar um quarto por cima de qualquer uma delas.

O que isso custou de verdade

Não foi só o formato do relatório. Foi o placar.

A função de pontuação decai o segundo sinal e os seguintes de uma mesma severidade porque assume que são famílias diferentes se corroborando. Uma família disparando três checagens coletava três contribuições independentes por um único achado.

Resultado: office-macro-template.docx pontuava 82. Com a evidência exatamente igual, hoje pontua 70.

O 82 nunca existiu. Não era um placar alto demais por generosidade de calibração — era um placar que somava a mesma evidência três vezes, e ninguém tinha como ver isso olhando o número.

Junto disso, summary.signalCount — o número que aparece no cabeçalho do relatório — contava achados em arquivos Office e famílias em todo o resto. O mesmo campo, dois significados, dependendo do tipo do arquivo. E a interface do relatório indexa o texto e o link “o que este sinal significa” por família, então cada card duplicado repetia os dois.

O teste, e o quarto caso que a revisão não tinha achado

A correção é uma função de dobra. Mas o que faz a correção durar é o teste: ele roda os três motores sobre todas as fixtures do repositório, coleta todas as violações antes de afirmar (em vez de parar na primeira), e separadamente fixa que signalCount conta famílias e não achados.

Ele achou um quarto caso: redaction-mixed-covers.pdf emite dois sinais na mesma família — uma caixa preenchida sobre texto em high, uma imagem sobre texto em info.

E esse não foi corrigido, e a razão é o ponto. A separação é deliberada, medida e coberta por fixture: juntar os dois era o comportamento antigo, e era um bug — ele anunciava “4 trechos de texto continuam legíveis sob caixas desenhadas por cima” em high quando três dos quatro eram o timbre do papel. Os dois achados carregam as mesmas chaves de evidência em escopos diferentes, então a dobra silenciosamente descartaria um dos conjuntos de números — e os números são o motivo da separação existir.

Resolver isso significa ou aninhar a evidência daquela família (mudança de API na família principal do produto) ou mudar a frase publicada de “um sinal por família” para algo como “um por tipo de achado”. As duas são decisões sobre o que o produto promete, então o PR registrou a exceção em vez de tomá-la — na página pública, na gêmea em português, na ARCHITECTURE.md e no teste, que isenta por família: uma segunda família aparecendo duas vezes reprova, e essa mesma família aparecendo três vezes também reprova.

O site parou de afirmar algo com que o motor discorda. Que é o mínimo, e é diferente de ter resolvido.

O bônus: o teste que se protegia com regex

De quebra, um teste de cobertura contava as famílias de Office com uma regex sobre o código-fonte de um array privado do módulo. O comentário dele mesmo chamava isso de solução temporária, “até o dia em que alguém exportar o array”.

Esse dia chegou dentro dessa mesma mudança — e a regex teria passado a ler 7 silenciosamente no momento em que uma das famílias saiu do array. Esse número é publicado em duas páginas.

Um teste que deriva o número da representação errada não é melhor que uma constante escrita à mão. É pior, porque parece protegido.


Caso 2: o card social que dizia dez

Este é o mais visível e o mais barato de consertar — e o mais educativo sobre onde um número consegue se esconder.

O gerador de assets de marca renderizava “All ten signal families” num PNG que é o og:image de dez páginas. A página correspondente abre com <h2>The eighteen families</h2>.

Um denominador errado na imagem mais compartilhada de um produto cujo diferencial inteiro é que os denominadores dele são publicados e corretos.

A varredura existente não conseguia enxergar isso, duas vezes:

  1. A string mora em scripts/, fora dos diretórios que aquele teste varre.
  2. O que é publicado é um PNG. Nenhum lint de texto lê pixel.

Correções: string corrigida, card re-renderizado com Playwright em 1200×630, e — detalhe que vale copiar — só um PNG entrou no diff. O comando regenera os dez; os outros nove mudaram só por jitter de renderização e foram revertidos, para que a mudança binária no histórico seja exatamente a do card cujo conteúdo mudou.

E aí o teste novo, que é o que importa: ele varre o gerador procurando contagens de famílias e as deriva dos módulos registrados. Um segundo teste fecha a classe inteira que o cabeçalho do gerador descrevia em prosa (“o og:image de cada página precisa nomear o arquivo correspondente — dê grep no nome do arquivo ao adicionar uma página aqui”): todo card que ele nomeia existe em disco e é referenciado por pelo menos uma página. Aquele grep passou a rodar em CI em vez de rodar na memória de quem lembrar.

O irmão do mesmo PR

No mesmo lote, um post do blog dizia que uma lista de famílias suprimidas por criptografia “tem cinco nomes” e que uma senha de dono “desliga um terço do motor”. O array em types.ts tem oito desde a versão 1.15.0.

Aqui tem uma sutileza que eu acho a parte mais útil deste post inteiro. Os testes de cobertura isentam o blog por uma regra que vale manter: um post datado registra o que foi medido no dia em que foi publicado, e mover um número sem refazer a medição é reivindicar uma medição que ninguém fez.

Mas esse número específico nunca esteve coberto pela isenção. Não é resultado de bench. É o comprimento de um array no código atual, citado no presente, conferível numa leitura — numa página cuja tese inteira é que os números dela são medidos e não afirmados.

Corrigido para oito, nomeando as três famílias que o post nunca cobriu, e trocando “um terço” por “oito das dezoito”. Com um aviso datado explicando por que esse número podia se mover enquanto a matriz do post não podia. E o teste passou a derivar as duas figuras da constante.

A distinção entre “número que pode ser derivado” e “número que registra uma medição de uma data” é o que faz essa regra ser aplicável sem virar tirania.


Caso 3: a bancada que publicava as próprias falhas como resultado

O terceiro é o meu preferido, porque a ferramenta de medição estava mentindo, e o teste novo é sobre a ferramenta.

A matriz do post — quais sinais sobrevivem a quais transformações — tinha que ser re-rodada sobre as 18 famílias. Foi. A primeira coisa que o run pegou foi a própria bancada.

Quando uma ferramenta externa sai com status diferente de zero, a função que a chama não devolve caminho de arquivo, e quem chama guarda { error } — um objeto sem a chave families. O resumo de cada célula lia esse undefined exatamente como lê uma família que parou de disparar. O veredicto virava gone: “o sinal sumiu”.

Nove das 92 fixtures são legitimamente recusáveis — uma delas nem é um PDF, outra já é criptografada e não pode ser re-criptografada, e uma das ferramentas recusa seis arquivos deliberadamente malformados. Ou seja: toda coluna, menos uma, carregava contagem inflada de “sumiu”.

Alguns exemplos do antes/depois:

CélulaPublicadoMedido
structure-warnings × stripsumiu em 3 de 7sumiu em 0 — os três eram arquivos que a ferramenta recusou
structure-warnings × encryptmais fraco, sumiu em 2 de 7mais fraco, sumiu em 0
id-inconsistency × rewritesumiu em 5 de 8sumiu em 3 dos 6 que a ferramenta processou
document-injection-markers × printsumiunão medido: n=1 e a ferramenta recusa aquela fixture

E o detalhe que dói: o script já carregava a regra que estava quebrando. O cabeçalho dele diz que uma variante cuja ferramenta está ausente é não-medida, “ausente, não zero”, porque “uma coluna faltando silenciosamente é como uma matriz começa a mentir”. Ele aplicava isso por coluna e não por arquivo.

Corrigido na origem: falhas são contadas separadamente, nomeadas com os nomes dos arquivos no cabeçalho do run, e exibidas como unmeasured.

Uma verificação importante antes de concluir qualquer coisa: a bancada inteira foi rodada em duas versões do motor sobre as mesmas fixtures com as mesmas ferramentas. As saídas são byte a byte idênticas exceto pela linha de versão. O motor não estava implicado; o instrumento estava.

O formato final: registro escrito pela própria ferramenta

E aqui está o padrão que fecha o post.

docs/evasion-bench-last-run.json é escrito pela bancada (--record), nunca à mão. O teste lê esse registro e reprova quando:

  • a versão do motor passa da versão gravada no registro;
  • uma nova família nunca passou pela bancada;
  • qualquer n= publicado, figura de laundering ou barra de famílias silenciadas discorda do registro;
  • e ele re-deriva a conclusão do post (“nada no conjunto incondicional sobrevive às seis”) em vez de confiar na frase escrita.

Cada afirmação foi observada falhando contra um valor errado antes de ter permissão para passar. Que é o passo que separa um teste de derivação de um teste que só concorda com o que já está lá.

Total do lote: 1.602 testes, 0 falhas.


O padrão, generalizado

Três formatos, escolhidos pelo tipo do número:

  1. Número que vem de constante → o teste importa a constante e afirma sobre o HTML servido. Nunca compare duas constantes; compare a constante com o que o usuário recebe.
  2. Número que vem de uma varredura → o teste faz a varredura, não mantém uma lista. Lista escrita à mão exclui a página que alguém vai criar amanhã.
  3. Número que vem de uma medição → a ferramenta escreve um registro em JSON, o teste compara a página com o registro, e o registro sabe qual versão foi medida. Assim “atualizar o número” e “refazer a medição” viram a mesma operação, que é a única forma honesta.

E o teste do teste, que é a parte que as pessoas pulam: veja a asserção falhar contra o valor errado antes de deixar ela passar. Um teste de derivação que nunca foi visto falhando é indistinguível de um teste que lê a mesma string dos dois lados.

O que não escala é a alternativa: confiar que alguém vai lembrar. Nesta semana, 82 → 70, dez → dezoito, cinco → oito, sumiu em 3 de 7 → sumiu em 0. Nenhum desses foi descoberto por leitura atenta. Todos foram descobertos por um teste novo — ou, no caso mais revelador, pela própria ferramenta que os produzia, no momento em que ela foi rodada olhando o que fazia com os erros.

Precisa de um projeto técnico sob medida?

Arquitetura, TypeScript, APIs e automação, do protótipo à produção. Quem responde o seu e-mail é quem escreve o código, e o prazo que eu prometo é o prazo que eu consigo cumprir.

Me manda uma mensagem →