Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

### <a name="filtering-uncovered-code"></a>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.

### <a name="black-list-false-positives"></a>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.
Expand Down
42 changes: 42 additions & 0 deletions cmd/go-mutesting/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bytes"
"context"
"crypto/md5"
"fmt"
"go/ast"
Expand Down Expand Up @@ -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)

Expand All @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions cmd/go-mutesting/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
93 changes: 93 additions & 0 deletions internal/filter/coverage.go
Original file line number Diff line number Diff line change
@@ -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]
}
81 changes: 81 additions & 0 deletions internal/filter/coverage_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
3 changes: 2 additions & 1 deletion internal/models/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down