Você escreveu doze metamorphic relations para seu motor de precificação. Cada teste passa. Você se sente bem com sua cobertura.
Então um cliente relata que os descontos por volume estão sendo calculados ao contrário. Você verifica sua suite de relations. Nenhum teste falhou. Você tinha relations para consistência aditiva, monotonicidade e idempotência. Nenhuma delas capturou um erro de sinal no multiplicador de desconto.
Esse é o segredo sujo do metamorphic testing: ter relations não é o mesmo que ter relations úteis. Uma metamorphic relation fraca é pior do que nenhum teste, porque te convence de que seu código está correto quando não está.
O que faz uma relation ser “boa”?
Uma boa metamorphic relation tem alta capacidade de detecção de falhas. Ela captura bugs reais que programadores realmente escrevem. O resto é apenas sobrecarga.
O exemplo clássico é testar uma função de média com um bug off-by-one:
def buggy_mean(values):
"""Compute the arithmetic mean."""
return sum(values) / (len(values) - 1) # bug: off-by-one in denominator
Isso parece plausível se você está acostumado com fórmulas de variância amostral. Também está errado. Aqui estão quatro relations que as pessoas comumente escrevem para uma função de média, e o que cada uma realmente captura:
-
Limitação: a média fica entre min e max. Fraca. A média com bug ainda satisfaz isso para a maioria das entradas.
-
Idempotência em constantes:
mean([c] * n) == c. Média. Ela captura o bug para listas constantes, mas dados aleatórios raramente disparam uma falha. -
Invariância de translação:
mean([x + c for x in values]) == mean(values) + c. Forte. O denominador com bug quebra isso para quase toda entrada não vazia. -
Escalonamento:
mean([x * k for x in values]) == mean(values) * k. Forte. Mesmo motivo. O off-by-one sobrevive ao escalonamento em exatamente zero casos interessantes.
Se sua test suite apenas verificasse limitação e idempotência de constantes, o off-by-one navegaria para a produção. Você teria metamorphic tests. Você não teria detecção de bugs.
Relations fortes versus relations fracas
A diferença entre uma relation forte e uma fraca não é o quão inteligente ela soa. É quantas classes de falha ela elimina.
Uma relation fraca verifica uma propriedade que a maioria das implementações incorretas acidentalmente satisfaz de qualquer forma. A limitação é um exemplo perfeito. A maioria dos bugs aritméticos preserva a limitação porque adição e multiplicação não inventam espontaneamente valores fora do intervalo de entrada. Uma relation que passa para código quebrado é teatro.
Uma relation forte codifica uma restrição estrutural que implementações quebradas violam. A invariância de translação é forte porque vincula a transformação de entrada à transformação de saída através de uma igualdade precisa. Não há margem de manobra.
Você pode medir isso formalmente. Na pesquisa de metamorphic testing, a subsumção de relation significa que a relation A detecta toda falha que a relation B detecta, mais algumas. Se A subsume B, então B é redundante. Você deve manter A e deletar B.
Na prática, você não precisa da prova formal. Você precisa da intuição: se uma relation ainda passaria depois que você deliberadamente introduzir um bug plausível, ela é fraca. Jogue fora.
Boas relations cobrem domínios de falha diferentes
Uma relation forte não é suficiente. Uma relation única captura uma classe de erros. Programas reais contêm múltiplos tipos de bugs independentes, e seu conjunto de relations precisa cobri-los.
Considere uma função de ordenação. Aqui estão relations classificadas pelo que elas capturam:
Permutação: a saída contém exatamente os mesmos elementos que a entrada. Captura bugs de perda/duplicação. Perde bugs de ordenação.
Ordem: a saída é não decrescente. Captura bugs de comparação. Perde bugs de permutação.
Idempotência: sort(sort(x)) == sort(x). Captura apenas implementações genuinamente quebradas que destroem a ordenação. Quase inútil.
Estabilidade: se você parear cada elemento com seu index original, chaves iguais permanecem na ordem de entrada. Captura operadores de comparação que usam >= em vez de >.
Subestrutura: ordenar um prefixo e depois a lista completa deve concordar na ordem do prefixo. Captura bugs de término prematuro.
Uma test suite com apenas permutação e idempotência perderia uma ordenação que sempre retorna [1, 2, 3]. Uma suite com permutação e ordem captura esse bug. Adicione estabilidade e você também captura ordenações instáveis.
O ponto não é coletar o máximo de relations possível. O ponto é cobrir modos de falha independentes. Duas relations que capturam o mesmo bug são piores do que uma relation que captura um bug diferente.
A compensação: relations mais fortes são mais difíceis de encontrar
Há uma razão para as equipes escreverem relations fracas. Relations fortes exigem conhecimento de domínio. Você precisa entender a estrutura matemática do seu problema bem o suficiente para codificar um invariante não óbvio.
Para a função de média, a invariância de translação é óbvia para qualquer um com background em estatística. Para uma simulação de partículas, a relation equivalente pode exigir saber que a dinâmica hamiltoniana preserva o volume do espaço de fases. Nem toda equipe tem essa especialização à mão.
O outro custo é a depuração. Quando uma relation forte falha, a violação diz que alguma propriedade estrutural quebrou, mas o bug pode estar em qualquer lugar da cadeia de raciocínio que levou a essa propriedade. Uma relation fraca como “o comprimento da saída é igual ao da entrada” falha de exatamente uma forma. Uma relation forte como “a transformada de Fourier de um sinal deslocado adquire um termo de fase linear” falha de cem formas, e rastrear qual é o seu bug leva mais tempo.
Essa é a tensão central. Relations fracas são fáceis de escrever, fáceis de depurar e principalmente inúteis. Relations fortes são difíceis de escrever, difíceis de depurar e realmente encontram bugs. Não há almoço grátis.
Como avaliar uma metamorphic relation
Antes de adicionar uma relation à sua test suite, passe-a por três verificações:
O teste de bug deliberado. Introduza um bug realista em sua implementação. A relation falha? Se não, a relation não está puxando seu peso. Tente um erro de sinal, um off-by-one, um argumento trocado, uma condição de contorno ausente. Esses são os bugs que acontecem em produção. Suas relations devem capturá-los.
O teste de independência. Olhe para suas relations existentes. Alguma delas capturaria o mesmo bug? Se sim, essa nova relation é redundante. Redundância não é segurança. É carga de manutenção sem benefício marginal.
O teste de falseabilidade. Você consegue imaginar uma implementação quebrada plausível que satisfaça a relation? Se você conseguir esboçar uma em trinta segundos, a relation é muito fraca. Uma boa relation deve parecer uma restrição apertada, não uma sugestão vaga.
Aqui está como isso se parece em código para a função de média:
import random
def mean(values):
return sum(values) / len(values)
def test_translation_invariance():
values = [random.uniform(-100, 100) for _ in range(20)]
c = 5.5
shifted = [x + c for x in values]
assert mean(shifted) == mean(values) + c
def test_scaling():
values = [random.uniform(-50, 50) for _ in range(20)]
k = 3.0
scaled = [x * k for x in values]
assert mean(scaled) == mean(values) * k
Agora introduza o bug off-by-one. Mude len(values) para len(values) - 1. Execute ambos os testes. A invariância de translação falha imediatamente. O escalonamento falha imediatamente. A limitação provavelmente passaria.
Essa é a diferença entre uma relation que merece seu lugar na sua suite e uma que está apenas ocupando linhas.
Comece com classes de falha, não propriedades
O erro que a maioria das equipes comete é fazer brainstorm de propriedades primeiro. Elas perguntam: “Quais invariants essa função tem?” Isso produz relations fracas, porque invariants são fáceis de enunciar e difíceis de violar.
Em vez disso, comece com classes de falha. Pergunte: “Quais bugs um programador cansado escreveria nessa função?” Depois encontre relations que capturem esses bugs.
Para uma função de distância geométrica, os bugs prováveis são erros de sinal, confusões de unidade e incompatibilidades de dimensão. Uma relation que verifica se a distância é não negativa captura erros de sinal. Uma relation que verifica escalonamento sob transformações de coordenadas captura confusões de unidade. Uma relation que verifica a desigualdade triangular captura absurdo de dimensão.
Se você não consegue nomear o bug que uma relation captura, você não precisa dessa relation.
Relations são um recurso escasso. Gaste-as com sabedoria.
O metamorphic testing não é sobre metrics de cobertura. É sobre confiança. Uma relation forte que captura bugs reais vale mais do que vinte relations fracas que passam para código quebrado.
Audite seus metamorphic tests existentes. Introduza um bug. Veja o que falha. Delete o que não falha. Depois adicione uma relation para cada classe de falha sobre a qual você realmente está preocupado. Essa é uma test suite que vale seu custo.