From f278f3ab8ed857f981c864c94ee7106621f08e7b Mon Sep 17 00:00:00 2001 From: "Daniel R. Neal" Date: Mon, 25 May 2026 11:06:52 -0700 Subject: [PATCH] feat: add --covered-only flag to skip uncovered lines This introduces a CoverageFilter that automatically generates a temporary go test coverage profile. It skips mutating any AST nodes located on lines with 0 test coverage, drastically reducing noise and improving execution speed. --- README.md | 12 +++++ cmd/go-mutesting/main.go | 42 +++++++++++++++ cmd/go-mutesting/main_test.go | 12 +++++ internal/filter/coverage.go | 93 ++++++++++++++++++++++++++++++++ internal/filter/coverage_test.go | 81 ++++++++++++++++++++++++++++ internal/models/options.go | 3 +- 6 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 internal/filter/coverage.go create mode 100644 internal/filter/coverage_test.go diff --git a/README.md b/README.md index ce90e42..57ebe73 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,18 @@ The output shows that eight mutations have been found and tested. Six of them pa The summary also shows the **mutation score** which is a metric on how many mutations are killed by the test suite and therefore states the quality of the test suite. The mutation score is calculated by dividing the number of passed mutations by the number of total mutations, for the example above this would be 6/8=0.75. A score of 1.0 means that all mutations have been killed. +### Filtering untested code + +Mutation testing is incredibly powerful, but generating mutants for code that is already known to be untested is slow and generates unnecessary noise. go-mutesting includes a built-in coverage filter to solve this. + +You can use the `--covered-only` flag to automatically generate a Go test coverage profile and skip mutating any lines of code that have 0 coverage. + +```bash +go-mutesting --covered-only github.com/avito-tech/go-mutesting/... +``` + +This will drastically speed up execution time and ensure that every "Survived" mutant reported is a genuine logic blind spot in a tested function. + ### Blacklist false positives Mutation testing can generate many false positives since mutation algorithms do not fully understand the given source code. `early exits` are one common example. They can be implemented as optimizations and will almost always trigger a false-positive since the unoptimized code path will be used which will lead to the same result. go-mutesting is meant to be used as an addition to automatic test suites. It is therefore necessary to mark such mutations as false-positives. This is done with the `--blacklist` argument. The argument defines a file which contains in every line a MD5 checksum of a mutation. These checksums can then be used to ignore mutations. diff --git a/cmd/go-mutesting/main.go b/cmd/go-mutesting/main.go index 5749905..b759cfb 100644 --- a/cmd/go-mutesting/main.go +++ b/cmd/go-mutesting/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "crypto/md5" "fmt" "go/ast" @@ -202,6 +203,41 @@ MUTATOR: report := &models.Report{} + var coverageFilter *filter.CoverageFilter + if opts.Filter.CoveredOnly { + tmpFile, errTemp := os.CreateTemp("", "go-mutesting-coverage-*.out") + if errTemp != nil { + return exitError("Cannot create temporary coverage file: %v", errTemp) + } + + covPath := tmpFile.Name() + _ = tmpFile.Close() + + defer os.Remove(covPath) + + console.Verbose(opts, "Generating temporary coverage profile %q", covPath) + + testArgs := []string{"test", "-coverprofile=" + covPath} + testArgs = append(testArgs, opts.Remaining.Targets...) + + cmd := exec.CommandContext(context.Background(), "go", testArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if errRun := cmd.Run(); errRun != nil { + console.Verbose(opts, "Warning: go test with coverage returned an error: %v", errRun) + } + + cf, errLoad := filter.NewCoverageFilter(covPath) + if errLoad != nil { + return exitError("Cannot load coverprofile %q: %v", covPath, errLoad) + } + + coverageFilter = cf + + console.Verbose(opts, "Loaded coverage profile %q", covPath) + } + for _, file := range files { console.Verbose(opts, "Mutate %q", file) @@ -212,11 +248,17 @@ MUTATOR: annotationProcessor, skipFilterProcessor, } + if coverageFilter != nil { + collectors = append(collectors, coverageFilter) + } filters := []filter.NodeFilter{ annotationProcessor, skipFilterProcessor, } + if coverageFilter != nil { + filters = append(filters, coverageFilter) + } src, fset, pkg, info, err := parser.ParseAndTypeCheckFile(file, collectors) if err != nil { diff --git a/cmd/go-mutesting/main_test.go b/cmd/go-mutesting/main_test.go index fc5fc61..802cd6c 100644 --- a/cmd/go-mutesting/main_test.go +++ b/cmd/go-mutesting/main_test.go @@ -63,6 +63,18 @@ func TestMainSkipWithoutTest(t *testing.T) { ) } +func TestMainCoveredOnly(t *testing.T) { + t.Parallel() + + testMain( + t, + "../../example", + []string{"--debug", "--exec-timeout", "1", "--covered-only", "./..."}, + returnOk, + "The mutation score is 1.000000 (0 passed, 0 failed, 0 duplicated, 0 skipped, total is 0)", + ) +} + func TestMainJSONReport(t *testing.T) { tmpDir, err := os.MkdirTemp("", "go-mutesting-main-test-") assert.NoError(t, err) diff --git a/internal/filter/coverage.go b/internal/filter/coverage.go new file mode 100644 index 0000000..268654e --- /dev/null +++ b/internal/filter/coverage.go @@ -0,0 +1,93 @@ +package filter + +import ( + "fmt" + "go/ast" + "go/token" + "strings" + + "golang.org/x/tools/cover" +) + +// CoverageFilter filters out AST nodes that are on lines without test coverage. +type CoverageFilter struct { + CoveredLines map[string]map[int]bool + currentFset *token.FileSet +} + +// NewCoverageFilter creates a new CoverageFilter from a go cover profile. +func NewCoverageFilter(profilePath string) (*CoverageFilter, error) { + cf := &CoverageFilter{ + CoveredLines: make(map[string]map[int]bool), + } + + if profilePath == "" { + return cf, nil + } + + profiles, err := cover.ParseProfiles(profilePath) + if err != nil { + return nil, fmt.Errorf("failed to parse profiles: %w", err) + } + + for _, p := range profiles { + lines := make(map[int]bool) + + for _, b := range p.Blocks { + if b.Count == 0 { + continue + } + + for i := b.StartLine; i <= b.EndLine; i++ { + lines[i] = true + } + } + + cf.CoveredLines[p.FileName] = lines + } + + return cf, nil +} + +// Collect saves the fileset for the current file being walked. +func (c *CoverageFilter) Collect(_ *ast.File, fset *token.FileSet, _ string) { + c.currentFset = fset +} + +// ShouldSkip returns true if the node's line is not covered by tests. +func (c *CoverageFilter) ShouldSkip(node ast.Node, _ string) bool { + if c.currentFset == nil || len(c.CoveredLines) == 0 { + return false // No coverage filtering + } + + pos := c.currentFset.Position(node.Pos()) + + var bestMatch map[int]bool + + bestMatchLen := 0 + + for k, v := range c.CoveredLines { + aParts := strings.Split(k, "/") + bParts := strings.Split(pos.Filename, "/") + matchCount := 0 + + for i := 1; i <= len(aParts) && i <= len(bParts); i++ { + if aParts[len(aParts)-i] != bParts[len(bParts)-i] { + break + } + + matchCount++ + } + + if matchCount > bestMatchLen { + bestMatchLen = matchCount + bestMatch = v + } + } + + if bestMatchLen == 0 { + return true // File not found in coverage data, skip it + } + + return !bestMatch[pos.Line] +} diff --git a/internal/filter/coverage_test.go b/internal/filter/coverage_test.go new file mode 100644 index 0000000..bb1cf3c --- /dev/null +++ b/internal/filter/coverage_test.go @@ -0,0 +1,81 @@ +package filter + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "testing" +) + +func TestCoverageFilter(t *testing.T) { + t.Parallel() + + covData := `mode: set +github.com/example/pkg/test.go:1.1,2.2 1 1 +github.com/example/pkg/test.go:3.3,4.4 1 0 +` + tmpDir := t.TempDir() + covFile := filepath.Join(tmpDir, "coverage.out") + + err := os.WriteFile(covFile, []byte(covData), 0o644) + if err != nil { + t.Fatalf("failed to write cov file: %v", err) + } + + cf, err := NewCoverageFilter(covFile) + if err != nil { + t.Fatalf("failed to create filter: %v", err) + } + + if cf == nil { + t.Fatal("expected non-nil filter") + } + + // Line 1 and 2 should be true + if !cf.CoveredLines["github.com/example/pkg/test.go"][1] { + t.Error("expected line 1 to be covered") + } + + if !cf.CoveredLines["github.com/example/pkg/test.go"][2] { + t.Error("expected line 2 to be covered") + } + + // Line 3 and 4 should be absent or false + if cf.CoveredLines["github.com/example/pkg/test.go"][3] { + t.Error("expected line 3 to be not covered") + } + + fset := token.NewFileSet() + src := `package main +func main() { // Line 2 + println("covered") // Line 3 + println("not covered") // Line 4 +} +` + + f, err := parser.ParseFile(fset, "github.com/example/pkg/test.go", src, 0) + if err != nil { + t.Fatalf("failed to parse file: %v", err) + } + + cf.Collect(f, fset, "") + + funcDecl, ok := f.Decls[0].(*ast.FuncDecl) + if !ok { + t.Fatalf("expected ast.FuncDecl, got %T", f.Decls[0]) + } + + body := funcDecl.Body + + // The nodes will be at line 3 and 4 in the parsed AST. + // Since our dummy coverage says line 3 has 0 coverage, ShouldSkip should return true. + if !cf.ShouldSkip(body.List[0], "") { + t.Error("expected line 3 to be skipped") + } + + if !cf.ShouldSkip(body.List[1], "") { + t.Error("expected line 4 to be skipped") + } +} diff --git a/internal/models/options.go b/internal/models/options.go index 3c7d3a3..0068b69 100644 --- a/internal/models/options.go +++ b/internal/models/options.go @@ -23,7 +23,8 @@ type Options struct { } `group:"Mutator options"` Filter struct { - Match string `long:"match" description:"Only functions are mutated that confirm to the arguments regex"` + Match string `description:"Regex to match functions to mutate" long:"match"` + CoveredOnly bool `description:"Only mutate covered lines (auto-generates coverage)" long:"covered-only"` } `group:"Filter options"` Exec struct {