価格設定エンジン向けに12個のmetamorphic relationsを書いた。すべてのテストが通過した。カバレッジに自信を持っている。

その後、顧客から一括割引が逆に計算されているという報告があった。relationスイートを確認した。1つも失敗していない。加法的一貫性、単調性、冪等性のrelationがあった。どれも、割引乗数の符号エラーを捉えていなかった。

これがmetamorphic testingの知られざる真実だ:relationsを持つことと、有用なrelationsを持つことは同じではない。弱いmetamorphic relationは、テストがないよりも悪い。なぜなら、コードが正しいときに正しいと信じ込ませるからだ。

「良い」relationとは何か

良いmetamorphic relationは、高い欠陥検出能力を持つ。プログラマーが実際に書く本物のバグを捉える。残りは単なるオーバーヘッドだ。

古典的な例は、off-by-oneバグのある平均関数のテストだ:

def buggy_mean(values):
    """Compute the arithmetic mean."""
    return sum(values) / (len(values) - 1)  # bug: off-by-one in denominator

これは、標本分散の公式に慣れている人には見た目がもっともらしい。しかし、間違っている。平均関数に対して人々がよく書く4つのrelationと、それぞれが実際に何を捉えるかを見てみよう:

  1. 有界性:平均は最小値と最大値の間にある。弱い。バグのある平均も、ほとんどの入力でこれを満たす。

  2. 定数に対する冪等性mean([c] * n) == c。中程度。定数リストではバグを捉えるが、ランダムデータではめったに失敗を引き起こさない。

  3. 平行移動不変性mean([x + c for x in values]) == mean(values) + c。強い。バグのある分母は、ほぼすべての空でない入力でこれを破る。

  4. スケーリングmean([x * k for x in values]) == mean(values) * k。強い。同じ理由だ。off-by-oneは、スケーリング下でまったく面白くないケースでしか生き残らない。

テストスイートが有界性と定数冪等性だけをチェックしていたら、off-by-oneバグは本番環境に乗り込んでいただろう。Metamorphic testsはあった。しかし、バグ検出はなかった。

強いrelationと弱いrelation

強いrelationと弱いrelationの違いは、どれだけ賢く聞こえるかではない。どれだけの欠陥クラスを排除するかだ。

弱いrelationは、間違った実装でも偶然に満たしてしまう性質をチェックする。有界性は完璧な例だ。ほとんどの算術バグは有界性を保持する。なぜなら、加算と乗算は入力範囲外の値を突然生み出すことはないからだ。壊れたコードでもパスするrelationは、見せかけに過ぎない。

強いrelationは、壊れた実装が違反する構造的制約をエンコードする。平行移動不変性は強い。なぜなら、入力変換を正確な等式を通じて出力変換に結びつけるから。逃げ道がない。

これを形式的に測定できる。Metamorphic testingの研究では、relationのsubsumptionとは、relation Aがrelation Bが検出するすべての欠陥に加えて、さらにいくつかを検出することを意味する。AがBをsubsumesするなら、Bは冗長だ。Aを残してBを削除すべきだ。

実際には、形式的な証明は不要だ。必要なのは直感だ:故意にありそうなバグを導入した後でもrelationがパスするなら、それは弱い。捨てる。

良いrelationは異なる欠陥領域をカバーする

1つの強いrelationだけでは不十分だ。単一のrelationは1つのミスクラスを捉えるにとどまる。実際のプログラムには複数の独立したバグタイプが含まれており、relationセットはそれらをカバーする必要がある。

ソート関数を考えてみよう。どれを捉えるかでランク付けされたrelationはこうなる:

順列:出力は入力とまったく同じ要素を含む。ドロップ/重複バグを捉える。順序バグは見逃す。

順序:出力は非減少である。比較バグを捉える。順列バグは見逃す。

冪等性sort(sort(x)) == sort(x)。ソート状態を破壊する本当に壊れた実装だけを捉える。ほとんど役に立たない。

安定性:各要素に元のインデックスをペアリングすれば、等しいキーは入力順を保つ。>= の代わりに > を使う比較演算子を捉える。

部分構造:接頭辞をソートしてから完全なリストをソートすると、接頭辞の順序で一致するはずだ。早期終了バグを捉える。

順列と冪等性だけのテストスイートは、[1, 2, 3] を常に返すソートを見逃すだろう。順列と順序のスイートはそのバグを捉える。安定性を追加すれば、不安定なソートも捉えられる。

ポイントは、可能な限り多くのrelationを集めることではない。独立した失敗モードをカバーすることだ。同じバグを捉える2つのrelationは、異なるバグを捉える1つのrelationより劣る。

トレードオフ:強いrelationは見つけるのが難しい

チームが弱いrelationを書く理由がある。強いrelationはドメイン知識を必要とする。問題の数学的構造を十分に理解し、非自明なinvariantをエンコードできる必要がある。

平均関数なら、平行移動不変性は統計の背景を持つ人にとって自明だ。粒子シミュレーションなら、同等のrelationはハミルトン力学が位相空間体積を保存することを知る必要があるかもしれない。すべてのチームがその専門知識を持っているわけではない。

もう1つのコストはデバッグだ。強いrelationが失敗したとき、違反は何らかの構造的性質が壊れたことを教えてくれるが、バグはその性質に至る推論の連鎖のどこにでもある可能性がある。弱いrelation「出力長は入力長に等しい」は、正確に1つの方法でしか失敗しない。強いrelation「シフト信号のフーリエ変換は線位相項を獲得する」は100の方法で失敗し、どれが自分のバグかを特定するのにより時間がかかる。

これが核心的な緊張関係だ。弱いrelationは書きやすく、デバッグしやすく、ほとんど役に立たない。強いrelationは書くのが難しく、デバッグしにくく、実際にバグを見つける。タダランチはない。

Metamorphic relationの評価方法

Relationをテストスイートに追加する前に、3つのチェックを通す:

故意のバグテスト。 実装に現実的なバグを導入する。Relationは失敗するか? 失敗しなければ、そのrelationは貢献していない。符号エラー、off-by-one、引数の入れ替え、境界条件の欠落を試す。これらは本番で起きるバグだ。Relationsはそれらを捉えるべきだ。

独立性テスト。 既存のrelationを見る。どれかが同じバグを捉えるだろうか? そうなら、この新しいrelationは冗長だ。冗長性は安全性ではない。限界効果なしのメンテナンス負担だ。

反証可能性テスト。 そのrelationを満たす、もっともらしい壊れた実装を想像できるか? 30秒でスケッチできれば、そのrelationは弱すぎる。良いrelationは、きつい制約のように感じるべきだ。曖昧な示唆のようにではない。

平均関数の場合、コードではこうなる:

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

off-by-oneバグを導入する。len(values)len(values) - 1 に変える。両方のテストを実行する。平行移動不変性は即座に失敗する。スケーリングも即座に失敗する。有界性はおそらくパスするだろう。

これが、スイートでその場所を正当化するrelationと、ただ行数を占めているだけのrelationとの違いだ。

欠陥クラスから始めよ。性質からではない。

ほとんどのチームが犯すミスは、最初に性質をブレインストーミングすることだ。「この関数にはどんなinvariantがあるか?」と尋ねる。それは弱いrelationを生み出す。なぜなら、invariantは述べやすく、違反しにくいからだ。

代わりに、欠陥クラスから始める。「疲れたプログラマーがこの関数でどんなバグを書くだろうか?」次に、それらのバグを捉えるrelationを見つける。

幾何距離関数なら、起こりうるバグは符号エラー、単位の混同、次元の不一致だ。距離が非負であることをチェックするrelationは符号エラーを捉える。座標変換下でのスケーリングをチェックするrelationは単位の混同を捉える。三角不等式をチェックするrelationは次元のナンセンスを捉える。

Relationが捉えるバグの名前が言えないなら、そのrelationは不要だ。

Relationsは希少な資源だ。賢く使え。

Metamorphic testingはカバレッジメトリクスの話ではない。信頼の話だ。壊れたコードでもパスする20の弱いrelationより、本物のバグを捉える1つの強いrelationの方が価値がある。

既存のmetamorphic testsを監査する。バグを導入する。何が失敗するか見る。失敗しないものを削除する。次に、実際に心配している各欠陥クラスに対して1つのrelationを追加する。それが維持に値するテストスイートだ。