Você entrega um modelo de machine learning que rotula tickets de suporte. Sua test suite está verde. Cada teste passou.

Nenhum desses testes realmente verifica se os rótulos estão corretos. Você não sabe quais são os rótulos certos. Ninguém sabe. A saída “correta” é praticamente incognoscível para entradas do mundo real, então você recorre a verificar se a função não quebra, ou se a forma da saída corresponde às expectativas. Isso não é testar. É esperar.

Esse é o problema do oráculo, e ele aparece em toda parte: compilers, simulações, algoritmos de otimização, detectores de fraude e qualquer sistema onde a verdade fundamental é cara, subjetiva ou impossível de computar. Quando você não pode definir a saída esperada, os unit tests tradicionais desmoronam.

O metamorphic testing oferece uma saída. Em vez de perguntar “essa saída está correta?”, você pergunta “entradas relacionadas produzem saídas que satisfazem um relacionamento conhecido?” Se não produzirem, seu código está quebrado. Se produzirem, você ganhou confiança real sem nunca saber a resposta certa.

O que é metamorphic testing?

O metamorphic testing foi proposto no final dos anos 1990 por Tsong Yueh Chen e colegas como resposta a uma realidade frustrante: muitos programas são genuinamente úteis, mas praticamente impossíveis de testar com oráculos tradicionais.

A ideia central é simples. Você identifica uma metamorphic relation: uma propriedade que deve se manter entre as entradas e saídas de múltiplas execuções do seu programa. Você executa o programa em uma entrada fonte, transforma essa entrada de acordo com a relação, executa novamente e verifica se as saídas se relacionam entre si da forma que a propriedade prevê.

Nenhuma saída esperada necessária. Nenhum rotulador humano. Nenhum dataset dourado.

Considere uma função que encontra o caminho mais curto em um grafo ponderado:

from typing import List, Tuple, Optional

def shortest_path(
    edges: List[Tuple[int, int, float]],
    start: int,
    end: int
) -> Optional[float]:
    """Dijkstra's algorithm. Returns path length or None if unreachable."""
    import heapq

    graph = {}
    for u, v, w in edges:
        graph.setdefault(u, []).append((v, w))

    dist = {start: 0.0}
    heap = [(0.0, start)]

    while heap:
        d, u = heapq.heappop(heap)
        if u == end:
            return d
        if d > dist.get(u, float('inf')):
            continue
        for v, w in graph.get(u, []):
            nd = d + w
            if nd < dist.get(v, float('inf')):
                dist[v] = nd
                heapq.heappush(heap, (nd, v))

    return None

Para um grafo complexo, computar o caminho mais curto esperado à mão é tedioso. Mas conhecemos várias metamorphic relations que devem se manter:

  1. Monotonicidade em relação aos pesos das arestas. Se você aumentar o peso de qualquer aresta individual, o caminho mais curto não deve ficar mais curto. Ele pode permanecer o mesmo (se essa aresta não estava no caminho ótimo) ou ficar mais longo.

  2. Homogeneidade sob escalonamento. Se você multiplicar cada peso de aresta por uma constante positiva, o comprimento do caminho mais curto deve escalar pela mesma constante.

  3. Simetria de caminho em grafos não direcionados. Se o grafo é não direcionado, trocar início e fim deve produzir o mesmo comprimento de caminho.

Essas não são heurísticas. São propriedades matemáticas. Se alguma delas falhar, a implementação está errada, ponto final.

Como escrever um metamorphic test

Aqui está como a relação de monotonicidade se parece na prática:

import random

def test_shortest_path_monotonicity():
    # Generate a random connected graph
    nodes = list(range(10))
    edges = []
    for i in range(len(nodes) - 1):
        edges.append((i, i + 1, random.uniform(1.0, 10.0)))
    # Add some random cross edges
    for _ in range(10):
        u, v = random.sample(nodes, 2)
        edges.append((u, v, random.uniform(1.0, 10.0)))

    start, end = 0, 9
    original = shortest_path(edges, start, end)
    assert original is not None

    # Increase the weight of one arbitrary edge
    idx = random.randrange(len(edges))
    u, v, w = edges[idx]
    modified_edges = list(edges)
    modified_edges[idx] = (u, v, w + 5.0)

    modified = shortest_path(modified_edges, start, end)
    assert modified is not None
    assert modified >= original

Esse teste nunca computa o caminho mais curto esperado. Não precisa. Ele verifica se uma propriedade estrutural se mantém, o que é suficiente para capturar uma variedade surpreendente de bugs: erros de sinal, erros off-by-one em acumulação de peso, ordenação incorreta de queue de prioridade e mais.

A relação de escalonamento é ainda mais simples de testar:

def test_shortest_path_scaling():
    nodes = list(range(8))
    edges = []
    for i in range(len(nodes) - 1):
        edges.append((i, i + 1, random.uniform(2.0, 5.0)))

    start, end = 0, 7
    original = shortest_path(edges, start, end)

    factor = 3.5
    scaled_edges = [(u, v, w * factor) for u, v, w in edges]
    scaled = shortest_path(scaled_edges, start, end)

    assert abs(scaled - original * factor) < 1e-9

Observe a tolerância de ponto flutuante. Os metamorphic tests não são imunes a problemas de precisão numérica, então escreva suas asserções com o mesmo cuidado que usaria em qualquer outro teste numérico.

Onde essa abordagem realmente ajuda

O metamorphic testing brilha em domínios onde os oráculos tradicionais são fracos ou inexistentes.

Machine learning. Você não sabe o score exato de sentimento para uma resenha de filme, mas sabe que adicionar a palavra “terrible” não deve aumentar o sentimento positivo. Você não sabe a bounding box exata para um detector de objetos, mas sabe que espelhar a imagem horizontalmente deve espelhar as coordenadas da bounding box.

compilers. Verificar que um binário otimizado produz exatamente a mesma saída que um não otimizado para todo programa possível é impossível. Mas você pode verificar que compilar um programa e depois compilá-lo novamente com uma transformação no-op (como renomear uma variável) produz binários semanticamente equivalentes.

Computação científica. Você não sabe a trajetória exata de uma partícula em uma simulação complexa, mas sabe que reverter o tempo deve reverter a trajetória. Você não sabe a energia exata do estado fundamental de uma molécula, mas sabe que ela deve diminuir (ou permanecer constante) à medida que você aumenta o tamanho do conjunto de bases.

Em cada caso, a intuição é a mesma: a correção não é sempre sobre corresponder a um único valor esperado. Às vezes é sobre preservar estrutura através de transformações.

As compensações e limitações

O metamorphic testing não é de graça, e não é um substituto para todo outro tipo de teste.

As relations podem ser incompletas. Um programa pode passar em toda metamorphic relation que você definir e ainda estar errado. Se o seu conjunto de relations não cobrir uma classe de bug específica, esse bug escapa. Esse é o problema de cobertura, e ele é real.

As relations podem estar erradas. Se você afirmar erroneamente que uma propriedade se mantém quando não se mantém, seu teste se torna uma fábrica de falsos positivos. Uma vez vi uma equipe afirmar que o clustering k-means deveria ser invariante ao escalonamento de features. Não é. Os centróides escalam com os dados. O teste passou por meses porque o fator de escalonamento por acaso era 1.0 nos dados de teste. Quando os dados reais chegaram, o modelo degradou silenciosamente enquanto os testes permaneceram verdes.

Depurar falhas é mais difícil. Quando um unit test tradicional falha, você sabe exatamente qual era a saída esperada. Quando um metamorphic test falha, você sabe que uma relation foi violada, mas ainda não sabe a saída correta. Você precisa raciocinar de trás para frente a partir da violação da propriedade até o bug subjacente, o que pode ser mais trabalhoso.

A geração de dados de teste importa. Grafos aleatórios, frases aleatórias e imagens aleatórias não são representativos de entradas reais. Um metamorphic test em dados sintéticos pode passar enquanto o sistema de produção falha em edge cases que seu gerador nunca produziu. Use bibliotecas de property-based testing como Hypothesis para ajudar, mas mantenha-se cético em relação aos seus próprios generators.

Como começar a usar hoje

Você não precisa de um novo framework. Precisa de três coisas:

  1. Escolha uma função com um oráculo fraco. Um método de inferência de modelo, uma computação geométrica, um passo de simulação. Algo onde você atualmente testa “não quebra” e gostaria de poder fazer mais.

  2. Brainstorm três relations. Pergunte: quais transformações devem deixar a saída inalterada? Quais transformações devem mudar a saída de forma previsível? Quais pares de entradas devem produzir saídas relacionadas? Anote-os, mesmo que pareçam óbvios.

  3. Implemente uma relation como teste. Execute-o em entradas aleatorizadas. Se falhar, você encontrou um bug ou uma relation errada. Ambos são valiosos.

Para o exemplo de k-means que mencionei anteriormente, um conjunto de relations correto se parece com isto:

import numpy as np
from sklearn.cluster import KMeans

def test_kmeans_translation_invariance():
    X = np.random.rand(100, 3)
    shift = np.array([10.0, -5.0, 2.0])

    km1 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X)
    km2 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X + shift)

    # Centroids should differ by exactly the shift vector
    np.testing.assert_allclose(km1.cluster_centers_ + shift, km2.cluster_centers_)

Esse teste falhará se a lógica de clustering tratar incorretamente o sistema de coordenadas, e faz isso sem nunca afirmar quais são os centróides corretos.

Quando você não pode saber a resposta, teste a estrutura

O problema do oráculo não é um edge case de testes. É o estado padrão para uma enorme classe de software útil. O metamorphic testing não resolve completamente, mas te move de “não consigo testar isso” para “consigo testar as propriedades que importam”.

Comece com uma relation, uma função e um bug real que ela captura. Isso é suficiente para justificar a abordagem. Todo o resto é apenas adicionar mais relations.