🧬

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

に公開1
32
11

はじめに

こんにちは!
株式会社エクスプラザの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の overlay 機能(Go 1.16で導入)のように、実際のファイルを変更せず仮想的に別ファイルで置き換えてビルドする仕組みが使われます。元のソースを汚さずにミュータントをテストでき、ミュータントごとに独立した設定を持てるので並列実行しても競合しません。Mutation Testingツールは、こうした仕組みを内部で使ってミュータントを安全にテストしています。

言語別に使えるツール

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

Go

  • go-mutesting: Go向けのMutation Testingツール(オリジナルはzimmski氏、現在はavito-techのフォークが活発)
  • ooze: go test の仕組みに乗せて実行できるのが特徴

JavaScript / TypeScript

  • Stryker: JS/TSで広く使われているMutation Testingフレームワーク。TypeScriptのほかC#やScalaなどもサポートする。HTMLレポートでミュータントの状態を可視化できる

Python

  • mutmut: Python向けのシンプルなツール。pytestと組み合わせて使える

ツールによって細かい違いはありますが、共通して論点になるのは「変更箇所だけを対象にできるか」「並列実行できるか」「結果をどう可視化するか」です。自分のプロジェクトの言語に合ったツールを選べば、すぐに試せます。

現実的に導入する

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

最大の課題は実行時間

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

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

差分だけを対象にする

そこで有効なのが、変更された箇所だけを対象にするインクリメンタルな実行です。Gitの差分を利用して、プルリクエストで変更されたファイルだけをMutation Testingの対象にします。

「新しく書いたコード、あるいはAIが生成したコードの品質をプルリクエスト単位で検証する」という運用なら、実行時間を現実的な範囲に抑えられます。多くのツールがインクリメンタル実行と閾値設定に対応しているので、「スコアが80%未満ならCIを失敗させる」といった設定ができます。

「使わない判断」も必要

最後に、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%は目指さない。

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

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

参考リンク

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

Discussion

gggggg

記事を拝読しました。内容について、技術的な対応関係と出典の扱いの両面で強い違和感があるため、確認させてください。

まず、Go の Mutation Testing の説明として、go test -overlay を使って元ファイルを汚さずに mutant を検証する話や、Git 差分・インクリメンタル解析・quality gate まわりの話が出てきます。一方で、その後に Go 向けツールとして紹介されているのは go-mutestingooze です。

go-mutesting は、オリジナルが zimmski/go-mutesting で、現在は avito-tech/go-mutesting のフォークが比較的活発だと理解しています。このツール自体は Go 向け Mutation Testing ツールとして紹介するのは自然です。ただし README では、デフォルトの実行手順として「元ファイルを mutation に置き換える」「mutated file の package のテストを実行する」「mutation が killed されたかを報告する」と説明されています。実装上も、少なくとも同梱の exec script では、元ファイルを一時退避し、mutated file を元ファイルパスへコピーして go test を実行し、その後に元へ戻す流れに見えます。これは go test -overlay を使っている説明とは別物だと思います。

ooze についても、Go 向け Mutation Testing ツールとして紹介すること自体は自然です。ただし特徴としては、外部 CLI というより、mutation_test.goooze.Release(t) を書き、go test -v -tags=mutation で Go の testing framework に乗せて実行する設計だと理解しています。README 上も、デフォルトの test command は go test -count=1 ./... で、threshold や parallel 実行はあります。一方で、少なくとも README や公開されている実装からは、Go の -overlay build flag を中心にした設計や、Git 差分ベースのインクリメンタル解析を備えているとは読み取れません。

また、インクリメンタル解析についても、記事中の説明は少し混線しているように見えます。

Mutation Testing における「インクリメンタル実行」には、前回の mutant result をキャッシュして再利用する、変更された source / test file だけを対象にする、Git 差分で対象を絞る、対象 mutant に関係する test だけを実行する、といった複数の意味があります。これらは似ていますが、実装上は別の機能です。

問題は、Go 文脈で説明されている -overlay、Git 差分ベースのインクリメンタル解析、JSON ベースの履歴、quality gate、threshold といった話が、記事中で紹介されている go-mutesting / ooze とは直接対応していないように見える点です。Go でこの説明により直接対応するツールとしては、むしろ gomu のほうが近いはずです。

そのため、この記事全体を見ると、

  • Go の -overlay による安全な mutant 実行
  • Go 向け Mutation Testing ツールとしての go-mutesting / ooze
  • Stryker / mutmut など他言語ツールの incremental / report 機能
  • Go 文脈での Git 差分・インクリメンタル解析・quality gate
  • gomu の特徴に近い説明

が、どのツールのどの機能を指しているのか明確に整理されないまま接続されているように見えます。

加えて、gomu については 商用雑誌で取り上げられていた内容があります。この記事の構成や説明の流れ、特に Go における Mutation Testing の課題、インクリメンタル解析、Git 差分、quality gate、threshold まわりの説明が、その商用雑誌掲載記事および gomu の説明内容にかなり近いと感じています。
一方でこの記事では gomu には言及されていない一方で、gomu の特徴に近い説明が含まれており、しかもその説明と実際に紹介されている Go ツールとの対応関係が曖昧になっています。

盗用していないということであれば、少なくとも以下の点について、根拠となる公式ドキュメントまたは実装箇所を示していただけないでしょうか。

  1. go-mutesting または oozego test -overlay を利用している、またはそれに相当する方式を採っているという根拠
  2. go-mutesting または ooze が Git 差分ベースのインクリメンタル解析に対応しているという根拠
  3. Go 文脈で説明されている quality gate / threshold / JSON ベースの履歴管理が、どのツールのどの機能を指しているのか
  4. gomu に近い説明があるにもかかわらず、gomu や掲載記事への言及・出典がない理由

意図的かどうかに関わらず、この点は看過しにくいため、参照関係の明示、または技術的な根拠の補足お願いしたいです。

現時点で何かを断定する意図はありませんが、本件を出版社・権利者に共有し、参照関係や権利処理の有無について、正式な形で説明を求める可能性があります。まずはその前段として、この記事上で参照の有無、出典を明記していない理由、ならびに技術的な根拠を確認させていただきたいです。

2
ログインするとコメントできます
32
11