課題
昨今、AI がテストコードを自動生成するケースが増えています。開発速度は上がる一方で、「そのテスト自体の品質が十分かどうか」を確認することは難しくなってきました。
ここで言う「テストの品質」とは、Elasticsearch クエリに対するテストが、クエリの変化を正しく検知できるかどうかです。テストが存在していても、それがクエリのセマンティクスを十分に検証できていなければ、クエリの品質は保証されません。
Elasticsearch のクエリはパラメータひとつの変化が検索結果に大きく影響します。Must を Should に変えれば返却ドキュメントの集合が変わり、スコアリング句の変更は並び順を変えます。こうした変化を検知できるテストを書くには、テストデータと検証内容の両方が十分である必要があります。
- テストデータ: クエリ条件を満たさないドキュメントを含めておかないと、クエリを変えても返却結果が変わらず、変化に気づけません。
- アサーション: 「結果が返ってくること」だけでなく、返ってきたドキュメントの内容や順序まで検証しないと、スコアリングの変化を見逃してしまいます。
つまり、「テストはあるが、クエリの変化を検知できていない」という状態が起こりえます。このようなテストの品質不足を定量的に可視化したいと思い、ツールを作ることにしました。
今回作ったツール esmutantとは
esmutantはgo-elasticsearchのTyped APIで書かれたElasitcsearchクエリに対してMutation Testingを行うCLIツールです。
このツールが行うことはシンプルで、
- クエリ構築コードを解析して変異対象の箇所を特定する
-
Must → nil、Filter → Mustのような小さな変異を加えたコードを生成する - 変異後のコードで
go testを実行し、テストが失敗するかを確認する - 結果を Mutation Score としてレポートする
Mutation Score: 9/11 (81.8%)
Score = Killed / (Killed + Survived + Timeouts + Errors)
Skipped mutants are excluded from the score.
Killed: 9 Survived: 2 Timeouts: 0 Errors: 0 | Skipped: 4
KILLED (9):
search.go:41 BuildActiveUsersQuery BoolQuery.Must → nil [RemoveClause]
Detected by:
TestBuildActiveUsersQuery ✗ failed
SURVIVED (2):
search.go:98 BuildArticlesQuery BoolQuery.Must → Should [MustToShould]
Tested by (all passed):
TestBuildArticlesQuery ✓ passed
TestBuildUserByEmailQuery ✓ passed
Test Contribution Summary:
4 test case(s) ran / 11 mutant(s) evaluated
Tests that killed no mutants (may not test query logic):
TestHealthCheck
Killed はテストが変異を検知できた(テストが失敗した)ことを意味し、Survived はテストが変異を見逃した(全テストがパスした)ことを意味します。Survived の行を見ることで「どのクエリのどの部分に対して、アサーションが不足しているか」を具体的に把握できます。
Mutation Testing とは
Mutation Testing は、プログラムに小さな変異(Mutation)を意図的に加え、テストスイートがその変異を検知できるかを測定することでテストの品質を評価する手法です。歴史ある手法で、近年は CI に組み込む事例も増えています。
変異を加えたコード(Mutant)に対してテストを実行し、結果を以下のように分類します。
| 結果 | 意味 |
|---|---|
| Killed | テストが失敗した → 変異を検知できた |
| Survived | テストが全てパスした → 変異を見逃した |
Mutation Score は次の式で計算されます。
Mutation Score = Killed / (Killed + Survived + Timeouts + Errors) × 100
スコアが高いほど、テストがコードの変化に対して敏感であることを意味します。
esmutantの設計思想
go-elasticsearch Typed API に特化する
Elasticsearch クライアントには DSL を文字列や map[string]interface{} で組み立てるアプローチと、go-elasticsearch v8 の Typed API のように型安全に組み立てるアプローチがあります。
esmutant は後者の Typed API に特化しています。型情報が明示的なため、go/packages で型情報付きのパッケージ解析を行い、github.com/elastic/go-elasticsearch/v8/typedapi/types パッケージの構造体フィールドであることを型レベルで確認した上で変異対象を特定できます。これにより、同名フィールドを持つ他の構造体を誤って変異対象にしてしまうことを防いでいます。
元ファイルを変更しない
go test -overlay を使うことで、元のソースファイルを一切変更せずに変異後のコードでテストを実行できます。-overlay は {Replace: {"元ファイルのパス": "変異後ファイルのパス"}} という JSON を渡すことでファイルを仮想的に差し替える仕組みです。
go test -json -overlay /tmp/overlay.json -count=1 ./...
実際の Elasticsearch に対してテストを実行する
モックやスタブを使わず、実際の Elasticsearch インスタンスに対してテストを実行します。クエリの変異がドキュメントの返却結果に影響するかどうかは、実際に Elasticsearch を動かしてみなければわからないからです。
技術スタック
| 技術 | 用途 |
|---|---|
go/ast go/parser go/token
|
ソースコードの AST 解析・フィールドの書き換え |
go/packages |
型情報付きパッケージ読み込み(ES 型の識別) |
go test -overlay |
元ファイルを変更せずに変異後コードでテスト実行 |
go test -json |
テスト結果を構造化パース(どのテスト関数が検知したかを追跡) |
サポートしている Mutation Operators
現在 9 種類のオペレーターを実装しています。
BoolQuery 系
| オペレーター | 変異内容 |
|---|---|
RemoveClause |
Must / Should → nil
|
MustToShould |
Must → Should(必須条件をオプション条件に) |
ShouldToFilter |
Should → Filter(スコアリング句を非スコアリングに) |
FilterToMust |
Filter → Must(非スコアリングをスコアリング必須に) |
RemoveMustNot |
MustNot → nil(除外条件を削除) |
Range クエリ系
| オペレーター | 変異内容 |
|---|---|
RangeBoundary |
Gte → Gt、Lte → Lt(包含境界を排他境界に) |
RangeDirection |
Gte ↔ Lte、Gt ↔ Lt(上下限を入れ替え) |
その他
| オペレーター | 変異内容 |
|---|---|
RemoveFunctionScoreFilter |
FunctionScore.Filter → nil(ブーストスコープを全ドキュメントに拡大) |
MultiMatchType |
Bestfields → Phrase / Mostfields
|
使い方
インストール
go install github.com/kurakura967/go-elasticsearch-mutant/cmd/esmutant@latest
基本的な実行
esmutant run ./...
テストコードが別パッケージにある場合は --test フラグで指定します。
esmutant run ./internal/repository/... \
--test ./testing/integration/... \
--workers 1
主なフラグ
| フラグ | デフォルト | 説明 |
|---|---|---|
--test |
(対象パターンと同じ) | テスト実行パッケージを別指定 |
--workers |
1 |
並列ワーカー数 |
--threshold |
0(無効) |
Mutation Score の最低ライン(超えなければ exit 1) |
--verbose |
false |
Survived / Error の go test 出力を表示 |
--workers の注意点
統合テストで共有の Elasticsearch インデックスを使っている場合、--workers を 2 以上にすると複数ワーカーが同一インデックスを同時に操作して競合が発生し、偽陽性(本来 Survived のはずが Killed と判定される)が生じます。
共有インデックスを使う統合テストでは --workers 1 を推奨します。
今後の展望
-
等価変異(Equivalent Mutation)への対応:
MatchAllに対するFilter → Mustのように、どんなテストを書いても原理的に検知できない変異を「等価変異の可能性あり」として注記する機能 -
CI への組み込みガイド:
--thresholdを使った品質ゲートの設定例 - オペレーターの拡充: プロダクトでの使用経験をもとに、検知価値の高い変異パターンを追加していく