mutation_tester 1.5.1 → 1.6.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 +4 -4
- data/CHANGELOG.md +8 -0
- data/Gemfile.lock +1 -1
- data/docs/ci.md +31 -0
- data/docs/execution-runners.md +23 -0
- data/docs/json-schema.md +44 -0
- data/examples/github_actions/redundant_tests.yml +77 -0
- data/exe/mutation_test +11 -1
- data/lib/mutation_tester/batch_runner.rb +3 -2
- data/lib/mutation_tester/configuration.rb +3 -1
- data/lib/mutation_tester/core.rb +37 -9
- data/lib/mutation_tester/fork_runner/worker.rb +3 -2
- data/lib/mutation_tester/fork_runner.rb +2 -2
- data/lib/mutation_tester/minitest_fail_fast.rb +3 -5
- data/lib/mutation_tester/minitest_load_hook.rb +35 -0
- data/lib/mutation_tester/mutation_runner.rb +50 -37
- data/lib/mutation_tester/progress_display.rb +134 -30
- data/lib/mutation_tester/reporters/base_reporter.rb +2 -1
- data/lib/mutation_tester/reporters/batch_json_reporter.rb +2 -1
- data/lib/mutation_tester/reporters/console_reporter.rb +1 -0
- data/lib/mutation_tester/reporters/json_reporter.rb +7 -0
- data/lib/mutation_tester/test_command.rb +60 -31
- data/lib/mutation_tester/test_recorder/minitest_hook.rb +55 -0
- data/lib/mutation_tester/test_recorder/rspec_hook.rb +62 -0
- data/lib/mutation_tester/test_recorder.rb +75 -0
- data/lib/mutation_tester/version.rb +1 -1
- data/lib/mutation_tester.rb +1 -0
- data/readme.md +87 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b33e53b5e67f12380151690883608e8bc76ab3915d4771baa6d5293e5cedd94c
|
|
4
|
+
data.tar.gz: 24fda20aa66c67f0a828c44fd3cd064a586c8ea40f6c9fe2f8263518ef6318be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 76f242526719139f3e78ba2a32e145671db00486f7a017d75011330c6c390c8b12e91ff142e779270d2f05711da36970747831cf1af9aadd06735f604010cfa8
|
|
7
|
+
data.tar.gz: 58d9d64c6344dfcee881455109b9638e9e380233a7e3a4be826879e14f8b4ca21de201f79978b9eb07fbcd0376a30b9e9b71cc440bb1c033fc92804062c6770a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.0] - 2026-09-19
|
|
4
|
+
|
|
5
|
+
- Added an opt-in kill matrix (`--kill-matrix`, `config.kill_matrix = true`) that records which tests kill each mutant, so the JSON report can be used to find redundant tests and not only missing ones. In this mode every mutant runs the full test file (no stop at the first failing test and no RSpec test selection), for RSpec and Minitest on all three runners, serial and parallel. The JSON report gains three additive fields, so `schema_version` stays at 1: `kill_matrix: true`, `tests[]` (every test of the baseline run with `id`, `name`, `line` and `status`) and `mutations[].killed_by` (the sorted ids of every test that failed under the mutant). An empty `killed_by` on a `killed` or `timeout` mutant means the killers are unknown (every timeout, and a mutated file that no longer loads), so a test must never be called redundant because of such a mutant. `--kill-matrix` together with `--fail-fast` is a usage error (exit code `2`), and a baseline that records no test aborts the run instead of reporting an empty matrix. The README has a new "Finding redundant tests" section with `jq` recipes, and `examples/github_actions/redundant_tests.yml` ships a scheduled audit workflow.
|
|
6
|
+
- The live progress line now shows the elapsed time, an estimate of the remaining time and running tallies of survived and timed out mutants (plus errored ones once any occur): `69.2% | 848/1226 processed | elapsed 37m 2s, remaining ~16m 32s | 170 survived, 0 timed out`. The estimate is extrapolated from real throughput measured from the first completed mutant, so worker boot and the shadow copy do not distort it and it holds for parallel runs; it is withheld until the sample is stable. On a terminal the line is fitted to the terminal width so it never wraps. The `| type | line N` suffix is gone (it was always empty in parallel runs) and the counter label is shortened to `processed`.
|
|
7
|
+
- Behavior change for Ruby API users: the progress callback of `MutationRunner#run` now fires for every completed mutant in parallel mode (it used to fire for every second one) and receives the result hash as a third argument. A plain two-argument block keeps working; a two-argument lambda or `method(:name)` passed with `&` now raises `ArgumentError` and needs a third parameter. `ProgressDisplay#update` now takes `(index, result = nil)` and `ProgressDisplay#current_mutation` is removed.
|
|
8
|
+
- Fixed a false 100% score on Minitest projects without a Gemfile whose test file pins `gem 'minitest', '~> 5.0'` while a newer Minitest is installed. The Minitest preloads and the fork worker loaded Minitest before the test file ran, which activated the newer version and made the pin raise `Gem::LoadError`: the baseline failed on the default runner, and under `--runner spawn` every mutant ended in that load error and counted as killed, so a test that asserts nothing scored 100%. Nothing loads Minitest before the test file does anymore.
|
|
9
|
+
- Fixed `format_duration` returning `1m 60s` for 119.6 seconds (it now rounds before splitting into units), and fixed the spinner thread staying alive after Ctrl-C, which printed the interruption message onto a half-drawn progress line.
|
|
10
|
+
|
|
3
11
|
## [1.5.1] - 2026-08-13
|
|
4
12
|
|
|
5
13
|
- Fixed load-time mutants losing per-worker database isolation in a parallel in-memory run with `--worker-env` and `--after-fork`. Mutants on load-time code (constants, class macros, `included do`) are decided file-based on purpose, but that file-based run acquires a fork-runner worker, and a worker booted inside a `Parallel` worker process inherited the parent process's value of the worker-env variable instead of the per-worker assignment: with 8 workers, every load-time mutant's test run hit the parent's database concurrently, races on shared rows failed tests unrelated to the mutant, and those failures were scored as kills. The same file could report 75% and 100% on consecutive runs with no error and no timeout in the output, while the spawn path and the pooled fork runner (which already carried the per-worker value) stayed stable. The fork execution path now passes the same per-worker env override the spawn path always used, and the worker applies it inside the forked test child before the spec loads, so every execution path resolves the same per-worker database. Reported in the field with exactly this signature: only class-macro mutants unstable, serial and fork-runner scores identical and stable.
|
data/Gemfile.lock
CHANGED
data/docs/ci.md
CHANGED
|
@@ -23,6 +23,9 @@ It also carries commented variants for parallel execution
|
|
|
23
23
|
(`MUTATION_TESTER_PARALLEL_PROCESSES`), for testing several file pairs, and for
|
|
24
24
|
an incremental pull-request gate (`--since`/`--fail-fast`).
|
|
25
25
|
|
|
26
|
+
Two more templates sit next to it: the [AI mutation gate](#ai-workflow-mutation-gate)
|
|
27
|
+
and the scheduled [redundant test audit](#redundant-test-audit-scheduled-job).
|
|
28
|
+
|
|
26
29
|
## GitHub Actions (minimal inline workflow)
|
|
27
30
|
|
|
28
31
|
The same thing, condensed to a copy-pasteable minimal workflow. It matches the
|
|
@@ -179,3 +182,31 @@ the template carries a commented step showing where to wire your agent CLI. Use
|
|
|
179
182
|
it alongside the plain [5-minute CI template](#mutation-testing-in-ci-in-5-minutes):
|
|
180
183
|
the AI gate adds the surviving-mutant worklist, the plain template is just the
|
|
181
184
|
pass/fail gate.
|
|
185
|
+
|
|
186
|
+
## Redundant test audit (scheduled job)
|
|
187
|
+
|
|
188
|
+
The opt-in kill matrix (`--kill-matrix`) records, for every mutant, the tests
|
|
189
|
+
that kill it, which makes it possible to list tests that add no protection. The
|
|
190
|
+
method, the `jq` recipes and their limits are described under
|
|
191
|
+
[Finding redundant tests](../readme.md#finding-redundant-tests) in the README.
|
|
192
|
+
|
|
193
|
+
The gem ships a ready-to-copy workflow at
|
|
194
|
+
[`examples/github_actions/redundant_tests.yml`](../examples/github_actions/redundant_tests.yml)
|
|
195
|
+
(installed with the gem, so you have it offline). Copy it to
|
|
196
|
+
`.github/workflows/redundant_tests.yml`, add the gem to your bundle, and edit the
|
|
197
|
+
`EDIT:` lines. It:
|
|
198
|
+
|
|
199
|
+
1. runs on a weekly schedule and on demand (`workflow_dispatch`), not on every
|
|
200
|
+
push: without the stop at the first failing test the run is slower than the
|
|
201
|
+
normal gate,
|
|
202
|
+
2. runs `mutation_test --kill-matrix --json --glob ...` with `--minimum-score 0`,
|
|
203
|
+
so the mutation score never fails the audit while a broken run (failing
|
|
204
|
+
baseline, no file measured) still does,
|
|
205
|
+
3. writes two lists to the GitHub job summary (`$GITHUB_STEP_SUMMARY`): tests
|
|
206
|
+
that kill no mutant, and tests whose every kill is shared with another test,
|
|
207
|
+
plus the number of mutants whose killers are unknown,
|
|
208
|
+
4. uploads `kill_matrix.json` as an artifact.
|
|
209
|
+
|
|
210
|
+
It is an audit, not a gate: the findings never fail the job. Treat the lists as
|
|
211
|
+
candidates to review and remove one test at a time, because two listed tests can
|
|
212
|
+
cover for each other.
|
data/docs/execution-runners.md
CHANGED
|
@@ -92,12 +92,32 @@ Every mutant run stops as soon as one test fails, on all three runners:
|
|
|
92
92
|
non-passing result. On `spawn` the file is preloaded with `ruby -r`, on the
|
|
93
93
|
preloaded runners the worker enables the same reporter per job.
|
|
94
94
|
|
|
95
|
+
Neither preload loads Minitest itself. Each one waits until the test file has
|
|
96
|
+
loaded Minitest and only then registers its plugin, and the fork worker sets its
|
|
97
|
+
autorun guard the same way. The test file therefore still chooses the Minitest
|
|
98
|
+
version: a project without a Gemfile that pins `gem 'minitest', '~> 5.0'` at the
|
|
99
|
+
top of its test file keeps working when a newer Minitest is installed. Loading
|
|
100
|
+
Minitest first would activate the newest installed version, the pin would then
|
|
101
|
+
raise `Gem::LoadError`, and every mutant run would end in that load error and be
|
|
102
|
+
counted as killed.
|
|
103
|
+
|
|
95
104
|
This cannot change a verdict. A run that stops early has already recorded a
|
|
96
105
|
failure, which is exactly what makes a mutant killed, and a run without a failure
|
|
97
106
|
is untouched and executes every test. Only the mutant runs opt in: the baseline
|
|
98
107
|
run and the shadow-workspace sanity check are expected to pass and always run the
|
|
99
108
|
whole file, so a failing baseline still reports every failure it finds.
|
|
100
109
|
|
|
110
|
+
The opt-in kill matrix (`--kill-matrix`, `config.kill_matrix = true`) turns the
|
|
111
|
+
early stop off for mutant runs too, on all three runners, because it has to
|
|
112
|
+
record every failing test of every mutant. A small recorder is loaded next to the
|
|
113
|
+
test file (`--require` for RSpec and `ruby -r` for Minitest on `spawn`, at worker
|
|
114
|
+
boot on the preloaded runners) and appends the failing test ids to a per-run
|
|
115
|
+
temporary file named through the environment. The ids deliberately do not travel
|
|
116
|
+
through the fork worker's result pipe: the parent reads that pipe only after the
|
|
117
|
+
child has finished, so a list larger than the pipe buffer would block the child
|
|
118
|
+
until the deadline and turn a kill into a timeout. See
|
|
119
|
+
[Finding redundant tests](../readme.md#finding-redundant-tests).
|
|
120
|
+
|
|
101
121
|
The pathological case it removes is a mutant that breaks something every test
|
|
102
122
|
touches (a class body that no longer loads, a constant every test reads). Such a
|
|
103
123
|
mutant used to re-raise the same error once per test, which on a large test file
|
|
@@ -200,6 +220,9 @@ runner (the default path) performs no selection at all: every mutant runs the
|
|
|
200
220
|
full preloaded example set, which is why its report omits the `Selection:`
|
|
201
221
|
summary line (see the in-memory limitations above).
|
|
202
222
|
|
|
223
|
+
The kill matrix mode (`--kill-matrix`) turns selection off for the run, because a
|
|
224
|
+
subset run would under-report the tests that kill a mutant.
|
|
225
|
+
|
|
203
226
|
Disable it with the `--no-test-selection` CLI flag or in Ruby:
|
|
204
227
|
|
|
205
228
|
```ruby
|
data/docs/json-schema.md
CHANGED
|
@@ -73,6 +73,9 @@ history of changes.
|
|
|
73
73
|
| `mutations[].timeout` | boolean | Legacy passthrough flag from the runner (`true` only when the mutant timed out). Kept for backward compatibility; prefer `status`. |
|
|
74
74
|
| `mutations[].status` | string | One of the taxonomy statuses below. This is the authoritative per-mutant result. |
|
|
75
75
|
| `mutations[].description` | string | Human-readable description of the mutation (for `error`, the failure message). |
|
|
76
|
+
| `kill_matrix` | boolean | Optional, additive (does not bump `schema_version`). Present and `true` only for a run with the opt-in `--kill-matrix` mode, which guarantees that every `mutations[].killed_by` list is complete. Absent otherwise. See [Kill matrix fields](#kill-matrix-fields-opt-in). |
|
|
77
|
+
| `tests[]` | array | Optional, additive. Present only with `--kill-matrix`: every test of the unmutated baseline run, ordered by line. See [Kill matrix fields](#kill-matrix-fields-opt-in). |
|
|
78
|
+
| `mutations[].killed_by` | array | Optional, additive. Present only with `--kill-matrix`, on every mutant: the sorted `tests[].id` values of every test that failed under this mutant. See [Kill matrix fields](#kill-matrix-fields-opt-in). |
|
|
76
79
|
| `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
80
|
|
|
78
81
|
**Status taxonomy** (`mutations[].status`): `killed` (tests caught it), `survived`
|
|
@@ -80,6 +83,47 @@ history of changes.
|
|
|
80
83
|
(unparseable, excluded from the score), `error` (runner failure, excluded from
|
|
81
84
|
the score).
|
|
82
85
|
|
|
86
|
+
## Kill matrix fields (opt-in)
|
|
87
|
+
|
|
88
|
+
A normal run stops every mutant at its first failing test, so it cannot say which
|
|
89
|
+
tests kill a mutant and emits none of the fields below. With `--kill-matrix`
|
|
90
|
+
(`config.kill_matrix = true`) every mutant runs the full test file, on every
|
|
91
|
+
runner and for both RSpec and Minitest, and the report gains:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"schema_version": 1,
|
|
96
|
+
"interrupted": false,
|
|
97
|
+
"kill_matrix": true,
|
|
98
|
+
"tests": [
|
|
99
|
+
{ "id": "InvoiceTest#test_rejects_negative_total", "name": "test_rejects_negative_total", "line": 12, "status": "passed" },
|
|
100
|
+
{ "id": "InvoiceTest#test_total_sums_lines", "name": "test_total_sums_lines", "line": 20, "status": "passed" }
|
|
101
|
+
],
|
|
102
|
+
"mutations": [
|
|
103
|
+
{
|
|
104
|
+
"id": 12,
|
|
105
|
+
"status": "killed",
|
|
106
|
+
"line": 41,
|
|
107
|
+
"killed_by": ["InvoiceTest#test_rejects_negative_total", "InvoiceTest#test_total_sums_lines"]
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| Field | Type | Description |
|
|
114
|
+
|-------------------------|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
115
|
+
| `tests[].id` | string | Stable id of the test within the run. Minitest: `TestClass#test_name`. RSpec: the spec path relative to the project root plus the scoped example id, e.g. `spec/invoice_spec.rb[1:2:1]`, which `rspec` accepts as an argument from the project root. RSpec older than 3.3 has no scoped ids, so there the id is the file and line, e.g. `spec/invoice_spec.rb:12`. |
|
|
116
|
+
| `tests[].name` | string | Human-readable name: the full example description for RSpec, the test method name for Minitest. |
|
|
117
|
+
| `tests[].line` | integer or null | 1-based line where the test is defined in the test file. `null` when the framework does not report it or the example is defined in another file (RSpec shared examples). |
|
|
118
|
+
| `tests[].status` | string | `passed` or `skipped` (RSpec `pending`/`skip`/`xit`, Minitest `skip`) in the baseline run. The baseline must pass, so there is no `failed`. |
|
|
119
|
+
| `mutations[].killed_by` | array of string | Sorted ids of every test that failed under the mutant. Always `[]` for `survived`, `stillborn`, `error` and `timeout`. Also `[]` for a `killed` mutant whose run failed without any test failing (typically the mutated file no longer loads, or an error outside a test). |
|
|
120
|
+
|
|
121
|
+
An empty `killed_by` on a `killed` or `timeout` mutant means the killers are
|
|
122
|
+
**unknown**, not that there are none: a consumer must never call a test redundant
|
|
123
|
+
because of such a mutant. In the multi-file envelope the fields appear inside each
|
|
124
|
+
`files[]` report. The redundancy recipes live under
|
|
125
|
+
[Finding redundant tests](../readme.md#finding-redundant-tests) in the README.
|
|
126
|
+
|
|
83
127
|
## Extracting the score and the survived mutants
|
|
84
128
|
|
|
85
129
|
A ready-to-use snippet for an agent or script: read the score and list every
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Redundant-test audit for GitHub Actions (mutation_tester gem). Runs
|
|
2
|
+
# mutation_test --kill-matrix, which records for every mutant the tests that
|
|
3
|
+
# kill it, and publishes two lists of candidates to the job summary: tests that
|
|
4
|
+
# kill no mutant, and tests whose every kill is shared with another test. This
|
|
5
|
+
# is an AUDIT, not a gate: it runs on a schedule and never fails the job because
|
|
6
|
+
# of what it finds. Copy to .github/workflows/, edit the "EDIT:" lines. Needs jq
|
|
7
|
+
# (preinstalled on ubuntu runners); see mutation_test.yml for the quality gate.
|
|
8
|
+
#
|
|
9
|
+
# Read the lists as candidates to review. Remove one test at a time and re-run:
|
|
10
|
+
# two tests can cover for each other, so deleting both may let mutants survive.
|
|
11
|
+
|
|
12
|
+
name: Redundant Test Audit
|
|
13
|
+
|
|
14
|
+
on:
|
|
15
|
+
schedule:
|
|
16
|
+
- cron: '0 4 * * 1' # EDIT: weekly, Monday 04:00 UTC
|
|
17
|
+
workflow_dispatch:
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
redundant_tests:
|
|
21
|
+
name: Redundant test audit
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
# The kill matrix runs every mutant against the full test file without
|
|
24
|
+
# stopping at the first failure, so it is slower than the normal gate.
|
|
25
|
+
timeout-minutes: 60
|
|
26
|
+
steps:
|
|
27
|
+
- name: Checkout
|
|
28
|
+
uses: actions/checkout@v5
|
|
29
|
+
|
|
30
|
+
- name: Set up Ruby
|
|
31
|
+
uses: ruby/setup-ruby@v1
|
|
32
|
+
with:
|
|
33
|
+
ruby-version: '3.3' # EDIT: match your project's Ruby (floor is 3.0)
|
|
34
|
+
bundler-cache: true
|
|
35
|
+
|
|
36
|
+
- name: Run mutation tests with the kill matrix (JSON report on stdout)
|
|
37
|
+
run: |
|
|
38
|
+
# EDIT: select YOUR sources; add --spec-glob 'test/{name}_test.rb' for Minitest.
|
|
39
|
+
# --minimum-score 0 keeps the score from failing this audit; a broken run
|
|
40
|
+
# (failing baseline, no file measured) still fails the step.
|
|
41
|
+
bundle exec mutation_test --kill-matrix --json --minimum-score 0 \
|
|
42
|
+
--timeout-factor 10 --glob 'lib/**/*.rb' > kill_matrix.json
|
|
43
|
+
|
|
44
|
+
- name: List redundancy candidates in the job summary
|
|
45
|
+
run: |
|
|
46
|
+
kill_nothing=$(jq -r '(.files // [.])[]
|
|
47
|
+
| (.metadata.spec_file | ltrimstr("\($ENV.PWD)/")) as $spec
|
|
48
|
+
| [.mutations[].killed_by[]] as $killers
|
|
49
|
+
| .tests[] | select(.status == "passed" and (.id | IN($killers[]) | not))
|
|
50
|
+
| "- `\(.id)` \(.name) (\($spec))"' kill_matrix.json)
|
|
51
|
+
shared_only=$(jq -r '(.files // [.])[]
|
|
52
|
+
| (.metadata.spec_file | ltrimstr("\($ENV.PWD)/")) as $spec
|
|
53
|
+
| [.mutations[] | select(.status == "killed") | .killed_by] as $kills
|
|
54
|
+
| .tests[] | .id as $id
|
|
55
|
+
| [$kills[] | select(index($id))] as $mine
|
|
56
|
+
| select(($mine | length) > 0 and all($mine[]; length > 1))
|
|
57
|
+
| "- `\(.id)` \(.name) (\($spec))"' kill_matrix.json)
|
|
58
|
+
unknown=$(jq '[(.files // [.])[] | .mutations[]
|
|
59
|
+
| select((.status == "killed" or .status == "timeout") and (.killed_by | length) == 0)]
|
|
60
|
+
| length' kill_matrix.json)
|
|
61
|
+
{
|
|
62
|
+
echo "## Redundant test audit"
|
|
63
|
+
echo "### Tests that kill no mutant"
|
|
64
|
+
echo "${kill_nothing:-None.}"
|
|
65
|
+
echo "### Tests whose every kill is shared with another test"
|
|
66
|
+
echo "${shared_only:-None.}"
|
|
67
|
+
echo
|
|
68
|
+
echo "Mutants with unknown killers (timeouts, load failures): **${unknown}**."
|
|
69
|
+
echo "Remove one test at a time and re-run: two listed tests can cover for each other."
|
|
70
|
+
} >> "$GITHUB_STEP_SUMMARY"
|
|
71
|
+
|
|
72
|
+
- name: Upload the kill matrix report
|
|
73
|
+
if: always()
|
|
74
|
+
uses: actions/upload-artifact@v4
|
|
75
|
+
with:
|
|
76
|
+
name: kill-matrix
|
|
77
|
+
path: kill_matrix.json
|
data/exe/mutation_test
CHANGED
|
@@ -76,6 +76,10 @@ OptionParser.new do |opts|
|
|
|
76
76
|
options[:fail_fast] = true
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
opts.on('--kill-matrix', 'Audit mode: run every mutant against the full test file without stopping at its first failing test and without test selection, and record in the JSON report which tests kill each mutant (mutations[].killed_by) plus the list of tests that ran (tests[]). Slower than a normal run; cannot be combined with --fail-fast') do
|
|
80
|
+
options[:kill_matrix] = true
|
|
81
|
+
end
|
|
82
|
+
|
|
79
83
|
opts.on('--timeout-factor N', Float, "Per-mutant timeout budget as N times the measured baseline test run, never below #{MutationTester::Configuration::CALIBRATED_TIMEOUT_FLOOR}s (default: #{MutationTester::Configuration::DEFAULT_TIMEOUT_FACTOR}; ignored when config.timeout is set explicitly, which keeps a fixed budget)") do |n|
|
|
80
84
|
options[:timeout_factor] = n
|
|
81
85
|
end
|
|
@@ -184,6 +188,7 @@ apply_configuration = lambda do
|
|
|
184
188
|
config.worker_env_var = options[:worker_env] if options.key?(:worker_env)
|
|
185
189
|
config.after_fork_file = options[:after_fork] if options.key?(:after_fork)
|
|
186
190
|
config.fail_fast = options.fetch(:fail_fast, false)
|
|
191
|
+
config.kill_matrix = true if options[:kill_matrix]
|
|
187
192
|
config.mutation_types[:strict_equality] = true if options[:strict_equality]
|
|
188
193
|
|
|
189
194
|
if json_mode
|
|
@@ -208,6 +213,11 @@ run_batch_machine = lambda do |batch, passed_for|
|
|
|
208
213
|
exit(passed ? 0 : 1)
|
|
209
214
|
end
|
|
210
215
|
|
|
216
|
+
if options[:kill_matrix] && options[:fail_fast]
|
|
217
|
+
usage_error.call('--kill-matrix cannot be combined with --fail-fast.',
|
|
218
|
+
'--fail-fast stops the run at the first surviving mutant, which would leave the kill matrix incomplete; drop one of the two flags.')
|
|
219
|
+
end
|
|
220
|
+
|
|
211
221
|
if options[:since] && !glob_mode
|
|
212
222
|
usage_error.call('--since requires --glob (batch mode).',
|
|
213
223
|
'Usage: mutation_test --glob "lib/**/*.rb" --since origin/main')
|
|
@@ -360,7 +370,7 @@ if json_mode
|
|
|
360
370
|
end
|
|
361
371
|
|
|
362
372
|
reporter = MutationTester::Reporters::JsonReporter.new(
|
|
363
|
-
core.results, core.source_file, core.spec_file, core.config, interrupted: core.interrupted
|
|
373
|
+
core.results, core.source_file, core.spec_file, core.config, interrupted: core.interrupted?, tests: core.tests
|
|
364
374
|
)
|
|
365
375
|
|
|
366
376
|
unless core.results.empty?
|
|
@@ -22,7 +22,7 @@ module MutationTester
|
|
|
22
22
|
|
|
23
23
|
SKIP_ORDER = %i[missing not_ruby test_file no_spec].freeze
|
|
24
24
|
|
|
25
|
-
ProcessedEntry = Struct.new(:source_file, :spec_file, :score, :passed, :output_dir, :results, :interrupted, :degraded, keyword_init: true) do
|
|
25
|
+
ProcessedEntry = Struct.new(:source_file, :spec_file, :score, :passed, :output_dir, :results, :interrupted, :degraded, :tests, keyword_init: true) do
|
|
26
26
|
def passed?
|
|
27
27
|
passed
|
|
28
28
|
end
|
|
@@ -230,7 +230,8 @@ module MutationTester
|
|
|
230
230
|
output_dir: file_config.output_dir,
|
|
231
231
|
results: core.results,
|
|
232
232
|
interrupted: core.interrupted?,
|
|
233
|
-
degraded: core.infrastructure_failure
|
|
233
|
+
degraded: core.infrastructure_failure?,
|
|
234
|
+
tests: core.tests
|
|
234
235
|
)
|
|
235
236
|
[entry, core.stopped_on_survivor?]
|
|
236
237
|
end
|
|
@@ -33,7 +33,8 @@ module MutationTester
|
|
|
33
33
|
:show_file_path,
|
|
34
34
|
:show_progress,
|
|
35
35
|
:test_selection,
|
|
36
|
-
:fail_fast
|
|
36
|
+
:fail_fast,
|
|
37
|
+
:kill_matrix
|
|
37
38
|
|
|
38
39
|
def initialize
|
|
39
40
|
self.parallel_processes = ENV['MUTATION_TESTER_PARALLEL_PROCESSES'] || self.class.auto_parallel_processes
|
|
@@ -66,6 +67,7 @@ module MutationTester
|
|
|
66
67
|
@show_progress = true
|
|
67
68
|
@test_selection = true
|
|
68
69
|
@fail_fast = false
|
|
70
|
+
@kill_matrix = false
|
|
69
71
|
end
|
|
70
72
|
|
|
71
73
|
def merge(options)
|
data/lib/mutation_tester/core.rb
CHANGED
|
@@ -2,7 +2,7 @@ require_relative 'progress_display'
|
|
|
2
2
|
|
|
3
3
|
module MutationTester
|
|
4
4
|
class Core
|
|
5
|
-
attr_reader :source_file, :spec_file, :mutations, :results, :config
|
|
5
|
+
attr_reader :source_file, :spec_file, :mutations, :results, :config, :tests
|
|
6
6
|
|
|
7
7
|
def initialize(source_file, spec_file, config = MutationTester.configuration)
|
|
8
8
|
@source_file = File.expand_path(source_file)
|
|
@@ -10,6 +10,7 @@ module MutationTester
|
|
|
10
10
|
@config = config
|
|
11
11
|
@mutations = []
|
|
12
12
|
@results = []
|
|
13
|
+
@tests = []
|
|
13
14
|
@parse_failed = false
|
|
14
15
|
@shadow_aborted = false
|
|
15
16
|
MutationRunner.recover_in_place_backup(@source_file)
|
|
@@ -18,6 +19,7 @@ module MutationTester
|
|
|
18
19
|
|
|
19
20
|
def run
|
|
20
21
|
print_header
|
|
22
|
+
return report_conflicting_modes if @config.kill_matrix && @config.fail_fast
|
|
21
23
|
return false unless run_original_tests
|
|
22
24
|
|
|
23
25
|
generate_mutations
|
|
@@ -98,14 +100,35 @@ module MutationTester
|
|
|
98
100
|
return false
|
|
99
101
|
end
|
|
100
102
|
puts Rainbow('✓ Original tests passed').green
|
|
103
|
+
@tests = recorded_tests(result.tests)
|
|
104
|
+
return report_unrecorded_baseline if @config.kill_matrix && @tests.empty?
|
|
105
|
+
|
|
101
106
|
@config.baseline_duration = baseline_elapsed
|
|
102
107
|
true
|
|
103
108
|
end
|
|
104
109
|
|
|
110
|
+
def report_conflicting_modes
|
|
111
|
+
puts Rainbow('❌ kill_matrix cannot be combined with fail_fast.').red
|
|
112
|
+
puts Rainbow(' fail_fast stops the run at the first surviving mutant, which would leave the kill matrix incomplete.').red
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def report_unrecorded_baseline
|
|
117
|
+
puts Rainbow('❌ The kill matrix could not record a single test of the passing baseline run.').red
|
|
118
|
+
puts Rainbow(' Without the list of tests it cannot tell which tests kill a mutant, so the run is aborted instead of').red
|
|
119
|
+
puts Rainbow(' reporting an empty matrix. Usual causes: the test file defines no tests, or a plugin or hook replaces').red
|
|
120
|
+
puts Rainbow(' the test framework reporters or ends the process before the results are written.').red
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
|
|
105
124
|
def monotonic_time
|
|
106
125
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
107
126
|
end
|
|
108
127
|
|
|
128
|
+
def recorded_tests(entries)
|
|
129
|
+
Array(entries).uniq { |entry| entry[:id] }.sort_by { |entry| [entry[:line].to_i, entry[:id]] }
|
|
130
|
+
end
|
|
131
|
+
|
|
109
132
|
def replay_baseline_output(output)
|
|
110
133
|
return if output.nil? || output.strip.empty?
|
|
111
134
|
|
|
@@ -161,11 +184,14 @@ module MutationTester
|
|
|
161
184
|
|
|
162
185
|
progress_display = ProgressDisplay.new(@mutations.size, @config)
|
|
163
186
|
|
|
164
|
-
|
|
165
|
-
|
|
187
|
+
begin
|
|
188
|
+
@results = mutation_runner.run(@mutations) do |_mutation, index, result|
|
|
189
|
+
progress_display.update(index, result)
|
|
190
|
+
end
|
|
191
|
+
progress_display.finish
|
|
192
|
+
ensure
|
|
193
|
+
progress_display.stop
|
|
166
194
|
end
|
|
167
|
-
|
|
168
|
-
progress_display.finish
|
|
169
195
|
puts Rainbow("✓ Completed #{@results.size} mutations").green
|
|
170
196
|
end
|
|
171
197
|
|
|
@@ -186,11 +212,11 @@ module MutationTester
|
|
|
186
212
|
def create_reporter(type)
|
|
187
213
|
case type
|
|
188
214
|
when :console
|
|
189
|
-
Reporters::ConsoleReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted
|
|
215
|
+
Reporters::ConsoleReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?, tests: @tests)
|
|
190
216
|
when :html
|
|
191
|
-
Reporters::HtmlReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted
|
|
217
|
+
Reporters::HtmlReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?, tests: @tests)
|
|
192
218
|
when :json
|
|
193
|
-
Reporters::JsonReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted
|
|
219
|
+
Reporters::JsonReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?, tests: @tests)
|
|
194
220
|
end
|
|
195
221
|
end
|
|
196
222
|
|
|
@@ -198,7 +224,9 @@ module MutationTester
|
|
|
198
224
|
TestCommand.new(
|
|
199
225
|
@spec_file,
|
|
200
226
|
use_bundle_exec: TestCommand.use_bundle_exec?(@source_file),
|
|
201
|
-
runner: @config.runner
|
|
227
|
+
runner: @config.runner,
|
|
228
|
+
record: @config.kill_matrix ? :all : nil,
|
|
229
|
+
record_root: @config.kill_matrix ? mutation_runner.recording_root : nil
|
|
202
230
|
)
|
|
203
231
|
end
|
|
204
232
|
|
|
@@ -10,11 +10,12 @@ STDOUT.reopen(File::NULL)
|
|
|
10
10
|
framework = ARGV.shift == 'minitest' ? :minitest : :rspec
|
|
11
11
|
|
|
12
12
|
if framework == :minitest
|
|
13
|
-
require 'minitest'
|
|
14
13
|
require_relative '../minitest_fail_fast'
|
|
15
|
-
|
|
14
|
+
require_relative '../test_recorder/minitest_hook'
|
|
15
|
+
MutationTester::MinitestLoadHook.on_load { Minitest.class_variable_set(:@@installed_at_exit, true) }
|
|
16
16
|
else
|
|
17
17
|
require 'rspec/core'
|
|
18
|
+
require_relative '../test_recorder/rspec_hook'
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
kill_group = lambda do |pid|
|
|
@@ -190,8 +190,8 @@ module MutationTester
|
|
|
190
190
|
[false, 'the fork runner worker terminated unexpectedly']
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
-
def execute_in_memory(source:, path:, timeout: nil, chdir: nil)
|
|
194
|
-
job = { in_memory: { source: source, path: path }, timeout: timeout, chdir: chdir }
|
|
193
|
+
def execute_in_memory(source:, path:, timeout: nil, chdir: nil, env: nil)
|
|
194
|
+
job = { in_memory: { source: source, path: path }, timeout: timeout, chdir: chdir, env: env }
|
|
195
195
|
@job_writer.puts(JSON.generate(job))
|
|
196
196
|
event = await_result(timeout)
|
|
197
197
|
InMemoryOutcome.new(event['status'], event['message'])
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'minitest_load_hook'
|
|
4
|
+
|
|
3
5
|
module MutationTester
|
|
4
6
|
module MinitestFailFast
|
|
5
7
|
PLUGIN_NAME = :mutation_tester_fail_fast
|
|
@@ -12,11 +14,7 @@ module MutationTester
|
|
|
12
14
|
end
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
require 'minitest'
|
|
17
|
-
rescue LoadError
|
|
18
|
-
Kernel.warn '[MutationTester] minitest is not loadable here; mutant runs will execute every test instead of stopping at the first failure.'
|
|
19
|
-
else
|
|
17
|
+
MutationTester::MinitestLoadHook.on_load do
|
|
20
18
|
module MutationTester
|
|
21
19
|
module MinitestFailFast
|
|
22
20
|
class Reporter < Minitest::AbstractReporter
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutationTester
|
|
4
|
+
module MinitestLoadHook
|
|
5
|
+
class << self
|
|
6
|
+
def on_load(&block)
|
|
7
|
+
return block.call if loaded?
|
|
8
|
+
|
|
9
|
+
callbacks << block
|
|
10
|
+
trace.enable unless trace.enabled?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def loaded?
|
|
16
|
+
return false unless defined?(::Minitest::AbstractReporter)
|
|
17
|
+
|
|
18
|
+
::Minitest.respond_to?(:register_plugin) && ::Minitest.respond_to?(:extensions) && !::Minitest.extensions.nil?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def callbacks
|
|
22
|
+
@callbacks ||= []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def trace
|
|
26
|
+
@trace ||= TracePoint.new(:end) do |point|
|
|
27
|
+
next unless defined?(::Minitest) && point.self.equal?(::Minitest) && loaded?
|
|
28
|
+
|
|
29
|
+
@trace.disable
|
|
30
|
+
callbacks.shift.call until callbacks.empty?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|