Skip to content

Commit

Permalink
Output mutant statuses filter (#226)
Browse files Browse the repository at this point in the history
* docs: add output-statuses flag

#108

* feat: add mutant logger

#108

* refactor: move mutant printing to logger

#108

* ci: add statuses filter

#108

* fix: deepsource issue

---------

Co-authored-by: Davide Petilli <[email protected]>
  • Loading branch information
rusinikita and k3rn31 authored Feb 15, 2024
1 parent ba6851a commit e7bdf6a
Show file tree
Hide file tree
Showing 10 changed files with 245 additions and 10 deletions.
1 change: 1 addition & 0 deletions .gremlins.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
unleash:
output-statuses: lctr
threshold:
efficacy: 80
mutant-coverage: 90
2 changes: 2 additions & 0 deletions cmd/unleash.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const (
paramBuildTags = "tags"
paramCoverPackages = "coverpkg"
paramDryRun = "dry-run"
paramOutputStatuses = "output-statuses"
paramOutput = "output"
paramIntegrationMode = "integration"
paramExcludeFiles = "exclude-files"
Expand Down Expand Up @@ -206,6 +207,7 @@ func setFlagsOnCmd(cmd *cobra.Command) error {

fls := []*flags.Flag{
{Name: paramDryRun, CfgKey: configuration.UnleashDryRunKey, Shorthand: "d", DefaultV: false, Usage: "find mutations but do not executes tests"},
{Name: paramOutputStatuses, CfgKey: configuration.UnleashOutputStatusesKey, Shorthand: "S", DefaultV: "", Usage: "print only statuses from this flag, allowed values - 'lctkvsr'"},
{Name: paramBuildTags, CfgKey: configuration.UnleashTagsKey, Shorthand: "t", DefaultV: "", Usage: "a comma-separated list of build tags"},
{Name: paramCoverPackages, CfgKey: configuration.UnleashCoverPkgKey, DefaultV: "", Usage: "a comma-separated list of package patterns"},
{Name: paramDiff, CfgKey: configuration.UnleashDiffRef, Shorthand: "D", DefaultV: "", Usage: "diff branch or commit"},
Expand Down
53 changes: 50 additions & 3 deletions docs/docs/usage/commands/unleash/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ gremlins unleash -E "_(gen|wrap).go$" -E "^(generate|wrap)/" -E "internal/super_

### Diff

:material-flag: `--diff` · :material-sign-direction: Default: empty
:material-flag: `--diff`/`-D` · :material-sign-direction: Default: empty

Run tests only for mutants inside code changes between current state and git reference (branch or commit).
The default is each mutant covered by tests.
Expand All @@ -107,8 +107,6 @@ gremlins unleash --diff "origin/$GITHUB_BASE_REF"

Use `actions/checkout@v4` with `fetch-depth: 0` to fetch all history.

#### Using

### Dry run

:material-flag:`--dry-run`/`-d` · :material-sign-direction: Default: false
Expand All @@ -119,6 +117,55 @@ Just performs the analysis but not the mutation testing.
gremlins unleash --dry-run
```

### Statuses output

:material-flag: `--output-statuses`/`-S` · :material-sign-direction: Default: empty - show all

Filters stdout to print only statuses from flag. Useful to filter important findings in big project output.
Alternative to `gremlins r | grep LIVED` configured from file.

Flag do not change json file and stats report content.

### Examples

#### Show only `LIVED` and `NOT COVERED`

```shell
gremlins unleash --output-statuses "lc"
```

Output

```
LIVED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3
NOT COVERED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3
```

#### Filter out out `SKIPPED`, `KILLED`.

```shell
gremlins unleash --S lctv
```

Output

```
LIVED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3
NOT COVERED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3
NOT VIABLE CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3
TIMED OUT CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3
```

### Filter letters

- `l` - LIVED
- `c` - NOT COVERED
- `t` - TIMED OUT
- `k` - KILLED
- `v` - NOT VIABLE
- `s` - SKIPPED
- `r` - RUNNABLE

### Increment decrement

:material-flag: `--increment-decrement` · :material-sign-direction: Default: `true`
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/usage/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ unleash:
dry-run: false
tags: ""
output: ""
diff: ""
output-statuses: ""
workers: 0 #(1)
test-cpu: 0 #(2)
timeout-coefficient: 0 #(3)
Expand Down
1 change: 1 addition & 0 deletions internal/configuration/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
const (
GremlinsSilentKey = "silent"
UnleashDryRunKey = "unleash.dry-run"
UnleashOutputStatusesKey = "unleash.output-statuses"
UnleashOutputKey = "unleash.output"
UnleashTagsKey = "unleash.tags"
UnleashCoverPkgKey = "unleash.coverpkg"
Expand Down
3 changes: 3 additions & 0 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type Engine struct {
codeData CodeData
mutantStream chan mutator.Mutator
module gomodule.GoModule
logger report.MutantLogger
}

// CodeData is used to check if the mutant should be executed.
Expand All @@ -73,6 +74,7 @@ func New(mod gomodule.GoModule, codeData CodeData, jDealer ExecutorDealer, opts
jDealer: jDealer,
codeData: codeData,
fs: dirFS,
logger: report.NewLogger(),
}
for _, opt := range opts {
mut = opt(mut)
Expand Down Expand Up @@ -224,6 +226,7 @@ func (mu *Engine) executeTests(ctx context.Context) report.Results {
}()

for m := range outCh {
mu.logger.Mutant(m)
mutants = append(mutants, m)
}

Expand Down
8 changes: 2 additions & 6 deletions internal/engine/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,12 @@ import (
"sync"
"time"

"github.com/go-gremlins/gremlins/internal/configuration"
"github.com/go-gremlins/gremlins/internal/engine/workdir"
"github.com/go-gremlins/gremlins/internal/engine/workerpool"
"github.com/go-gremlins/gremlins/internal/gomodule"
"github.com/go-gremlins/gremlins/internal/log"
"github.com/go-gremlins/gremlins/internal/mutator"
"github.com/go-gremlins/gremlins/internal/report"

"github.com/go-gremlins/gremlins/internal/configuration"
"github.com/go-gremlins/gremlins/internal/gomodule"
)

// DefaultTimeoutCoefficient is the default multiplier for the timeout length
Expand Down Expand Up @@ -172,7 +170,6 @@ func (m *mutantExecutor) Start(w *workerpool.Worker) {

if m.mutant.Status() == mutator.NotCovered || m.mutant.Status() == mutator.Skipped || m.dryRun {
m.outCh <- m.mutant
report.Mutant(m.mutant)

return
}
Expand All @@ -191,7 +188,6 @@ func (m *mutantExecutor) Start(w *workerpool.Worker) {
}

m.outCh <- m.mutant
report.Mutant(m.mutant)
}

func (m *mutantExecutor) runTests(rootDir, pkg string) mutator.Status {
Expand Down
73 changes: 73 additions & 0 deletions internal/report/logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package report

import (
"errors"

"github.com/go-gremlins/gremlins/internal/configuration"
"github.com/go-gremlins/gremlins/internal/log"
"github.com/go-gremlins/gremlins/internal/mutator"
)

type Filter = map[mutator.Status]struct{}

var ErrInvalidFilter = errors.New("invalid statuses filter, only 'lctkvsr' letters allowed")

// MutantLogger prints mutant statuses based on filter and verbosity flags.
type MutantLogger struct {
Filter
}

func NewLogger() MutantLogger {
outputStatuses := configuration.Get[string](configuration.UnleashOutputStatusesKey)
f, err := ParseFilter(outputStatuses)
if err != nil {
log.Infof("output-statuses filter not applied: %s\n", err)
}

return MutantLogger{
Filter: f,
}
}

func (l MutantLogger) Mutant(m mutator.Mutator) {
if l.Filter == nil {
Mutant(m)

return
}

if _, ok := l.Filter[m.Status()]; ok {
Mutant(m)
}
}

func ParseFilter(s string) (Filter, error) {
if s == "" {
return nil, nil
}

result := Filter{}

for _, r := range s {
switch r {
case 'l':
result[mutator.Lived] = struct{}{}
case 'c':
result[mutator.NotCovered] = struct{}{}
case 't':
result[mutator.TimedOut] = struct{}{}
case 'k':
result[mutator.Killed] = struct{}{}
case 'v':
result[mutator.NotViable] = struct{}{}
case 's':
result[mutator.Skipped] = struct{}{}
case 'r':
result[mutator.Runnable] = struct{}{}
default:
return nil, ErrInvalidFilter
}
}

return result, nil
}
107 changes: 107 additions & 0 deletions internal/report/logger_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package report_test

import (
"bytes"
"errors"
"reflect"
"testing"

"github.com/google/go-cmp/cmp"

"github.com/go-gremlins/gremlins/internal/configuration"
"github.com/go-gremlins/gremlins/internal/log"
"github.com/go-gremlins/gremlins/internal/mutator"
"github.com/go-gremlins/gremlins/internal/report"
)

func Test_parseFilter(t *testing.T) {
tests := []struct {
filter string
want report.Filter
err error
}{
{
filter: "lc",
want: report.Filter{
mutator.Lived: struct{}{},
mutator.NotCovered: struct{}{},
},
},
{
filter: "tkvs",
want: report.Filter{
mutator.TimedOut: struct{}{},
mutator.Killed: struct{}{},
mutator.NotViable: struct{}{},
mutator.Skipped: struct{}{},
},
},
{
filter: "r",
want: report.Filter{
mutator.Runnable: struct{}{},
},
},
{
filter: "",
},
{
filter: "lnc",
want: nil,
err: report.ErrInvalidFilter,
},
}
for _, tt := range tests {
t.Run(tt.filter, func(t *testing.T) {
got, err := report.ParseFilter(tt.filter)
if !errors.Is(err, tt.err) {
t.Errorf("ParseFilter() error = %v, wantErr %v", err, tt.err)
}

if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParseFilter() got = %v, want %v", got, tt.want)
}
})
}
}

func TestLogger(t *testing.T) {
out := &bytes.Buffer{}
defer out.Reset()
log.Init(out, &bytes.Buffer{})
defer log.Reset()

m := stubMutant{status: mutator.NotCovered, mutantType: mutator.ConditionalsBoundary, position: fakePosition}

configuration.Set(configuration.UnleashOutputStatusesKey, "lp")
logger := report.NewLogger() // prints error

logger.Mutant(m) // prints Not covered because no filter

m.status = mutator.Killed

configuration.Set(configuration.UnleashOutputStatusesKey, "")
logger = report.NewLogger()

logger.Mutant(m) // prints Killed because no filter

configuration.Set(configuration.UnleashOutputStatusesKey, "l")
logger = report.NewLogger()

logger.Mutant(m) // Killed filtered

m.status = mutator.Lived

logger.Mutant(m) // prints Lived because no filter

got := out.String()

want := "output-statuses filter not applied: " + report.ErrInvalidFilter.Error() + "\n" +
" NOT COVERED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" +
" KILLED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" +
" LIVED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n"

if !cmp.Equal(got, want) {
t.Errorf(cmp.Diff(got, want))
}
}
5 changes: 4 additions & 1 deletion internal/report/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,8 @@ func TestMutantLog(t *testing.T) {
report.Mutant(m)
m = stubMutant{status: mutator.TimedOut, mutantType: mutator.ConditionalsBoundary, position: fakePosition}
report.Mutant(m)
m = stubMutant{status: mutator.Skipped, mutantType: mutator.ConditionalsBoundary, position: fakePosition}
report.Mutant(m)

got := out.String()

Expand All @@ -320,7 +322,8 @@ func TestMutantLog(t *testing.T) {
" NOT COVERED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" +
" RUNNABLE CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" +
" NOT VIABLE CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" +
" TIMED OUT CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n"
" TIMED OUT CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" +
" SKIPPED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n"

if !cmp.Equal(got, want) {
t.Errorf(cmp.Diff(got, want))
Expand Down

0 comments on commit e7bdf6a

Please sign in to comment.