mutation_tester 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +83 -0
- data/LICENSE.txt +19 -0
- data/Rakefile +57 -0
- data/docs/ci.md +158 -0
- data/docs/execution-runners.md +163 -0
- data/docs/json-schema.md +171 -0
- data/docs/mutation-types.md +207 -0
- data/examples/calculator.rb +35 -0
- data/examples/calculator_100_perc_spec.rb +121 -0
- data/examples/calculator_minitest.rb +53 -0
- data/examples/calculator_spec.rb +105 -0
- data/examples/github_actions/ai_mutation_gate.yml +75 -0
- data/examples/github_actions/mutation_test.yml +87 -0
- data/examples/hooks/pre-push +41 -0
- data/examples/run_example.rb +31 -0
- data/examples/run_example_minitest.rb +38 -0
- data/examples/run_example_parallel_minitest.rb +48 -0
- data/examples/run_example_parallel_rspec.rb +48 -0
- data/examples/run_example_rspec.rb +38 -0
- data/examples/spec_helper.rb +21 -0
- data/exe/mutation_test +345 -0
- data/lib/mutation_tester/batch_runner.rb +286 -0
- data/lib/mutation_tester/configuration.rb +105 -0
- data/lib/mutation_tester/core.rb +193 -0
- data/lib/mutation_tester/fork_runner/worker.rb +202 -0
- data/lib/mutation_tester/fork_runner.rb +382 -0
- data/lib/mutation_tester/framework_detector.rb +25 -0
- data/lib/mutation_tester/in_memory_loader.rb +25 -0
- data/lib/mutation_tester/mutation_runner.rb +644 -0
- data/lib/mutation_tester/mutator.rb +874 -0
- data/lib/mutation_tester/progress_display.rb +130 -0
- data/lib/mutation_tester/railtie.rb +7 -0
- data/lib/mutation_tester/rake_task.rb +18 -0
- data/lib/mutation_tester/reporters/base_reporter.rb +154 -0
- data/lib/mutation_tester/reporters/batch_json_reporter.rb +72 -0
- data/lib/mutation_tester/reporters/console_reporter.rb +130 -0
- data/lib/mutation_tester/reporters/html_reporter.rb +693 -0
- data/lib/mutation_tester/reporters/json_reporter.rb +67 -0
- data/lib/mutation_tester/test_command.rb +165 -0
- data/lib/mutation_tester/version.rb +3 -0
- data/lib/mutation_tester.rb +52 -0
- data/lib/tasks/mutation_tester.rake +80 -0
- data/readme.md +925 -0
- metadata +213 -0
data/docs/json-schema.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# JSON Report Schema
|
|
2
|
+
|
|
3
|
+
Field-by-field documentation of the machine-readable JSON that `mutation_test`
|
|
4
|
+
produces (written to `mutation_report.json` and, with `--json`, streamed to
|
|
5
|
+
stdout). See the [Reports and output](../readme.md#reports-and-output) section of
|
|
6
|
+
the README for the stdout/stderr contract and the quick-start `jq` recipes.
|
|
7
|
+
|
|
8
|
+
A run that resolves exactly one positional source file, or an explicit
|
|
9
|
+
`SOURCE_FILE TEST_FILE` pair, prints the plain per-file report documented in
|
|
10
|
+
[Single-file report](#single-file-report-schema_version-1). A multi-file run (a
|
|
11
|
+
`FILE` list with more than one file, `--staged`, or `--glob`) prints one
|
|
12
|
+
aggregate envelope instead; see
|
|
13
|
+
[Multi-file runs: the aggregate envelope](#multi-file-runs-the-aggregate-envelope).
|
|
14
|
+
|
|
15
|
+
## Schema versioning
|
|
16
|
+
|
|
17
|
+
The report carries a top-level `schema_version` (currently `1`). It is bumped on
|
|
18
|
+
any **incompatible** change to the shape (a removed or renamed field, or a
|
|
19
|
+
changed type or meaning); purely additive fields do not bump it. Read
|
|
20
|
+
`schema_version` before relying on the payload, and see the CHANGELOG for the
|
|
21
|
+
history of changes.
|
|
22
|
+
|
|
23
|
+
## Single-file report (schema_version 1)
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"schema_version": 1,
|
|
28
|
+
"interrupted": false,
|
|
29
|
+
"metadata": {
|
|
30
|
+
"version": "1.0.0",
|
|
31
|
+
"generated_at": "2024-01-15T10:30:00Z",
|
|
32
|
+
"source_file": "app/models/user.rb"
|
|
33
|
+
},
|
|
34
|
+
"summary": {
|
|
35
|
+
"total": 20,
|
|
36
|
+
"killed": 18,
|
|
37
|
+
"survived": 2,
|
|
38
|
+
"mutation_score": 90.0
|
|
39
|
+
},
|
|
40
|
+
"mutations": [
|
|
41
|
+
...
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| Field | Type | Description |
|
|
47
|
+
|--------------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
48
|
+
| `schema_version` | integer | Version of this schema. Start value `1`. |
|
|
49
|
+
| `interrupted` | boolean | Additive (does not bump `schema_version`). `true` only when `--fail-fast` stopped the run before every generated mutation was processed, so the report is partial; `false` for a complete run, including a run whose first surviving mutant was the last mutation. |
|
|
50
|
+
| `metadata.version` | string | Version of the `mutation_tester` gem that produced the report. |
|
|
51
|
+
| `metadata.generated_at` | string | ISO 8601 timestamp of when the report was generated. |
|
|
52
|
+
| `metadata.source_file` | string | Absolute path of the mutated source file. |
|
|
53
|
+
| `metadata.spec_file` | string | Absolute path of the test file that was run. |
|
|
54
|
+
| `summary.total` | integer | Total number of mutations produced (all statuses). |
|
|
55
|
+
| `summary.killed` | integer | Effective kills (a `timeout` counts as a kill). Kept for backward compatibility. |
|
|
56
|
+
| `summary.survived` | integer | Number of surviving mutants. |
|
|
57
|
+
| `summary.mutation_score` | number | Percentage `(killed + timeout) / (killed + timeout + survived) * 100`, rounded to 2 decimals. `stillborn` and `error` are excluded from the denominator. |
|
|
58
|
+
| `summary.quality_rating` | string | Human label derived from the score (Excellent/Good/Fair/Poor/Critical). |
|
|
59
|
+
| `summary.categories.killed` | integer | Mutants whose covering tests failed. |
|
|
60
|
+
| `summary.categories.survived` | integer | Mutants whose covering tests still passed (a test gap candidate). |
|
|
61
|
+
| `summary.categories.timeout` | integer | Mutants that exceeded the per-mutant deadline (counted as kills in the score). |
|
|
62
|
+
| `summary.categories.stillborn` | integer | Mutants whose code no longer parses. Never run; excluded from the score. |
|
|
63
|
+
| `summary.categories.error` | integer | Mutants that hit a runner-side error. Excluded from the score. |
|
|
64
|
+
| `mutations[].id` | integer | Stable id of the mutation within this run. |
|
|
65
|
+
| `mutations[].type` | string | Mutation category (e.g. `arithmetic`, `boolean`, `comparison`, `call_removal`, `nil_injection`, `argument`, `strict_equality` when the opt-in mode is enabled). |
|
|
66
|
+
| `mutations[].line` | integer | 1-based line number of the mutated code. |
|
|
67
|
+
| `mutations[].file_path` | string | Absolute path of the mutated source file. |
|
|
68
|
+
| `mutations[].original` | string | The original operator/literal that was mutated; for `call_removal`, the removed call expression (e.g. `items.uniq`); for `nil_injection`, the replaced expression (e.g. `self`); for `argument`, the full original call (e.g. `raise ArgumentError, msg`) or, for a default-value mutant, the original parameter (e.g. `opts = {}`). |
|
|
69
|
+
| `mutations[].mutated` | string | The replacement operator/literal; for `call_removal`, the bare receiver (e.g. `items`); for `nil_injection`, always `nil`; for `argument`, the call after the mutation (e.g. `raise ArgumentError`) or, for a default-value mutant, the parameter after the mutation (e.g. `opts` or `opts = nil`). |
|
|
70
|
+
| `mutations[].source_line` | string | The original source line. |
|
|
71
|
+
| `mutations[].mutated_line` | string | The source line after mutation. |
|
|
72
|
+
| `mutations[].killed` | boolean | Legacy passthrough flag from the runner (`true` for `killed` and `timeout`). Kept for backward compatibility; prefer `status`. |
|
|
73
|
+
| `mutations[].timeout` | boolean | Legacy passthrough flag from the runner (`true` only when the mutant timed out). Kept for backward compatibility; prefer `status`. |
|
|
74
|
+
| `mutations[].status` | string | One of the taxonomy statuses below. This is the authoritative per-mutant result. |
|
|
75
|
+
| `mutations[].description` | string | Human-readable description of the mutation (for `error`, the failure message). |
|
|
76
|
+
| `mutations[].diff` | string | Optional, additive (does not bump `schema_version`). Present only for `survived` and `timeout` mutants: a unified diff of the change with a few lines of surrounding context (`@@` hunk header, lines prefixed with `- `, `+ ` or two spaces). Falls back to a context-free `- `/`+ ` pair when the source file is not readable at report time. |
|
|
77
|
+
|
|
78
|
+
**Status taxonomy** (`mutations[].status`): `killed` (tests caught it), `survived`
|
|
79
|
+
(tests missed it), `timeout` (ran too long, scored as a kill), `stillborn`
|
|
80
|
+
(unparseable, excluded from the score), `error` (runner failure, excluded from
|
|
81
|
+
the score).
|
|
82
|
+
|
|
83
|
+
## Extracting the score and the survived mutants
|
|
84
|
+
|
|
85
|
+
A ready-to-use snippet for an agent or script: read the score and list every
|
|
86
|
+
surviving mutant with its location.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Overall score and per-status breakdown
|
|
90
|
+
bundle exec mutation_test --json examples/calculator.rb examples/calculator_spec.rb \
|
|
91
|
+
| jq '{score: .summary.mutation_score, categories: .summary.categories}'
|
|
92
|
+
|
|
93
|
+
# Just the survivors: file, line and what changed (the test gaps to close)
|
|
94
|
+
bundle exec mutation_test --json examples/calculator.rb examples/calculator_spec.rb \
|
|
95
|
+
| jq '[.mutations[] | select(.status == "survived")
|
|
96
|
+
| {file: .file_path, line: .line, from: .original, to: .mutated}]'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Multi-file runs: the aggregate envelope
|
|
100
|
+
|
|
101
|
+
With `--json` on a multi-file run (a `FILE` list with more than one file,
|
|
102
|
+
`--staged`, or `--glob`) stdout carries exactly one JSON document: an envelope
|
|
103
|
+
with a top-level `schema_version`, a run summary, a condensed `survivors` array,
|
|
104
|
+
and the full per-file reports. All diagnostics and progress stay on stderr, and
|
|
105
|
+
the exit code keeps its meaning (`0` passed, `1` failed).
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"schema_version": 1,
|
|
110
|
+
"summary": {
|
|
111
|
+
"files": 3,
|
|
112
|
+
"processed": 2,
|
|
113
|
+
"skipped": [
|
|
114
|
+
{
|
|
115
|
+
"file": "lib/orphan.rb",
|
|
116
|
+
"reason": "no matching spec file"
|
|
117
|
+
}
|
|
118
|
+
],
|
|
119
|
+
"score": 83.33,
|
|
120
|
+
"passed": false,
|
|
121
|
+
"interrupted": false
|
|
122
|
+
},
|
|
123
|
+
"survivors": [
|
|
124
|
+
{
|
|
125
|
+
"file": "/home/you/project/lib/calc.rb",
|
|
126
|
+
"line": 4,
|
|
127
|
+
"type": "arithmetic",
|
|
128
|
+
"original": "+",
|
|
129
|
+
"mutated": "-"
|
|
130
|
+
}
|
|
131
|
+
],
|
|
132
|
+
"files": [
|
|
133
|
+
{
|
|
134
|
+
"schema_version": 1,
|
|
135
|
+
"...": "one full per-file report per processed file"
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
| Field | Type | Description |
|
|
142
|
+
|------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
143
|
+
| `schema_version` | integer | Version of the envelope schema. Start value `1`. Distinct from the per-file `files[].schema_version`: the envelope and the per-file report version independently. |
|
|
144
|
+
| `summary.files` | integer | Files that entered the run: processed plus skipped. Files a `--since` filter left out as unchanged are not counted; they are reported on stderr. |
|
|
145
|
+
| `summary.processed` | integer | Files that were actually mutation-tested. |
|
|
146
|
+
| `summary.skipped` | array | One entry per skipped file: `file` (the path as given) and `reason`, one of `file not found`, `not a Ruby source file`, `a test file, not a mutable source`, `no matching spec file`. |
|
|
147
|
+
| `summary.score` | number | Aggregate mutation score over every mutant of every processed file, with the per-file formula: `(killed + timeout) / (killed + timeout + survived) * 100`, rounded to 2 decimals; `stillborn` and `error` are excluded from the denominator. |
|
|
148
|
+
| `summary.passed` | boolean | `true` exactly when the process exits `0`: the run matched/processed files and every processed file met the threshold. |
|
|
149
|
+
| `summary.interrupted` | boolean | `true` when `--fail-fast` stopped the batch at a surviving mutant, so remaining files were not run and the envelope is partial. |
|
|
150
|
+
| `survivors[].file` | string | Absolute path of the mutated source file, the same convention as `files[].metadata.source_file`, so a survivor joins its full per-file report by an exact string match on this value. |
|
|
151
|
+
| `survivors[].line` | integer | 1-based line number of the mutated code. |
|
|
152
|
+
| `survivors[].type` | string | Mutation category, same values as `mutations[].type`. |
|
|
153
|
+
| `survivors[].original` | string | The original code fragment. |
|
|
154
|
+
| `survivors[].mutated` | string | The fragment after the mutation. |
|
|
155
|
+
| `files[]` | object | One complete per-file report per processed file, exactly the single-file schema above: `schema_version`, `interrupted`, `metadata`, `summary`, and `mutations` with the five statuses `killed`, `survived`, `timeout`, `stillborn`, `error`. |
|
|
156
|
+
|
|
157
|
+
`survivors` is the agent-facing view: every mutant the tests failed to detect,
|
|
158
|
+
with just the fields needed to decide which test to add and where, so there is
|
|
159
|
+
no need to descend into `files[]` for the common loop.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# The test gaps to close, straight from the staging area
|
|
163
|
+
bundle exec mutation_test --staged --json | jq '.survivors'
|
|
164
|
+
|
|
165
|
+
# Join a survivor to its full per-file report: survivors[].file == files[].metadata.source_file
|
|
166
|
+
bundle exec mutation_test --staged --json \
|
|
167
|
+
| jq '.survivors[0] as $s | .files[] | select(.metadata.source_file == $s.file)'
|
|
168
|
+
|
|
169
|
+
# Gate a script on the aggregate outcome (exit code of jq -e follows .summary.passed)
|
|
170
|
+
bundle exec mutation_test --staged --json | jq -e '.summary.passed'
|
|
171
|
+
```
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Mutation Types
|
|
2
|
+
|
|
3
|
+
The full catalog of mutation families MutationTester generates. Ten families are
|
|
4
|
+
enabled by default; strict-equality is opt-in. See the
|
|
5
|
+
[Mutation types](../readme.md#mutation-types) section of the README for how to
|
|
6
|
+
enable or disable individual families via `config.mutation_types` or the
|
|
7
|
+
`--strict-equality` flag. The [Equivalent mutants](#equivalent-mutants) section
|
|
8
|
+
below covers how to interpret and suppress survivors.
|
|
9
|
+
|
|
10
|
+
## Arithmetic Mutations
|
|
11
|
+
|
|
12
|
+
- `+` → `-`, `*`, `/`
|
|
13
|
+
- `-` → `+`, `*`, `/`
|
|
14
|
+
- `*` → `+`, `-`, `/`
|
|
15
|
+
- `/` → `+`, `-`, `*`
|
|
16
|
+
- `%` → `+`, `-`, `*`
|
|
17
|
+
- `**` → `*`, `+` (only these two: `-`/`/` tend to produce hard-to-kill or `ZeroDivisionError` mutants)
|
|
18
|
+
- Compound assignment (`+=`, `-=`, `*=`, `/=`, `%=`, `**=`) mutates its operator the same way (e.g. `a **= b` →`a *= b`,
|
|
19
|
+
`a += b`).
|
|
20
|
+
|
|
21
|
+
## Bitwise Compound-Assignment Mutations
|
|
22
|
+
|
|
23
|
+
- `|=` → `&=`
|
|
24
|
+
- `&=` → `|=`
|
|
25
|
+
- `^=` → `|=`, `&=`
|
|
26
|
+
|
|
27
|
+
Plain bitwise sends (`a | b`, `a & b`, `a ^ b`), and the shift assignments `<<=` / `>>=`, are intentionally left alone:
|
|
28
|
+
mutating them mostly yields always-killed `NoMethodError` garbage rather than a meaningful test gap.
|
|
29
|
+
|
|
30
|
+
## Comparison Mutations
|
|
31
|
+
|
|
32
|
+
- `>` → `<`, `>=`, `<=`, `==`
|
|
33
|
+
- `<` → `>`, `>=`, `<=`, `==`
|
|
34
|
+
- `>=` → `<`, `>`, `<=`, `==`
|
|
35
|
+
- `<=` → `>`, `>=`, `<`, `==`
|
|
36
|
+
- `==` → `!=`, `>`, `<`
|
|
37
|
+
- `!=` → `==`, `>`, `<`
|
|
38
|
+
- `<=>` → `==`
|
|
39
|
+
|
|
40
|
+
## Strict Equality Mutations (opt-in)
|
|
41
|
+
|
|
42
|
+
- `==` → `eql?` (value equality without numeric type coercion: `1 == 1.0` is true, `1.eql?(1.0)` is false)
|
|
43
|
+
- `==` → `equal?` (object identity instead of value equality)
|
|
44
|
+
- **Off by default** and generated only when explicitly enabled via `config.mutation_types[:strict_equality] = true` or
|
|
45
|
+
the CLI flag `--strict-equality`. The default mutation set generates none of these probes, preserving its
|
|
46
|
+
zero-equivalent-mutant property.
|
|
47
|
+
- Purpose: code that is sensitive to numeric types or object identity (money amounts, identifiers, cache keys). A
|
|
48
|
+
surviving `eql?` probe means no test distinguishes Integer from Float inputs (e.g. no `divide(10.0, 0.0)` case); a
|
|
49
|
+
surviving `equal?` probe means no test distinguishes equal-value objects from the same object.
|
|
50
|
+
- **Warning: expect noise.** On code that intentionally does not distinguish numeric types or identity, these probes
|
|
51
|
+
survive as equivalent-in-practice mutants even against a complete test suite, lowering the score without pointing at a
|
|
52
|
+
real gap. Enable the mode deliberately for type-sensitive code, not in default CI runs; use the
|
|
53
|
+
`# mutation_tester:disable` line annotation for individual false positives.
|
|
54
|
+
- Applies to `==` sends with an explicit receiver and one operand. Reported with `type: strict_equality` (e.g.
|
|
55
|
+
`Change == to eql?`).
|
|
56
|
+
|
|
57
|
+
## Logical Mutations
|
|
58
|
+
|
|
59
|
+
- `&&` → `||`
|
|
60
|
+
- `||` → `&&`
|
|
61
|
+
- `||=` → `&&=`
|
|
62
|
+
- `&&=` → `||=`
|
|
63
|
+
- Operand removal: each `a && b` / `a || b` (and the keyword forms `a and b` / `a or b`) also yields two mutants
|
|
64
|
+
replacing the whole expression with just `a` and with just `b`. A flag whose branch the suite never exercises (say
|
|
65
|
+
`vip || total >= limit` with no VIP test) survives as an operand-removal mutant even when the `||` → `&&` swap is
|
|
66
|
+
killed.
|
|
67
|
+
- Chains like `a || b || c` are mutated per logical node, removing each operand separately (`a || b`, `c`, `a || c`,
|
|
68
|
+
`b || c`).
|
|
69
|
+
- Reported with `type: logical`; the description names the removed operand (e.g. `Remove operand @vip from ||`). A
|
|
70
|
+
removal candidate whose code would no longer parse is dropped at generation time.
|
|
71
|
+
|
|
72
|
+
## Boolean Mutations
|
|
73
|
+
|
|
74
|
+
- `true` → `false`
|
|
75
|
+
- `false` → `true`
|
|
76
|
+
|
|
77
|
+
## Number Mutations
|
|
78
|
+
|
|
79
|
+
- Any number → `0`, `1`, `number + 1`, `number - 1`
|
|
80
|
+
|
|
81
|
+
## String Mutations
|
|
82
|
+
|
|
83
|
+
- Any string → empty string `""`
|
|
84
|
+
|
|
85
|
+
## Conditional Mutations
|
|
86
|
+
|
|
87
|
+
- Negate conditions in `if` statements (also `unless`, ternary `?:`, and `elsif` branches)
|
|
88
|
+
- Negate the condition of `while` and `until` loops, including the modifier forms (`x while cond`, `x until cond`) and
|
|
89
|
+
post forms (`begin ... end while cond`)
|
|
90
|
+
- The whole condition is wrapped as `!(cond)`. A negated loop condition may never terminate; that infinite-loop mutant
|
|
91
|
+
is caught by the per-mutant timeout and counted as killed.
|
|
92
|
+
- `case`/`when` branch deletion: for each `when` clause of a `case`, one mutant removes that clause so its values fall
|
|
93
|
+
through to the next `when`/`else` (or return `nil`). A `case` with a single `when` is left alone (removing it would
|
|
94
|
+
not parse), and Ruby `case/in` pattern matching is not mutated. Literals inside a `when` (numbers, strings, range
|
|
95
|
+
bounds) are still mutated by their own types.
|
|
96
|
+
|
|
97
|
+
## Call Removal Mutations
|
|
98
|
+
|
|
99
|
+
- Removes a pure transforming call from a chain, replacing `recv.m` with `recv` (e.g. `items.uniq.sum` → `items.sum`). A
|
|
100
|
+
test suite that never exercises the transformation (say, no input with duplicates for `uniq`) lets this mutant
|
|
101
|
+
survive.
|
|
102
|
+
- Applies only to a curated whitelist of pure, no-argument transformations: `uniq`, `compact`, `sort`, `flatten`,
|
|
103
|
+
`strip`, `chomp`, `downcase`, `upcase`, `capitalize`, `reverse`, `round`, `floor`, `ceil`, `abs`, `to_a`. `freeze` and
|
|
104
|
+
`dup` are deliberately excluded: removing them is behaviorally equivalent under normal test observation (e.g. dropping
|
|
105
|
+
`.freeze` on a constant is unkillable black-box), and the default set keeps the zero-equivalent-mutants property.
|
|
106
|
+
- Calls with arguments or a block, calls without an explicit receiver, and safe-navigation calls (`recv&.m`) are never
|
|
107
|
+
removed. A removal candidate whose code would no longer parse is dropped at generation time.
|
|
108
|
+
- Reported with `type: call_removal`; `original` is the removed call expression (e.g. `items.uniq`) and `mutated` is the
|
|
109
|
+
bare receiver (e.g. `items`).
|
|
110
|
+
|
|
111
|
+
## Nil Injection Mutations
|
|
112
|
+
|
|
113
|
+
- Replaces the last expression of a method body (`def` and `def self.`) with `nil`, unless it is already a literal`nil`.
|
|
114
|
+
A single-expression method has its whole body replaced. The most common catch is a fluent API returning a final`self`:
|
|
115
|
+
a spec that only checks side effects lets the `self` → `nil` mutant survive.
|
|
116
|
+
- Replaces the right-hand side of an instance variable assignment (`@x = expr` → `@x = nil`), unless the assigned value
|
|
117
|
+
is already `nil`. Local variable assignments and `||=`/`&&=` memoization are intentionally left alone.
|
|
118
|
+
- Limitation: only the last expression of the method body is mutated. Expressions returned via an explicit `return` in
|
|
119
|
+
the middle of the method are not mutated in this iteration, and methods whose body ends in a `rescue`/`ensure` clause
|
|
120
|
+
are skipped.
|
|
121
|
+
- A candidate whose code would no longer parse is dropped at generation time.
|
|
122
|
+
- Reported with `type: nil_injection`; `original` is the replaced expression (e.g. `self`) and `mutated` is `nil`.
|
|
123
|
+
|
|
124
|
+
## Argument Mutations
|
|
125
|
+
|
|
126
|
+
- Removes the last argument of a regular method call: `m(a, b)` → `m(a)`, `m(a)` → `m()`, and the paren-free style is
|
|
127
|
+
preserved (`raise ArgumentError, msg` → `raise ArgumentError`, `raise msg` → `raise`). Only the last argument is
|
|
128
|
+
removed per mutant; a two-argument `raise` never degenerates to a bare `raise` in a single mutant.
|
|
129
|
+
- Substitutes `nil` for each argument separately (`Result.new(:ok, format_label(total))` → `Result.new(:ok, nil)`),
|
|
130
|
+
unless the argument is already the `nil` literal. A test that never verifies the effect of an argument (say, an
|
|
131
|
+
exception message asserted only by class, or a constructor field no spec reads) lets these mutants survive even when
|
|
132
|
+
the argument is not a literal.
|
|
133
|
+
- Exclusions: operator sends (`+`, `==`, `[]`, `[]=`, `<<`, setters, ...), require-like calls (`require`,
|
|
134
|
+
`require_relative`, `load`, `autoload`), block-pass arguments (`&blk`), splats (`*args`), double splats (`**opts`),
|
|
135
|
+
and safe-navigation calls are not mutated by this family. A candidate whose code would no longer parse is dropped at
|
|
136
|
+
generation time.
|
|
137
|
+
- Mutates parameter defaults in method definitions structurally, for any default expression (not only literals): removes
|
|
138
|
+
the default so the parameter becomes required (`def m(x, opts = {})` → `def m(x, opts)`, `def m(vip: false)` →
|
|
139
|
+
`def m(vip:)`) and replaces the default with `nil` (`opts = nil`), unless the default is already the `nil` literal. A
|
|
140
|
+
literal `false` default gets only the removal variant, never the `nil` variant: `false` and `nil` are
|
|
141
|
+
indistinguishable in boolean context, so `vip: nil` would survive as an equivalent mutant against any truth-testing
|
|
142
|
+
code. A surviving removal mutant means no test ever calls the method without that argument.
|
|
143
|
+
- Default-mutation exclusions: parameter names, splat/double-splat and block parameters are untouched. When an optional
|
|
144
|
+
positional parameter precedes a required one (`def m(a = 1, b)`), removing the default would silently change argument
|
|
145
|
+
binding, so only the `nil` variant is generated for those positional parameters; keyword defaults are unaffected by
|
|
146
|
+
ordering.
|
|
147
|
+
- Reported with `type: argument`; `original` is the full original call (e.g. `raise ArgumentError, msg`) and `mutated`is
|
|
148
|
+
the call after the mutation (e.g. `raise ArgumentError`). For a default-value mutant, `original` is the original
|
|
149
|
+
parameter (e.g. `opts = {}`) and `mutated` is the parameter after the mutation (e.g. `opts` or `opts = nil`).
|
|
150
|
+
|
|
151
|
+
## Equivalent mutants
|
|
152
|
+
|
|
153
|
+
A mutation score of 100% is not always achievable, and a surviving mutation is
|
|
154
|
+
not always a gap in your tests. Some mutations produce code that behaves
|
|
155
|
+
**identically** to the original for every possible input. These are called
|
|
156
|
+
*equivalent mutants*, and no test can ever kill them because there is no input
|
|
157
|
+
that makes them behave differently.
|
|
158
|
+
|
|
159
|
+
For example, in a `max` implementation:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
a > b ? a : b # original
|
|
163
|
+
a >= b ? a : b # mutant
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The two expressions only differ when `a == b`, and in that case both return the
|
|
167
|
+
same value (`a` equals `b`), so the observable result is the same for every
|
|
168
|
+
input. This mutant survives no matter how thorough your tests are.
|
|
169
|
+
|
|
170
|
+
Because equivalent mutants cannot be detected automatically in the general case
|
|
171
|
+
(it is an undecidable problem), treat surviving mutations as *candidates* to
|
|
172
|
+
review rather than guaranteed test gaps. When you determine a survivor is
|
|
173
|
+
equivalent, it is reasonable to accept a mutation score below 100%.
|
|
174
|
+
|
|
175
|
+
### Excluding a line with `# mutation_tester:disable`
|
|
176
|
+
|
|
177
|
+
Once you have confirmed that a survivor is equivalent, annotate its line so the
|
|
178
|
+
mutator stops generating mutants there. Add a trailing `# mutation_tester:disable`
|
|
179
|
+
comment (the same style as `# rubocop:disable`) to the line you want to skip:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
def max(a, b)
|
|
183
|
+
a > b ? a : b # mutation_tester:disable
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The mutator reads this marker from the source's comments and skips every mutation
|
|
188
|
+
whose location is on that line, across all mutation types (operator, number,
|
|
189
|
+
string, boolean, conditional, logical, call removal). Excluded lines never enter the mutation
|
|
190
|
+
score or the list of survived mutations, so an accepted equivalent mutant stops
|
|
191
|
+
deflating the score and cluttering the report on every run. The console summary
|
|
192
|
+
reports how many lines were excluded as a separate informational category:
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
Excluded: 1 line(s) (mutation_tester:disable) 🚫
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Notes and current limits:
|
|
199
|
+
|
|
200
|
+
- The annotation is honoured only inside a real comment, never inside a string
|
|
201
|
+
literal.
|
|
202
|
+
- Exclusion is per line: the marker skips only the line it sits on. Placing it on
|
|
203
|
+
its own line (above the code) is a no-op, because that line has nothing to
|
|
204
|
+
mutate.
|
|
205
|
+
- Block ranges (`disable`/`enable`), per-type exclusion
|
|
206
|
+
(`# mutation_tester:disable comparison`), and a global exclusion list in
|
|
207
|
+
configuration are not supported yet.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
class Calculator
|
|
2
|
+
def add(a, b)
|
|
3
|
+
a + b
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
def subtract(a, b)
|
|
7
|
+
a - b
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def multiply(a, b)
|
|
11
|
+
a * b
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def divide(a, b)
|
|
15
|
+
return 0 if b == 0
|
|
16
|
+
|
|
17
|
+
a / b
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def is_positive?(number)
|
|
21
|
+
number > 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def is_even?(number)
|
|
25
|
+
number % 2 == 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def max(a, b)
|
|
29
|
+
a > b ? a : b
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def absolute(number)
|
|
33
|
+
number < 0 ? -number : number
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
require_relative 'calculator'
|
|
2
|
+
|
|
3
|
+
RSpec.describe Calculator do
|
|
4
|
+
subject(:calculator) { described_class.new }
|
|
5
|
+
|
|
6
|
+
describe '#add' do
|
|
7
|
+
it 'adds two numbers' do
|
|
8
|
+
expect(calculator.add(2, 3)).to eq(5)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'adds negative numbers' do
|
|
12
|
+
expect(calculator.add(-2, -3)).to eq(-5)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe '#subtract' do
|
|
17
|
+
it 'subtracts two numbers' do
|
|
18
|
+
expect(calculator.subtract(5, 3)).to eq(2)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'handles negative results' do
|
|
22
|
+
expect(calculator.subtract(3, 5)).to eq(-2)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe '#multiply' do
|
|
27
|
+
it 'multiplies two numbers' do
|
|
28
|
+
expect(calculator.multiply(3, 4)).to eq(12)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'returns zero when multiplying by zero' do
|
|
32
|
+
expect(calculator.multiply(5, 0)).to eq(0)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe '#divide' do
|
|
37
|
+
context 'when divisor is zero' do
|
|
38
|
+
it 'returns 0' do
|
|
39
|
+
expect(calculator.divide(10, 0)).to eq(0)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
context 'when divisor is non-zero' do
|
|
44
|
+
it 'divides two numbers' do
|
|
45
|
+
expect(calculator.divide(10, 2)).to eq(5)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'performs integer division for integers' do
|
|
49
|
+
expect(calculator.divide(7, 2)).to eq(3)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe '#is_positive?' do
|
|
55
|
+
it 'returns true for positive integers' do
|
|
56
|
+
expect(calculator.is_positive?(5)).to be true
|
|
57
|
+
expect(calculator.is_positive?(1)).to be true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'returns true for fractional positives' do
|
|
61
|
+
expect(calculator.is_positive?(0.5)).to be true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'returns false for zero and negatives' do
|
|
65
|
+
expect(calculator.is_positive?(0)).to be false
|
|
66
|
+
expect(calculator.is_positive?(-0.1)).to be false
|
|
67
|
+
expect(calculator.is_positive?(-1)).to be false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
describe '#is_even?' do
|
|
72
|
+
it 'returns true for even numbers and zero' do
|
|
73
|
+
expect(calculator.is_even?(4)).to be true
|
|
74
|
+
expect(calculator.is_even?(0)).to be true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'returns false for odd numbers' do
|
|
78
|
+
expect(calculator.is_even?(3)).to be false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe '#max' do
|
|
83
|
+
it 'returns the larger number' do
|
|
84
|
+
expect(calculator.max(5, 3)).to eq(5)
|
|
85
|
+
expect(calculator.max(3, 5)).to eq(5)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'returns the second object when values are equal' do
|
|
89
|
+
a = String.new('same')
|
|
90
|
+
b = String.new('same')
|
|
91
|
+
expect(calculator.max(a, b)).to equal(b)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe '#absolute' do
|
|
96
|
+
it 'returns positive number unchanged' do
|
|
97
|
+
expect(calculator.absolute(5)).to eq(5)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'returns absolute value for negative numbers' do
|
|
101
|
+
expect(calculator.absolute(-5)).to eq(5)
|
|
102
|
+
expect(calculator.absolute(-1)).to eq(1)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'distinguishes negative zero and positive zero for floats' do
|
|
106
|
+
neg_zero = calculator.absolute(-0.0)
|
|
107
|
+
pos_zero = calculator.absolute(0.0)
|
|
108
|
+
|
|
109
|
+
expect(1.0 / neg_zero).to eq(-Float::INFINITY)
|
|
110
|
+
expect(1.0 / pos_zero).to eq(Float::INFINITY)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'returns zero for integer zero' do
|
|
114
|
+
expect(calculator.absolute(0)).to eq(0)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'handles fractional positives' do
|
|
118
|
+
expect(calculator.absolute(0.5)).to eq(0.5)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require 'minitest/autorun'
|
|
2
|
+
require_relative 'calculator'
|
|
3
|
+
|
|
4
|
+
class CalculatorTest < Minitest::Test
|
|
5
|
+
def setup
|
|
6
|
+
@calculator = Calculator.new
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def test_add
|
|
10
|
+
assert_equal 5, @calculator.add(2, 3)
|
|
11
|
+
assert_equal(-5, @calculator.add(-2, -3))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def test_subtract
|
|
15
|
+
assert_equal 2, @calculator.subtract(5, 3)
|
|
16
|
+
assert_equal(-2, @calculator.subtract(3, 5))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_multiply
|
|
20
|
+
assert_equal 12, @calculator.multiply(3, 4)
|
|
21
|
+
assert_equal 0, @calculator.multiply(5, 0)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_divide
|
|
25
|
+
assert_equal 5, @calculator.divide(10, 2)
|
|
26
|
+
assert_equal 0, @calculator.divide(10, 0)
|
|
27
|
+
assert_equal 3, @calculator.divide(7, 2)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_is_positive
|
|
31
|
+
assert @calculator.is_positive?(5)
|
|
32
|
+
refute(@calculator.is_positive?(-5))
|
|
33
|
+
refute(@calculator.is_positive?(0))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_is_even
|
|
37
|
+
assert @calculator.is_even?(4)
|
|
38
|
+
refute(@calculator.is_even?(3))
|
|
39
|
+
assert @calculator.is_even?(0)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_max
|
|
43
|
+
assert_equal 5, @calculator.max(5, 3)
|
|
44
|
+
assert_equal 5, @calculator.max(3, 5)
|
|
45
|
+
assert_equal 5, @calculator.max(5, 5)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_absolute
|
|
49
|
+
assert_equal 5, @calculator.absolute(5)
|
|
50
|
+
assert_equal 5, @calculator.absolute(-5)
|
|
51
|
+
assert_equal 0, @calculator.absolute(0)
|
|
52
|
+
end
|
|
53
|
+
end
|