Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Gremlins はどのようにコードを変異させているのか 👾

Gremlins はどのようにコードを変異させているのか 👾

社内勉強会

Avatar for Radish

Radish

July 22, 2026

More Decks by Radish

Other Decks in Programming

Transcript

  1. ミューテーションテストとは 本番コードに小さな変更(変異体 / mutant)を機械的に埋め込み、その状態でテストを走らせる手法。 ⚰️ killed(殺された) 👾 lived / survived(生き残った)

    カバレッジと何が違う? スコア(Test efficacy) 変異体を入れたらテストが落ちた → そのバグは テストで検出できている ✅ カバレッジ:その行をテストが通ったかしか分 からない ミューテーションテスト:その行のバグを検出 できるかまで測れる 変異体を入れてもテストが通ってしまう → 検出で きない穴がある(偽陰性)🔴 killed ÷(killed + lived) 変異体をどれだけ殺せたか=テストスイートの検出力
  2. Gremlins とは Go 向けのミューテーションテスト CLI 対象パッケージを解析し、変異体を自動で作って テストを回す Java の定番ツール PITest

    に触発されて開発 想定は小さめのモジュール(マイクロサービスな ど)。大きいと実行に時間がかかる gremlins unleash Mutation testing completed in 1m30s Killed: 10, Lived: 2, Not covered: 3 Timed out: 0, Not viable: 1, Skipped: 0 Test efficacy: 83.33% Mutator coverage: 80.00% 実行結果の例(この形式で集計とスコアが出る) デフォルトで有効な変異体 👾 変異体 変異の例 CONDITIONALS_BOUNDARY >= → > CONDITIONALS_NEGATION == → != ARITHMETIC_BASE + → INCREMENT_DECREMENT ++ → -INVERT_NEGATIVES -a → +a 変異体は全11種。残りはフラグで有効化する
  3. 全体パイプライン( gremlins unleash の中身) 準備は1回だけ。そのあと mutant を1個ずつ並列ループで処理する。 準備フェーズ(1回だけ) 1 作業ディレクトリを用意

    2 カバレッジを取得 3 ソースをAST にパース 4 変異ポイントを列挙 実行フェーズ(変異体ごと・並列ループ) 5 worker の作業コピーを確保(初回のみ) 6 本番コードを差し替える 7 go test で killed / lived 判定 8 元に戻す(Rollback) 実装: cmd/unleash.go run() → (*Engine).Run() 変異ポイントを全部見つけてから処理するのではなく、見つけたそばから worker に渡して処理する (=列挙 3–4 と実行 5–8 が同時進行)
  4. ① 作業ディレクトリを用意する Gremlins は本番のソースツリーを直接は書き換えない。 一時ディレクトリを作成し、その中だけで変異させる。 一時ディレクトリを1つ作る 起動時に gremlins-* を os.TempDir()

    に 1個作成 変異・テストはすべてこの中で完結 実行後は丸ごと削除( defer で後始末) workDir, _ := os.MkdirTemp(os.TempDir(), "gremlins-" defer cleanUp(workDir) 実装: cmd/unleash.go ( MkdirTemp / cleanUp ) なぜコピーで作業するのか 失敗・中断しても本番ソースは無傷 この領域の中に、後でworker ごとのコピーを 作る(→ ⑤)
  5. ② カバレッジを取得する 変異させる前に、素の状態で go test を1回だけ走らせて情報を集める。 ここで測る2つのこと どの行がテストで実行されるか (カバレッジ) テストの実行にかかる実時間(Elapsed)

    go test -cover -coverprofile=<f> ./... 後段でどう使うか カバーされた行の mutantだけをテスト対象に する → 通らない行は最初から NOT COVERED でス キップ 実時間 × 係数(既定 5)をテストのタイムア ウトに 実装: internal/coverage/coverage.go ( Run() が Profile と Elapsed を持つ Result を返す)
  6. ③ ソースを AST にする ファイルを1ファイルずつパース ( _test.go は除外) 使うのは Go

    標準の go/parser / go/ast / go/token だけ .go set := token.NewFileSet() file, _ := parser.ParseFile( set, fileName, src, parser.ParseComments) 型チェックをしない go/types も packages.Load も使っていな い 意味解析なしの純粋なトークン操作 だからシンプルで速い。反面、型を見た賢い 変異はしない ast.Inspect(file, func(n ast.Node) bool { // 変異できるノードを探す return true }) AST はファイルごとに1回だけ生成し、実行フェーズでは同じ木を全 worker が共有する(コピーしない) 実装: internal/engine/engine.go ( runOnFile )
  7. ④ 変異ポイントを見つける 対象は演算子・代入など5種のノードだけ。共通点は 「トークンを1個持つ」こと。 case *ast.BinaryExpr: tok = &n.Op pos

    = n.OpPos AssignStmt UnaryExpr // a >= b の >= // ← トークンへのポインタ / BinaryExpr / BranchStmt / IncDecStmt / ポインタで保持しているので、後から書き換えられる func (n *NodeToken) SetTok(t token.Token) { *n.tok = t } 「元トークン → 変異後」の対応表 変異体 例 ConditionalsBoundary >= → > , < → <= ConditionalsNegation == → != , > → <= ArithmeticBase +→-, *→/ InvertLogical && → || InvertLoopCtrl break → continue 実装: internal/engine/mappings.go の TokenMutantType / tokenMutations
  8. ⑤ worker ごとに作業コピーを確保する ループの各回は、まず自分用の作業コピーを受け取る。worker が初めて担当したときに1度だけ作られる。 worker ごとに丸ごとコピー worker 数 =

    並列度(既定 CPU 数) ルート gremlins-* の中に worker ごとの wd-* を作り、対象モジュール( go.mod の あるルート)を丸ごとコピー 初回だけ生成してキャッシュ、以降は使い回 す → 物理的に別の場所を書くので並列でも衝突 しない 同一ファイルには mutex コピーで守れるのはディスクだけ。共有 AST はファイル名ごとの mutexで守る(→ ⑥) 別ファイルなら AST も別の木 → 並列のまま fileLock(m.Position().Filename).Lock() 実装: internal/engine/workdir/workdir.go , internal/engine/workerpool/workerpool.go
  9. ⑥ 本番コードの差し替え やりたいのは「トークン1個の変更」。でも実際に触る対象は2つある。 func (m *TokenMutator) Apply() error { //

    ①ソースファイル単位でロック(worker 横断) fileLock(m.Position().Filename).Lock() defer fileLock(...).Unlock() // 解放は関数を抜けるとき // 書き込み先は worker コピー配下のファイル filename := filepath.Join( m.workDir, m.Position().Filename) // ②ディスクのファイルを退避 m.origFile, _ = os.ReadFile(filename) // ③元トークンを記憶 m.actualToken = m.tokenNode.Tok() // ④共有ASTを変異 m.tokenNode.SetTok(...) // ⑤printerでファイル全書き m.writeMutatedFile(filename) // ⑥共有ASTは即戻す(=ロック解放より前) m.tokenNode.SetTok(m.actualToken) } 実装: internal/engine/tokenmutator.go 触る対象は2つ AST:メモリ上・ファイルごとに1本を worker 横断で共有 ファイル:worker ごとのコピー (ディスク/ m.workDir 配下) 部分置換じゃない AST 全体を go/printer でソースコードに再レ ンダリングして上書き。 文字列 >= を探して置換ではない。
  10. ⑥ なぜ「AST は即戻す・ファイルは後で戻す」? 直前で触った2つ(メモリの共有 AST/ディスクのファイル)は、戻すタイミングが逆。 AST(メモリ上・全 worker で共有) 木はファイルごとに1本。同じファイルの mutant

    が worker をまたいで共有 変異→書き出し→復元を同じロック区間で完 結 他の mutant と干渉させない=原子性 → だから Apply を抜けた時点で AST は元通 り ファイル(worker コピー・ディスク) go test が読むのはディスク上のこのファ イル だからテスト中は変異したままにする テスト後に Rollback() で復元(=ステップ ⑧) func (m *TokenMutator) Rollback() error { return os.WriteFile( filename, m.origFile, 0o600) // 退避を書戻し } 実装: internal/engine/tokenmutator.go ( Rollback )/呼び出し順は internal/engine/executor.go
  11. ⑦ テストを回して判定する 実行するコマンド go test -timeout <t> -failfast <pkg> 対象は変異したパッケージだけ(全体は回さない

    → 速い) タイムアウト = カバレッジ取得の実時間 × 係数 (既定5) カバーされていない行の mutant はテストせずス キップ 実装: internal/engine/executor.go 終了コードで判定 結果 exit 1(テスト失敗) 成功(変異が素通り) タイムアウト exit 2(ビルド不能) 判定 KILLED ✅ LIVED TIMED OUT NOT VIABLE
  12. まとめ 差し替えの仕組み AST(トークン)ベース。型チェックはしな い 対象トークンを1個変えて go/printer でフ ァイル丸ごと再生成 AST は即戻し/ファイルはバックアップから

    安全と速度の工夫 カバレッジでテスト対象の行だけ変異 変異パッケージだけを go test -failfast worker ごとに作業コピー+ファイル mutex で 並列 Rollback 👾ご清聴ありがとうございました!👾
  13. 想定 Q&A | Q1 Q. AST は worker ごとにコピーされる? A.

    いいえ。メモリ上の1本を全 worker で共有 AST はファイルごとにメモリ上に1本だけ。全 worker が共有する Gremlins は1プロセスで動き、worker は goroutine → 同じ AST(同じポインタ)を触る worker ごとにコピーされるのはディスク上のファイルの方
  14. 想定 Q&A | Q2 Q. なぜ AST を共有するの? 速いから? A.

    速さのためではなく、設計の帰結 変異点を AST ノードのポインタで持つ設計 → パースした木をそのまま使い回す=共有になる パースは go test に比べ一瞬。コピーして避けるほどでもない 共有の代償としてファイル単位ロック+即戻しを払っている
  15. 想定 Q&A | Q3 Q. AST があるのに、なぜファイルを丸ごとコピーする? A. go test

    はディスクの実体で動くから はディスク上のソースをコンパイル&実行する テスト( _test.go )・ go.mod ・依存パッケージなどプロジェクト全体が必要 AST は「変異する1ファイルの中身」を作るだけ → だからツリーごとコピー go test
  16. 想定 Q&A | Q4 Q. どのテストが mutant を殺したかは分かる? A. 分からない。

    go test の終了コードしか見ない設計 判定は終了コードだけ(0 = LIVED / 1 = KILLED / 2 = NOT VIABLE)。テストの出力は読み捨て -failfast で最初の失敗で打ち切る → 2件目以降の結果はそもそも生成されない PIT(Java)や Stryker(JS)は killing test をレポートに出せる。gremlins はその観測を捨てて速度を 取る割り切り
  17. 想定 Q&A | Q5 Q. -failfast で打ち切ったら、他の mutant の結果が見れなくならない? A.

    ならない。効くのは mutant 1個のテスト実行の内側だけ レポートの1行 = mutant 1個 = 独立した go test 1回。打ち切られても次の mutant はまっさらな状態 から丸ごと走る KILLED の行ほど早く打ち切られ、LIVED の行だけがスイートを完走している NOT COVERED はさらに手前で、 go test 自体を走らせていない(カバレッジで判断)