TL;DR
- Gremlins は元のコードを書き換えず、モジュールを一時ディレクトリにコピーして、そちらを書き換える。
- 置き換えは文字列置換ではなく AST で行い、書き換えた AST をコードに出力し直してからテストを実行する。
背景
Gremlins は Go のミューテーションテストのツールである。ミューテーションテストを行うなら、どこかでコードを置き換えているはずなので、その箇所を探した。
ミューテーションテストを実行するコマンドは gremlins unleash である(unleash は「解き放つ」という意味で、しゃれたコマンド名である)。このコマンドの実体は cmd/unleash.go の runUnleash 関数なので、ここから追っていく。以下のコードでは、エラー処理を意図的に省略している。
検証
1. 一時ディレクトリの作成
runUnleash 関数では、Go モジュールを読み込み、gremlins- で始まる一時ディレクトリを作る。このディレクトリは終了時に削除される。
mod, err := gomodule.Init(path) // Go モジュールでなければエラー
workDir, err := os.MkdirTemp(os.TempDir(), "gremlins-") // ルート作業ディレクトリ
defer cleanUp(workDir) // 終了時に RemoveAllこの関数から呼ばれる run 関数で、以降の処理に必要なコンポーネントを組み立てている。
2. テストカバレッジの取得
run 関数でテストカバレッジを取得する。
c := coverage.New(workDir, mod)
cProfile, err := c.Run()Coverage.Run メソッドは、go test -cover を実行して所要時間を計り、プロファイルをパースする。
_ = os.Chdir(c.mod.Root)
if err := c.downloadModules(); err != nil { ... } // go mod download を先に実行
elapsed, err := c.executeCoverage() // go test -cover。所要時間を計測
profile, err := c.profile() // プロファイルをパース
return Result{Profile: profile, Elapsed: elapsed}, nilexecuteCoverage の前に go mod download を実行している。コメントによると、計った実行時間を変異体のテストのタイムアウトに使うので、ダウンロードの時間が入らないように別の手順に分けている。
Before executing the coverage, it downloads the go modules in a separate step.
This is done to avoid that the download phase impacts the execution time which
is later used as timeout for the mutant testing execution.
プロファイルのパースには、golang.org/x/tools/cover の関数を使っている。
3. タイムアウトの決定
run 関数で、2 で測った実行時間からタイムアウトを計算する。
jDealer := engine.NewExecutorDealer(mod, wdDealer, cProfile.Elapsed)4. 変異させる箇所の特定
run 関数で、変異体を作るエンジンを動かす。
mut := engine.New(mod, codeData, jDealer)
results := mut.Run(ctx)Engine.Run メソッドは、ディレクトリをたどり、テストでも除外対象でもない Go のファイルごとに Engine.runOnFile メソッドを呼ぶ。変異体はチャネルに流され、同じメソッドから呼ばれる executeTests で消費される。
mu.mutantStream = make(chan mutator.Mutator)
go func() {
defer close(mu.mutantStream)
_ = fs.WalkDir(mu.fs, ".", func(path string, _ fs.DirEntry, _ error) error {
isGoCode := filepath.Ext(path) == ".go" && !strings.HasSuffix(path, "_test.go")
if isGoCode && !mu.codeData.Exclusion.IsFileExcluded(path) {
mu.runOnFile(path)
}
return nil
})
}()
res := mu.executeTests(ctx)Engine.runOnFile メソッドは、ファイルをパースし、ast.Inspect 関数で AST をたどる。NewTokenNode 関数で、変異の対象になるノード(AssignStmt、BinaryExpr、BranchStmt、IncDecStmt、UnaryExpr の演算子や代入)だけを取り出す。
set := token.NewFileSet()
file, _ := parser.ParseFile(set, fileName, src, parser.ParseComments)
ast.Inspect(file, func(node ast.Node) bool {
n, ok := NewTokenNode(node)
if !ok {
return true
}
mu.findMutations(fileName, set, file, n)
return true
})Engine.findMutations 関数は、トークンに適用できる変異の種類を引き、変異体を作ってチャネルに送る。
mutantTypes, ok := TokenMutantType[node.Tok()] // このトークンに適用できる変異タイプの一覧
...
tm := NewTokenMutant(pkg, set, file, node) // set と file を共有したまま変異体を作る
tm.SetType(mutantType)
tm.SetStatus(mu.mutationStatus(set.Position(node.TokPos)))
mu.mutantStream <- tm5. 変異体の実行
変異体は、executeTests 関数の中で呼ばれる Start メソッドで実行される。カバーされていない変異体やスキップする変異体、dry run のときは、テストを実行せずに結果だけを流す。
rootDir, err := m.wdDealer.Get(workerName) // ⑤-1 隔離。ワーカー用コピーを取得(初回のみ実コピー)
workingDir := filepath.Join(rootDir, m.module.CallingDir)
m.mutant.SetWorkdir(workingDir)
if m.mutant.Status() == mutator.NotCovered ||
m.mutant.Status() == mutator.Skipped ||
m.dryRun {
m.outCh <- m.mutant // 実行せず結果だけ流す
return
}
if err := m.mutant.Apply(); err != nil { ... } // ⑤-2 差し替え
m.mutant.SetStatus(m.runTests(rootDir, m.mutant.Pkg())) // ⑤-3 テスト実行と判定
if err := m.mutant.Rollback(); err != nil { ... } // ⑤-4 復元
m.outCh <- m.mutant5-1. コードのコピー
run 関数で、コピーをキャッシュする構造体を NewCachedDealer で初期化する。
wdDealer := workdir.NewCachedDealer(workDir, mod.Root)
defer wdDealer.Clean()実際のコピーは、Start メソッドから呼ばれる CachedDealer.Get メソッドが、ワーカーごとに最初の 1 回だけ行う。たとえばワーカーが 8 つあっても、変異体が 3 つしかなければ、コピーは 3 回しか起きない。
dstDir, ok := cd.fromCache(idf)
if ok {
return dstDir, nil // 同じ idf なら同じディレクトリ
}
dstDir, err := os.MkdirTemp(cd.workDir, "wd-*")
err = filepath.Walk(cd.srcDir, cd.copyTo(dstDir)) // モジュール全体をファイルコピー
cd.setCache(idf, dstDir)5-2. 差し替え
TokenMutator.Apply メソッドは、元のファイルを退避してから、共有している AST のトークンを書き換え、printer.Fprint でファイル全体を出力して上書きする。書き出したら、AST はすぐに元に戻す。
func (m *TokenMutator) Apply() error {
fileLock(m.Position().Filename).Lock()
defer fileLock(m.Position().Filename).Unlock()
filename := filepath.Join(m.workDir, m.Position().Filename)
// (1) 元のバイト列を退避
m.origFile, err = os.ReadFile(filename)
m.origSnippet = extractSnippet(m.origFile, m.Position().Line, 3)
// (2) 現在のトークンを保存
m.actualToken = m.tokenNode.Tok()
// (3) 共有 AST を書き換え
m.tokenNode.SetTok(tokenMutations[m.Type()][m.tokenNode.Tok()])
// (4) printer.Fprint で全体を再出力して上書き
if err = m.writeMutatedFile(filename); err != nil {
return err
}
// (5) AST を即座に元へ戻す
m.tokenNode.SetTok(m.actualToken)
return nil
}5-3. テストの実行と判定
runTests メソッドは、コピー側のディレクトリで go test を実行する。タイムアウトの testExecutionTime は 3 で計算した値である。タイムアウトすれば TimedOut、テストが失敗すれば終了コードに応じた状態、成功すれば変異体が生き残ったとして Lived になる。
ctx, cancel := context.WithTimeout(context.Background(), m.testExecutionTime)
cmd := m.execContext(ctx, "go", m.getTestArgs(pkg)...)
cmd.Dir = m.mutant.Workdir() // コピー側で実行
...
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return mutator.TimedOut
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return getTestFailedStatus(exitErr.ExitCode())
}
return mutator.Lived5-4. 復元
Rollback メソッドで、退避しておいた元のバイト列をファイルに書き戻す。
func (m *TokenMutator) Rollback() error {
defer m.resetOrigFile()
filename := filepath.Join(m.workDir, m.Position().Filename)
return os.WriteFile(filename, m.origFile, 0o600)
}