Gremlins がどうやってコードを置き換えているのかを追う
Gremlins は Go の ミューテーションテスト ライブラリ
ということは、コードを置き換えているはずなので、該当箇所を探してみる
gremlins unleash が実際にミューテーションテストを実行するコマンド
unleash = 解き放つ、洒落てるコマンド名
該当コードは cmd/unleash.go: の runUnleashなので、これを追っていく
warning.icon エラー構文は意図的に省略
LT;DR
実際のコードを書き換えているわけではなく、一時ディレクトリにコピーしている
文字列置換ではなく、AST を用いている
置換後 AST をコードに再レンダリングし、コードを実行している
1. 一時ディレクトリ gremlins-* の作成: runUnleash 関数
code:go
mod, err := gomodule.Init(path) // Go モジュールでなければエラー
workDir, err := os.MkdirTemp(os.TempDir(), "gremlins-") // ルート作業ディレクトリ
defer cleanUp(workDir) // 終了時に RemoveAll
この関数から呼び出される run 関数で、以降の処理に必要なコンポーネントを組み立てている
2. テストカバレッジ 取得: run 関数
code:go
c := coverage.New(workDir, mod)
cProfile, err := c.Run()
Coverage.Run メソッド
code:go
_ = 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}, nil
実行(executeCoverage)前に 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 関数
code:go
jDealer := engine.NewExecutorDealer(mod, wdDealer, cProfile.Elapsed)
2. で測定した実行時間からタイムアウト時間を計算する
4. 変異する箇所の特定: run 関数
code:go
mut := engine.New(mod, codeData, jDealer)
results := mut.Run(ctx)
Engine.Run メソッド
code:go
gomu.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)
ディレクトリごとに処理を実施
Go の本番コードかつ除外されたファイルでなければ、Engine.runOnFile メソッド実行
Engine.runOnFile メソッド
code:go
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
})
ファイルをパースし、ast.Inspect 関数で AST を辿りながら操作する
NewTokenNode 関数で対象となるノード(演算子・代入)だけを抽出
AssignStmt, BinaryExpr, BranchStmt, IncDecStmt, UnaryExpr
Engine.findMutations 関数
code:go
mutantTypes, ok := TokenMutantTypenode.Tok() // このトークンに適用できる変異タイプの一覧
...
tm := NewTokenMutant(pkg, set, file, node) // set と file を共有したまま変異体を作る
tm.SetType(mutantType)
tm.SetStatus(mu.mutationStatus(set.Position(node.TokPos)))
mu.mutantStream <- tm
変異タイプとどう変更するかを引き、変異体を生成する
変異体は チャネルを通じて、Run 関数から呼ばれる executeTests 関数で消費される
5. 変異体の実行
executeTests 関数内で呼ばれている、Start メソッドで行われる
code:go
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.mutant
5-1. 本番コードのコピー
run 関数から呼ばれている NewCachedDealer でキャッシュを行う構造体を初期化
code:go
wdDealer := workdir.NewCachedDealer(workDir, mod.Root)
defer wdDealer.Clean()
実際のコピー処理は Start 関数内で呼ばれている CachedDelaler.Get メソッドで worker ごと初回だけ遅延実行する
e.g. 8 worker でも変異体が 3 個しかなければ、コピーは 3 回しか起こらない
code:go
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 メソッド
code:go
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(tokenMutationsm.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 メソッド
code:go
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.Lived
testExecutionTime は 3. で計算した値
5-4. 復元
Rollback メソッド
code:go
func (m *TokenMutator) Rollback() error {
defer m.resetOrigFile()
filename := filepath.Join(m.workDir, m.Position().Filename)
return os.WriteFile(filename, m.origFile, 0o600)
}
#Gremlins #Go