Skip to content
Closed
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
36 changes: 17 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Relational Operator Replacement (ROR) — mutating `>`, `>=`, `<`, `<=`, `==`, `
- **`-threshold`** — CI quality gate (e.g., `-threshold 80` fails if score < 80%)
- **`-json`** — Machine-readable output for CI pipelines (`-json -v` for NDJSON streaming)
- **`-dry-run`** — Preview mutations without running tests
- **Low noise** — Automatically skips equivalent mutants (`len(x) > 0`) and simple error propagation (`if err != nil { return err }`)
- **Low noise** — Automatically skips equivalent local integer clamps (`if x < 1 { x = 1 }`) and simple error propagation (`if err != nil { return err }`)
- **Zero dependencies** — Go standard library only

---
Expand Down Expand Up @@ -153,28 +153,25 @@ When mutest reports a survivor like this:
It means mutest swapped the operator and **no test noticed**:

```go
func Max(a, b int) int {
if a > b { // ← mutest changed this to >=, tests still passed
return a
}
return b
func IsAdult(age int) bool {
return age > 17 // ← mutest changed this to >=, tests still passed
}
```

The fix — add a test at the boundary:
Add a test at the boundary:

```go
func TestMax_EqualValues(t *testing.T) {
// This test kills the > → >= mutation because Max(3,3)
// returns 3 with >, but would return a (also 3) with >=.
// More importantly, it verifies the boundary behavior is intentional.
if got := Max(3, 3); got != 3 {
t.Errorf("Max(3,3) = %d, want 3", got)
func TestIsAdult_Boundary(t *testing.T) {
if IsAdult(17) {
t.Fatal("17 is below the adult age threshold")
}
}
```

Re-run mutest and that mutation point will now show `--- KILLED:`.
An equivalent mutation cannot be killed by adding tests: for example, changing
`>` to `>=` in an integer `Max(a, b)` that returns either operand produces the
same result when `a == b`. Review such sites and use `//mutest:skip` when appropriate.

### Exit Codes

Expand All @@ -201,7 +198,7 @@ Positional arguments are package patterns (default: `./...`), following the same
| `-diff` | | Only mutate lines changed relative to this git ref (e.g., `origin/main`) |
| `-dry-run` | `false` | Discover mutations without running tests |
| `-run` | | Regexp to pass to `go test -run` |
| `-workers` | `NumCPU` | Max parallel test processes |
| `-workers` | `NumCPU` | Max concurrent package builds / test processes; mutants in one package run sequentially |
| `-timeout` | `30s` | Per-mutant test timeout |
| `-threshold` | `0` | Minimum kill rate % (0-100); exit 1 if below. 0 = any survived fails |
| `-skip-err-propagation` | `true` | Skip simple error propagation patterns (`if err != nil { return err }`) |
Expand Down Expand Up @@ -367,9 +364,11 @@ $ mutest -dry-run -json ./...
3. **Instrument** — Replace each ordered comparison with a generic helper call (e.g., `a > b` → `_mutest_cmp_1(a, b)`) and each equality comparison with a flip of its result (`a == b` → `(a == b) != _mutest_on(1)`; the original comparison stays in place because its operands may legally have different static types, e.g. `any == error`), then generate a runtime file that switches behavior based on `MUTEST_ID`
4. **Build** — Compile one test binary per package with all mutations embedded
5. **Verify baseline** — Run each test binary once with **no** mutation active. If any package's tests fail without a mutation, mutest aborts (a broken or flaky suite would otherwise make every mutant a false KILLED)
6. **Test** — Run the pre-built binary once per mutation with `MUTEST_ID=N`, in a parallel worker pool. Each binary runs with its package directory as the working directory, so `testdata` relative paths resolve
6. **Test** — Run the pre-built binary once per mutation with `MUTEST_ID=N`, in a worker pool that runs packages in parallel and mutants within a package sequentially to avoid shared fixture conflicts. Each binary runs with its package directory as the working directory, so `testdata` relative paths resolve
7. **Judge** — `exit 0` = survived (test gap), `exit != 0` = killed (caught), timeout = detected (hung)

Thresholds use the unrounded kill rate. Captured build/test output retains at most the last 64 KiB per process, with a truncation marker when needed. Timeouts cancel the test process tree on Unix and Windows.

A package with **no test files** builds no binary; its mutants are reported as `SURVIVED` (no tests is the largest possible test gap). If the run is interrupted (e.g. `Ctrl+C`), untested mutants are reported as `CANCELED` and mutest exits non-zero rather than judging success on partial results.

This **runtime mutation selection** approach compiles each package only once regardless of how many mutations it contains. Traditional per-mutation compilation (`N mutations × compile`) is replaced with `P packages × compile + N mutations × run`, dramatically reducing overhead. **Original source files are never modified.**
Expand All @@ -380,13 +379,12 @@ mutest automatically skips mutations that are known to produce false positives:

| Pattern | Example | Why skipped |
|---------|---------|-------------|
| `len(x)` compared to `0` | `len(s) > 0` → `len(s) >= 0` | `len()` never returns negative, so the mutation cannot change behavior |
| `cap(x)` compared to `0` | `cap(s) > 0` → `cap(s) >= 0` | Same as `len()` — `cap()` is always non-negative |
| Local integer clamp | `if x < 1 { x = 1 }` | At equality, assigning the same constant leaves the local integer unchanged; no `else` branch |
| Simple error propagation | `if err != nil { return err }` | Go's idiomatic error propagation — mutating these generates noise without meaningful test gaps |

Comparisons with non-zero literals (e.g., `len(s) > 1`) are **not** skipped — the boundary between 1 and 2 is meaningful.
Comparisons involving `len` or `cap`, including comparisons to zero, are tested: changing `len(s) > 0` to `len(s) >= 0` changes behavior for an empty slice. Clamp skipping excludes floats, map entries, globals, and expressions with side effects.

Complex error handling is **not** skipped — compound conditions (`err != nil && !timedOut`), fallback assignments, and multi-statement bodies represent real logic that should be tested.
Only direct propagation of an error-typed value (or a `fmt.Errorf` wrapper) under `err != nil` is skipped. Success branches (`err == nil`) are tested. Complex error handling is **not** skipped — compound conditions (`err != nil && !timedOut`), fallback assignments, and multi-statement bodies represent real logic that should be tested.

To disable error propagation skipping and mutate all `err != nil` checks:

Expand Down
273 changes: 273 additions & 0 deletions cmd/mutest/regression_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
package mutest_test

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

mutest "github.com/fchimpan/mutest/cmd/mutest"
"github.com/fchimpan/mutest/output"
)

func TestRegressionMutationSemantics(t *testing.T) {
cases := []struct {
name string
src string
test string
total int
killed int
}{
{
name: "defined bool",
src: "type Flag bool\nfunc F(n int) Flag { return n > 0 }",
test: `if F(0) {t.Fatal("boundary")}`,
total: 1,
killed: 1,
},
{
name: "constant precision",
src: "func F() bool {return 9007199254740992.0 < 9007199254740993.0}",
test: `if !F() {t.Fatal("precision")}`,
total: 1,
},
{
name: "large constant",
src: "func F() bool {return 1<<100 > 0}",
test: `if !F() {t.Fatal("overflow")}`,
total: 1,
},
{
name: "constant boundary",
src: "func F() bool {return 1<<100 <= 1<<100}",
test: `if !F() {t.Fatal("boundary")}`,
total: 1,
killed: 1,
},
{
name: "len boundary",
src: "func F(s []int) bool {return len(s)>0}",
test: `if F(nil) {t.Fatal("empty")}`,
total: 1,
killed: 1,
},
{
name: "fallback",
src: `func F(err error) string {if err!=nil {return "fallback"};return "ok"}`,
test: `if F(nil)!="ok" {t.Fail()}`,
total: 1,
killed: 1,
},
{
name: "success branch",
src: `func F(err error) string {if err==nil {return "ok"};return "bad"}`,
test: `if F(nil)!="ok" {t.Fail()}`,
total: 1,
killed: 1,
},
{
name: "direct propagation",
src: "func F(err error) error {if err!=nil {return err};return nil}",
test: `if F(nil)!=nil {t.Fail()}`,
},
{
name: "wrapped propagation",
src: `import "fmt"
func F(err error) error {if err!=nil {return fmt.Errorf("wrap: %w",err)};return nil}`,
test: `if F(nil)!=nil {t.Fail()}`,
},
{
name: "return side effects",
src: `var Calls int
func record()int{Calls++;return 0}
func F(err error)(int,error) {if err!=nil {return record(),err};return 0,nil}`,
test: `_,_=F(nil);if Calls!=0 {t.Fail()}`,
total: 1,
killed: 1,
},
{
name: "typed non error",
src: "func F(err *int) int {if err!=nil {return 1};return 0}",
test: `if F(nil)!=0 {t.Fail()}`,
total: 1,
killed: 1,
},
{
name: "operand side effects",
src: `var Calls int
func next() int {Calls++;return Calls}
func F() bool {return next()<next()}`,
test: `Calls=0;if !F() || Calls!=2 {t.Fatal("evaluated twice")}`,
total: 1,
},
{
name: "cgo",
src: `/* typedef int number; */
import "C"
func F(n int) bool {return n>0}`,
test: "if F(0) {t.Fail()}",
total: 1,
killed: 1,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
if tt.name == "cgo" {
if os.Getenv("CGO_ENABLED") == "0" {
t.Skip("cgo disabled")
}
if _, err := exec.LookPath("cc"); err != nil {
t.Skip("C compiler unavailable")
}
}
dir := t.TempDir()
writeFiles(t, dir, map[string]string{
"go.mod": "module regression\n\ngo 1.24.0\n",
"lib.go": "package regression\n" + tt.src + "\n",
"lib_test.go": "package regression\nimport \"testing\"\nfunc TestF(t *testing.T){" + tt.test + "}\n",
})
chdir(t, dir)
var out, diag bytes.Buffer
err := mutest.Run(context.Background(), []string{"-json", "-workers", "1", "./..."}, &out, &diag)
if err != nil && !errors.Is(err, mutest.ErrTestsFailed) {
t.Fatalf("%v\n%s", err, diag.String())
}
var s output.JSONSummary
if err := json.Unmarshal(out.Bytes(), &s); err != nil {
t.Fatal(err)
}
if s.Total != tt.total || s.Killed != tt.killed || s.Errors != 0 || s.Canceled != 0 {
t.Fatalf("got %+v", s)
}
})
}
}

func TestRegressionDiscoveryErrorsAndPositions(t *testing.T) {
for _, tt := range []struct {
name string
src string
wantErr bool
}{
{
name: "syntax error",
src: "package p\nfunc F(n int) bool {return n > }",
wantErr: true,
},
{
name: "physical lines",
src: "package p\n//line input.go:1000\nfunc F(n int) bool {\n//mutest:skip\nreturn n > 0\n}",
},
} {
t.Run(tt.name, func(t *testing.T) {
d := t.TempDir()
writeFiles(t, d, map[string]string{
"go.mod": "module p\ngo 1.24.0\n",
"lib.go": tt.src,
})
chdir(t, d)
var out bytes.Buffer
err := mutest.Run(context.Background(), []string{"-dry-run", "-json", "./..."}, &out, io.Discard)
if tt.wantErr {
if !errors.Is(err, mutest.ErrDiscovery) {
t.Fatalf("got %v", err)
}
} else if err != nil || strings.TrimSpace(out.String()) != "[]" {
t.Fatalf("%v %s", err, &out)
}
})
}
}

type failingWriter struct{}

func (failingWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("disk full") }
func TestRunPropagatesOutputFailure(t *testing.T) {
d := t.TempDir()
writeFiles(t, d, map[string]string{
"go.mod": "module p\ngo 1.24.0\n",
"lib.go": "package p\n",
})
chdir(t, d)
err := mutest.Run(context.Background(), []string{"-dry-run", "-json"}, failingWriter{}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "disk full") {
t.Fatalf("got %v", err)
}
}
func TestRunCanceledBeforeDiscovery(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := mutest.Run(ctx, []string{"-dry-run"}, io.Discard, io.Discard)
if !errors.Is(err, mutest.ErrInterrupted) {
t.Fatalf("got %v", err)
}
if strings.Count(err.Error(), mutest.ErrInterrupted.Error()) != 1 {
t.Fatalf("duplicated interruption diagnostic: %v", err)
}
}

func TestRunSerializesSharedPackageFixtures(t *testing.T) {
dir := t.TempDir()
writeFiles(t, dir, map[string]string{
"go.mod": "module fixture\ngo 1.24.0\n",
"lib.go": `package fixture
func A(n int) bool {return n>0}
func B(n int) bool {return n<0}
`,
"lib_test.go": `package fixture
import("os";"testing";"time")
func TestSharedFixture(t *testing.T) {
f,err:=os.OpenFile("fixture.lock",os.O_CREATE|os.O_EXCL|os.O_WRONLY,0600)
if err!=nil {t.Fatal(err)}
f.Close()
defer os.Remove("fixture.lock")
time.Sleep(100*time.Millisecond)
}
`,
})
chdir(t, dir)
var out, diag bytes.Buffer
err := mutest.Run(context.Background(), []string{"-json", "-workers", "2", "./..."}, &out, &diag)
if !errors.Is(err, mutest.ErrTestsFailed) {
t.Fatalf("%v: %s", err, &diag)
}
var s output.JSONSummary
if err := json.Unmarshal(out.Bytes(), &s); err != nil {
t.Fatal(err)
}
if s.Total != 2 || s.Survived != 2 || s.Killed != 0 {
t.Fatalf("false detection from fixture conflict: %+v", s)
}
}

func writeFiles(t *testing.T, dir string, files map[string]string) {
t.Helper()
for name, content := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
}

func chdir(t *testing.T, dir string) {
t.Helper()
original, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.Chdir(original); err != nil {
t.Errorf("restore working directory: %v", err)
}
})
}
Loading
Loading