🧬

そのテスト、本当にバグを検出できますか?——Mutation Testingでテストの質を測る

に公開
63
32

はじめに

こんにちは!
株式会社エクスプラザのhyodoです!

最近はコードだけでなく、テストもAIに書かせることが増えてきました。「この関数のテストを書いて」とお願いすれば、それっぽいテストが一瞬で出てきます。

でも、ここで一度立ち止まって考えたいことがあります。

そのテスト、本当に「良いテスト」ですか?

テストが通っていて、カバレッジも十分。一見すると問題なさそうです。でも「テストが通る」ことと「テストがバグを検出できる」ことは、実は別物です。そして、AIが書いたテストはこのギャップにハマりやすいです。

この記事では、テスト自体の品質を測る手法であるMutation Testing(ミューテーションテスト)を紹介します。「テストをテストする」という考え方です。

動作環境

この記事のGoのコードはGo 1.26で動作確認しています。

テストが「ちゃんとしている」かを手で確かめる

AIが書いたテストを信頼できるか、確かめる素朴な方法があります。わざとコードを壊してみることです。

例として、商品価格に割引率を適用する関数を考えます。

discount.go
package discount

import "errors"

// ApplyDiscount は価格に割引率(0〜100)を適用した金額を返す
func ApplyDiscount(price int, rate int) (int, error) {
    if rate < 0 || rate > 100 {
        return 0, errors.New("rate must be between 0 and 100")
    }
    discounted := price * (100 - rate) / 100
    return discounted, nil
}

これに対して、AIが次のようなテストを書いてきたとします。

discount_test.go
package discount

import "testing"

func TestApplyDiscount(t *testing.T) {
    result, _ := ApplyDiscount(1000, 20)
    _ = result
}

テストは通ります。カバレッジも、正常系の行を通過するので高い数値が出ます。一見ちゃんとしていそうです。

ここで、プロダクトコードをわざと壊してみます。割引の計算式の -+ に書き換えてみましょう。

// discounted := price * (100 - rate) / 100
discounted := price * (100 + rate) / 100  // わざとバグを入れた

これは明らかにバグです。割引のはずが割増になっています。ところが、先ほどのテストを実行しても通ってしまいます。テストが戻り値を検証していないので、計算がおかしくなっても気づけないのです。

つまりこのテストは、コードを壊しても何も言ってこない。「テストがある」ように見えて、実際にはバグを検出する力を持っていません。

逆に、こういうテストならどうでしょう。

discount_test.go
func TestApplyDiscount(t *testing.T) {
    result, err := ApplyDiscount(1000, 20)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result != 800 {
        t.Errorf("got %d, want 800", result)
    }
}

このテストなら、100 - rate100 + rate に壊した瞬間に計算結果が 800 でなくなるので、テストが失敗します。コードの異常をちゃんと検出できる、良いテストです。

「コードをわざと壊して、テストが気づくかどうかを見る」——この手作業が、テストの質を確かめる本質的な方法です。そして、これを機械的に大規模にやるのがMutation Testingです。

その手作業を仕組みにしたのがMutation Testing

Mutation Testingは、プロダクトコードを機械的に少しだけ書き換えて(バグを埋め込んで)、テストがそれに気づくかどうかを試す手法です。

先ほど手でやった「-+ に書き換える」を、ツールが自動でたくさんのパターンに対して行います。書き換えたコードのことを「ミュータント(mutant)」と呼びます。

バグを意図的に注入したわけですから、まともなテストなら失敗するはずです。

  • テストが失敗した → ミュータントをKILLED(バグを検出できた = 良いテスト)
  • テストが成功した → ミュータントがSURVIVED(バグを見逃した = テストに穴がある)

先ほどの「戻り値を検証しないテスト」は、+ に書き換えたミュータントをSURVIVEDさせてしまいます。「戻り値を検証するテスト」はKILLEDできます。SURVIVEDが多いほど、テストに穴が多いことを意味します。

ミューテーションスコア

どれだけのミュータントをKILLEDできたかは、ミューテーションスコアという指標で表されます。

ミューテーションスコア=KILLEDしたミュータント数生成した全ミュータント数×100 \text{ミューテーションスコア} = \frac{\text{KILLEDしたミュータント数}}{\text{生成した全ミュータント数}} \times 100

このスコアが高いほど、「テストがバグをよく検出できる」ことを意味します。手作業で1個ずつコードを壊して確かめる代わりに、ツールが大量のミュータントで試して、スコアという形でテストの実力を数値化してくれるわけです。

カバレッジだけでは足りない理由

ここで「カバレッジがあるじゃないか」と思うかもしれません。なぜカバレッジだけでは不十分なのか、整理します。

カバレッジは「実行した」しか見ていない

テストカバレッジは「テストコードがプロダクトコードのどの行を実行したか」を示す指標です。Goなら go test -cover で計測できます。

ここが重要なのですが、カバレッジは実行されたかどうかを見ているだけで、正しく検証されたかどうかは見ていません。

最初の「戻り値を検証しないテスト」を思い出してください。あのテストは ApplyDiscount の正常系の行をすべて通るので、カバレッジは100% になります。でもバグは検出できませんでした。カバレッジ100%とテストの質は、イコールではないのです。

AIは指示された指標を最大化する

この問題は、AIにテストを書かせるとさらに目立ちます。

AIは「テストを通すこと」「カバレッジを上げること」を求められると、その指標を満たす方向に最適化します。「カバレッジを100%にして」と頼むと、とにかく全行を通すことを優先して、検証の薄いテストを生成しがちです。

実際によく見かけるのが、t.Skip を使って実質的にテストをスキップしているケースです。

notification_test.go
func TestSendNotification(t *testing.T) {
    t.Skip("外部APIのモックが未準備のため一旦スキップ")
    err := SendNotification("user@example.com", "hello")
    if err != nil {
        t.Errorf("unexpected error: %v", err)
    }
}

t.Skip が呼ばれた時点でテスト関数の残りは実行されません。テストが存在するように見えて、実際には何も検証していない状態です。こういうコードはカバレッジ上は問題なく見えるので、レビューで見逃されるとそのまま残ります。

カバレッジが「量」の指標だとすれば、ミューテーションスコアは「質」の指標です。AIにテストを量産させる時代だからこそ、量だけでなく質を測る軸が必要になります。

どうやってコードを壊すのか

Mutation Testingの仕組みを、Goを例に少し掘り下げます。「コードを書き換える」と言っても、文字列置換でやると壊れやすいので、構文を理解した上で書き換える必要があります。

ASTで構文を理解する

ソースコードをプログラムが解析・操作しやすい木構造で表現したものを AST(Abstract Syntax Tree、抽象構文木) と呼びます。

たとえば a + b というコードをASTに変換すると、「+ という演算を、左辺 a と右辺 b に対して行う」という構造に分解されます。この +ab といった各要素をノードと呼びます。

Goには標準ライブラリにASTを扱うパッケージが揃っています(go/parsergo/astgo/token)。これらを使うと、ソースコードをASTに変換し、書き換え対象のノードを見つけてミュータントを生成できます。

import (
    "go/ast"
    "go/parser"
    "go/token"
)

fset := token.NewFileSet()
node, _ := parser.ParseFile(fset, "discount.go", nil, parser.AllErrors)

// ASTを走査して二項演算のノードを探す
ast.Inspect(node, func(n ast.Node) bool {
    if expr, ok := n.(*ast.BinaryExpr); ok {
        // expr.Op が token.SUB (-) なら token.ADD (+) に書き換える、など
    }
    return true
})

書き換えのパターンは、演算子の置き換えが基本です。+-* に、==!= に、&&|| に、<<= に、といった具合です。たとえば if rate < 0if rate <= 0 に書き換えたとき、境界値(rate == 0)をテストしていなければミュータントがSURVIVEDするので、「境界値テストが足りない」という気づきになります。

元ファイルをどう扱うか

ミュータントを作るとき、元のソースファイルをどう扱うかはツールによって方式が分かれます。

たとえば後述するgo-mutestingは、元ファイルを一時的に退避してからミュータントで置き換え、テストを実行し、終わったら元に戻すという方式です。ツールが正常に動けば元のコードは保たれますが、実ファイルを一時的に書き換える以上、実行中に強制終了するとファイルが壊れた状態で残るリスクはあります。Mutation Testingツールを使うときは、コミット済みのクリーンな状態で実行するのが安全です。

なお、Goには実ファイルを変更せず仮想的に別ファイルへ差し替えてビルドできるoverlay機能(Go 1.16で導入)もあり、こうした仕組みを活用する設計のツールも作れます。ただし、どの方式を採るかはツール次第なので、使うツールがファイルをどう扱うかはドキュメントで確認しておくと安心です。

言語別に使えるツール

ここまで仕組みを説明してきましたが、Mutation Testingを自分でゼロから実装する必要はありません。主要な言語にはツールが揃っています。

Go

  • go-mutesting: Go向けのMutation Testingツール(オリジナルはzimmski氏、現在はavito-techのフォークが活発)。対象のファイルやパッケージを引数で指定して実行する
  • ooze: mutation_test.go にテストとして記述し、go test -tags=mutation で実行する設計。スコアの最低閾値(WithMinimumThreshold)や並列実行のオプションがあり、閾値を下回ると非ゼロの終了コードを返すのでCIに組み込みやすい
  • gomu: CIへの組み込みを強く意識した設計のGo向けツール。ミューテーションスコアの閾値設定(--threshold)、Gitの差分に基づくインクリメンタル解析(--incremental)、プルリクエストへの結果の自動コメント、JSON/HTML形式のレポート出力を備える。.gomuignore ファイルで対象外のファイルやディレクトリも指定できる

JavaScript / TypeScript

  • Stryker: JS/TSで広く使われているMutation Testingフレームワーク。HTMLレポートでミュータントの状態を可視化できるほか、incrementalモード(前回の実行結果をファイルに保存し、変更のあった箇所だけ再実行する)を備えている

Python

  • mutmut: Python向けのシンプルなツール。pytestと組み合わせて使え、実行結果をキャッシュして再実行を効率化できる

ツールごとに、実行方式(CLIか、テストフレームワークに乗せるか)、結果の可視化、再実行の効率化の仕組みが異なります。自分のプロジェクトの言語に合ったツールのドキュメントを確認して選んでください。

現実的に導入する

Mutation Testingは強力ですが、何も考えずに導入すると運用がつらくなります。現実的に使うための勘所を整理します。

最大の課題は実行時間

Mutation Testingの一番の課題は実行時間です。ミュータントごとにテストスイート全体を実行するため、ミュータント数 × テスト実行時間がそのまま合計時間になります。

100個のミュータントが生成されて、テストスイートの実行に10秒かかるなら、単純計算で1000秒。大規模なプロジェクトだと数時間かかることもあります。これを毎回のCIで全体に対して回すのは非現実的です。

対象を絞って現実的な時間にする

そこで重要になるのが、実行範囲を絞ることです。やり方はツールによって異なります。

対象の指定で絞る。 go-mutestingは引数で対象のファイルやパッケージを指定できるので、たとえば「今回のプルリクエストで変更したパッケージだけを対象にする」という運用ができます。oozeにも特定のソースファイルを除外する設定(IgnoreSourceFiles)があります。CIの前段でGitの差分からパッケージ一覧を作り、それをツールに渡すスクリプトを書けば、変更分だけの検証を組めます。

差分検出を組み込みで持つツールを使う。 gomuは--incrementalオプションでGitの差分に基づくインクリメンタル解析を備えていて、変更のあったファイルだけを自動で対象にできます。自前のスクリプトを書かずに差分実行したい場合は、こうしたツールを選ぶのが手軽です。

前回結果の再利用で絞る。 Strykerにはincrementalモードがあり、実行結果をファイルに保存しておいて、次回はコードやテストに変更のあった箇所だけを再実行します。mutmutも結果をキャッシュして再実行を効率化します。こうした機能を持つツールなら、2回目以降の実行が大幅に速くなります。

スコアの閾値でCIを制御する

CIに組み込むなら、ミューテーションスコアの閾値で合否を判定する形が分かりやすいです。たとえばoozeはWithMinimumThresholdで最低スコアを設定でき、下回ると非ゼロの終了コードを返すので、そのままCIの失敗にできます。gomuも--thresholdでスコアの下限を指定でき、プルリクエストに結果を自動コメントする機能があるので、レビューの流れに組み込みやすいです。StrykerにもスコアのしきいでCIを失敗させる設定があります。

「新しく書いたコード、あるいはAIが生成したコードの品質をプルリクエスト単位で検証する」という運用を、こうしたツールの機能と対象の絞り込みを組み合わせて実現していくのが現実的です。

「使わない判断」も必要

最後に、Mutation Testingと付き合ううえで大事な割り切りを2つ。

ミューテーションスコア100%を目指さない。 書き換えても意味が変わらないミュータント(等価ミュータント)が存在します。たとえば x * 1x / 1 に書き換えても結果は同じ x なので、このミュータントはどうやってもKILLEDできません。スコアが100%にならないのは正常なので、無理に追わないことです。

すべてのコードに適用しない。 Mutation Testingは実行コストが高いので、全コードに一律でかける必要はありません。バグが致命的になる箇所(決済、認証、計算ロジックなど)に絞って適用するのが効果的です。テストの薄いところ、壊れたら困るところを優先する、という判断が現実的です。

スコアを上げること自体が目的になると本末転倒です。「そのミュータントは本当に検証すべきものか」を考えながら使うのが、Mutation Testingとの良い付き合い方だと思います。

まとめ

AIにテストを書かせる時代だからこそ、テスト自体の品質を測る仕組みが重要になります。

テストの質は「壊して確かめる」。 コードをわざと壊してテストが気づくか見る。これがテストの実力を測る本質的な方法で、それを機械化したのがMutation Testing。

KILLED / SURVIVEDで測る。 コードにバグを埋め込み、テストが検出できれば(失敗すれば)KILLED、見逃せば(成功すれば)SURVIVED。ミューテーションスコアでテストの実力を数値化できる。

カバレッジは量、Mutation Testingは質。 カバレッジは実行したかを測るだけ。AIは指示された指標を最大化するので、カバレッジ100%でも検証の薄いテストが生まれる。だから別の軸が要る。

仕組みはAST操作。 構文を理解してミュータントを生成する。元ファイルの扱い(一時退避方式など)はツールごとに異なる。Go、JS/TS、Pythonそれぞれにツールがあるので自作は不要。

導入は絞り込み+割り切り。 全体に毎回実行すると重いので、対象の指定・前回結果の再利用・重要箇所への限定で絞る。等価ミュータントがあるので100%は目指さない。

「テストが通った」だけで安心せず、「そのテストはバグを検出できるのか」を一度疑ってみると、テストの質が一段上がります。

最後までお読みいただきありがとうございました!

参考リンク

63
32
株式会社エクスプラザ

Discussion

Hidden comment
ログインするとコメントできます
63
32