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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../test_recorder'
|
|
4
|
+
|
|
5
|
+
module MutationTester
|
|
6
|
+
module TestRecorder
|
|
7
|
+
module RSpecHook
|
|
8
|
+
STATUSES = { passed: 'passed', failed: 'failed', pending: 'skipped' }.freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def install
|
|
12
|
+
return if @installed
|
|
13
|
+
|
|
14
|
+
@installed = true
|
|
15
|
+
::RSpec.configure do |config|
|
|
16
|
+
config.after(:suite) { MutationTester::TestRecorder::RSpecHook.flush }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def flush
|
|
21
|
+
return unless TestRecorder.active?
|
|
22
|
+
|
|
23
|
+
TestRecorder.write(all_examples.filter_map { |example| entry_for(example) })
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
Kernel.warn "[MutationTester] The kill matrix could not record the rspec results: #{e.class}: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def all_examples
|
|
31
|
+
::RSpec.world.example_groups.flat_map(&:descendants).flat_map(&:examples)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def entry_for(example)
|
|
35
|
+
status = STATUSES[example.execution_result.status]
|
|
36
|
+
return nil unless status
|
|
37
|
+
|
|
38
|
+
metadata = example.metadata
|
|
39
|
+
{
|
|
40
|
+
id: id_for(metadata),
|
|
41
|
+
name: example.full_description,
|
|
42
|
+
line: metadata[:file_path] == rerun_path(metadata) ? metadata[:line_number] : nil,
|
|
43
|
+
status: status
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def id_for(metadata)
|
|
48
|
+
path = TestRecorder.relative_to_root(File.expand_path(rerun_path(metadata)))
|
|
49
|
+
return "#{path}[#{metadata[:scoped_id]}]" if metadata[:scoped_id]
|
|
50
|
+
|
|
51
|
+
"#{path}:#{metadata[:line_number]}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rerun_path(metadata)
|
|
55
|
+
metadata[:rerun_file_path] || metadata[:file_path]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
MutationTester::TestRecorder::RSpecHook.install
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
|
|
6
|
+
module MutationTester
|
|
7
|
+
module TestRecorder
|
|
8
|
+
LOG_ENV = 'MUTATION_TESTER_TEST_LOG'
|
|
9
|
+
SCOPE_ENV = 'MUTATION_TESTER_TEST_LOG_SCOPE'
|
|
10
|
+
ROOT_ENV = 'MUTATION_TESTER_TEST_LOG_ROOT'
|
|
11
|
+
FAILED = 'failed'
|
|
12
|
+
RSPEC_HOOK_PATH = File.expand_path('test_recorder/rspec_hook.rb', __dir__).freeze
|
|
13
|
+
MINITEST_HOOK_PATH = File.expand_path('test_recorder/minitest_hook.rb', __dir__).freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def capture(scope:, root:)
|
|
17
|
+
log = Tempfile.new(['mutation_tester_tests', '.jsonl'])
|
|
18
|
+
log.close
|
|
19
|
+
outcome = yield(LOG_ENV => log.path, SCOPE_ENV => scope.to_s, ROOT_ENV => root.to_s)
|
|
20
|
+
[outcome, read(log.path)]
|
|
21
|
+
ensure
|
|
22
|
+
log&.unlink
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def read(path)
|
|
26
|
+
return [] unless File.exist?(path)
|
|
27
|
+
|
|
28
|
+
File.readlines(path).filter_map do |line|
|
|
29
|
+
JSON.parse(line, symbolize_names: true)
|
|
30
|
+
rescue JSON::ParserError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def failed_ids(entries)
|
|
36
|
+
Array(entries).select { |entry| entry[:status] == FAILED }.map { |entry| entry[:id] }.uniq.sort
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def active?
|
|
40
|
+
!ENV[LOG_ENV].to_s.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def write(entries)
|
|
44
|
+
return unless active?
|
|
45
|
+
|
|
46
|
+
kept = ENV[SCOPE_ENV] == 'failures' ? entries.select { |entry| entry[:status] == FAILED } : entries
|
|
47
|
+
return if kept.empty?
|
|
48
|
+
|
|
49
|
+
File.open(ENV[LOG_ENV], 'a') do |file|
|
|
50
|
+
kept.each { |entry| file.puts(JSON.generate(entry)) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def relative_to_root(path)
|
|
55
|
+
root = ENV[ROOT_ENV].to_s
|
|
56
|
+
return path if root.empty?
|
|
57
|
+
|
|
58
|
+
prefixes = [root, resolved(root)].uniq.map { |candidate| "#{candidate.chomp('/')}/" }
|
|
59
|
+
[path, resolved(path)].uniq.each do |candidate|
|
|
60
|
+
prefix = prefixes.find { |value| candidate.start_with?(value) }
|
|
61
|
+
return candidate.delete_prefix(prefix) if prefix
|
|
62
|
+
end
|
|
63
|
+
path
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def resolved(path)
|
|
69
|
+
File.realpath(path)
|
|
70
|
+
rescue SystemCallError
|
|
71
|
+
path
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/mutation_tester.rb
CHANGED
data/readme.md
CHANGED
|
@@ -55,6 +55,7 @@ interruption line (no backtrace), and exits with status 130.
|
|
|
55
55
|
- [Mutation types](#mutation-types)
|
|
56
56
|
- [Equivalent mutants](#equivalent-mutants)
|
|
57
57
|
- [Reports and output](#reports-and-output)
|
|
58
|
+
- [Finding redundant tests](#finding-redundant-tests)
|
|
58
59
|
- [Pre-push hook](#pre-push-hook)
|
|
59
60
|
- [CI/CD integration](#cicd-integration)
|
|
60
61
|
- [Troubleshooting](#troubleshooting)
|
|
@@ -294,6 +295,7 @@ expected path printed.
|
|
|
294
295
|
| `--spec-map RULE` | Spec-mapping regex rule `'PATTERN=>REPLACEMENT'`; repeatable, first match wins. See [Mapping sources to specs](#mapping-sources-to-specs). |
|
|
295
296
|
| `--minimum-score N` | Mutation score percentage a file must reach to pass (default: 80). Drives the `PASS`/`FAIL` verdict and the exit code. |
|
|
296
297
|
| `--fail-fast` | Stop the run at the first surviving mutant and finish with a failing status. |
|
|
298
|
+
| `--kill-matrix` | Audit mode: run every mutant against the full test file and record which tests kill it (`mutations[].killed_by`, `tests[]`). Slower; cannot be combined with `--fail-fast`. See [Finding redundant tests](#finding-redundant-tests). |
|
|
297
299
|
| `--timeout-factor N` | Per-mutant timeout budget as `N` times the measured baseline test run, never below 5 s (default: 5, must be > 0). Ignored when `config.timeout` is set explicitly. |
|
|
298
300
|
| `--timeout-policy MODE` | Scoring policy for timed-out mutants: `killed` (default) counts a timeout as a kill; `separate` keeps timeouts out of the score and reports them as their own category. |
|
|
299
301
|
| `--worker-env NAME` | Per-worker database isolation via the `parallel_tests` `TEST_ENV_NUMBER` convention. See [Making parallelism work with Rails](#making-parallelism-work-with-rails). |
|
|
@@ -304,7 +306,7 @@ expected path printed.
|
|
|
304
306
|
| `--reporters LIST` | Comma-separated reporters to run: `console`, `html`, `json` (default: all three). An unknown name errors and exits 1. |
|
|
305
307
|
| `--output-dir PATH` | Directory for the generated report files (default: `tmp/mutation_reports`). In batch mode each file writes to its own subdirectory. |
|
|
306
308
|
| `--verbose` | Show a per-mutation warning for every skipped mutation (quiet by default; the "Generated N mutations, skipped M" summary always prints when mutants are dropped). |
|
|
307
|
-
| `--no-progress` | Disable progress
|
|
309
|
+
| `--no-progress` | Disable the live progress line (percentage, processed count, elapsed time, estimated remaining time, survived and timed out tallies, and an errored tally once a mutant errors). |
|
|
308
310
|
| `-h, --help` | Show help message. |
|
|
309
311
|
| `-v, --version` | Show version. |
|
|
310
312
|
|
|
@@ -404,6 +406,11 @@ MutationTester.configure do |config|
|
|
|
404
406
|
# survived. Set to false to always run the full file. See Execution model.
|
|
405
407
|
config.test_selection = true
|
|
406
408
|
|
|
409
|
+
# Kill matrix (audit mode): run every mutant against the full test file and
|
|
410
|
+
# record in the JSON report which tests kill it. Slower than a normal run.
|
|
411
|
+
# Equivalent to the --kill-matrix CLI flag. See Finding redundant tests.
|
|
412
|
+
config.kill_matrix = false
|
|
413
|
+
|
|
407
414
|
# Hard deadline for the single baseline run of the whole suite (seconds).
|
|
408
415
|
# Runs every example once, so it is looser than the per-mutant timeout above.
|
|
409
416
|
# Set to nil to disable the baseline deadline.
|
|
@@ -622,6 +629,9 @@ reads). Such a mutant used to pay the full test file once per mutant, which on a
|
|
|
622
629
|
per-mutant deadline and turn a decided kill into a reported timeout. The baseline run and the shadow sanity check are
|
|
623
630
|
unaffected: they are expected to pass, and a passing run runs every test.
|
|
624
631
|
|
|
632
|
+
The one exception is the opt-in `--kill-matrix` audit mode, which needs every failing test of every mutant and
|
|
633
|
+
therefore runs the full test file each time. See [Finding redundant tests](#finding-redundant-tests).
|
|
634
|
+
|
|
625
635
|
### Test selection (fast kill with full-file confirmation)
|
|
626
636
|
|
|
627
637
|
For RSpec suites on the file-based runners, MutationTester runs each mutant in two phases: it first runs only the
|
|
@@ -751,6 +761,79 @@ gap: the worklist you can hand to an AI agent or a CI gate (see [CI/CD integrati
|
|
|
751
761
|
Full field-by-field documentation of both shapes (the single-file report and the multi-file envelope), the
|
|
752
762
|
`schema_version` policy, and ready-to-use `jq` recipes live in [docs/json-schema.md](docs/json-schema.md).
|
|
753
763
|
|
|
764
|
+
## Finding redundant tests
|
|
765
|
+
|
|
766
|
+
Mutation testing normally answers "which behavior is untested?". The opt-in **kill matrix** answers the opposite
|
|
767
|
+
question: "which tests could I delete without losing any protection?". A test is a redundancy candidate when removing
|
|
768
|
+
it leaves the set of killed mutants unchanged.
|
|
769
|
+
|
|
770
|
+
```bash
|
|
771
|
+
# One file
|
|
772
|
+
bundle exec mutation_test --kill-matrix --json lib/calculator.rb > kill_matrix.json
|
|
773
|
+
|
|
774
|
+
# A whole directory (one aggregate JSON document)
|
|
775
|
+
bundle exec mutation_test --kill-matrix --json --glob 'lib/**/*.rb' > kill_matrix.json
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
`--kill-matrix` (or `config.kill_matrix = true`) changes how every mutant is run and what the JSON report contains. It
|
|
779
|
+
works the same for RSpec and Minitest and on all three runners:
|
|
780
|
+
|
|
781
|
+
- Every mutant runs the **full** test file. The run does not stop at the first failing test and RSpec test selection
|
|
782
|
+
is off, because a run that stops early would name only one of the killers.
|
|
783
|
+
- `tests[]` lists every test of the unmutated baseline run (`id`, `name`, `line`, `status`), so a test that kills
|
|
784
|
+
nothing is still visible.
|
|
785
|
+
- `mutations[].killed_by` lists the `id` of every test that failed under that mutant.
|
|
786
|
+
|
|
787
|
+
Test ids are `TestClass#test_name` for Minitest and `spec/calculator_spec.rb[1:2:1]` for RSpec, which you can pass
|
|
788
|
+
straight to `rspec` from the project root to run that one example (RSpec older than 3.3 has no such ids and gets
|
|
789
|
+
`spec/calculator_spec.rb:12`, file and line, instead).
|
|
790
|
+
|
|
791
|
+
The analysis itself is a short `jq` program over the report. Both recipes work on a single-file report and on the
|
|
792
|
+
multi-file envelope.
|
|
793
|
+
|
|
794
|
+
```bash
|
|
795
|
+
# 1. Tests that kill no mutant at all
|
|
796
|
+
jq -r '(.files // [.])[]
|
|
797
|
+
| [.mutations[].killed_by[]] as $killers
|
|
798
|
+
| .tests[] | select(.status == "passed" and (.id | IN($killers[]) | not))
|
|
799
|
+
| "\(.id)\t\(.name)"' kill_matrix.json
|
|
800
|
+
|
|
801
|
+
# 2. Tests whose every kill is shared: each mutant they kill is also killed by another test
|
|
802
|
+
jq -r '(.files // [.])[]
|
|
803
|
+
| [.mutations[] | select(.status == "killed") | .killed_by] as $kills
|
|
804
|
+
| .tests[] | .id as $id
|
|
805
|
+
| [$kills[] | select(index($id))] as $mine
|
|
806
|
+
| select(($mine | length) > 0 and all($mine[]; length > 1))
|
|
807
|
+
| "\(.id)\t\(.name)"' kill_matrix.json
|
|
808
|
+
|
|
809
|
+
# 3. Mutants whose killers are unknown (read the results with care when this is not 0)
|
|
810
|
+
jq '[(.files // [.])[] | .mutations[]
|
|
811
|
+
| select((.status == "killed" or .status == "timeout") and (.killed_by | length) == 0)] | length' kill_matrix.json
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
Read the lists as candidates to review, not as a delete list:
|
|
815
|
+
|
|
816
|
+
- **Remove one test at a time and re-run.** Two tests from list 2 can cover for each other: each one is redundant
|
|
817
|
+
while the other exists, but deleting both lets their mutants survive.
|
|
818
|
+
- The result is relative to **one source file and the enabled mutation types**. A test that kills nothing here may
|
|
819
|
+
protect another file, or behavior no mutation operator changes (for example the exact wording of a message when the
|
|
820
|
+
`string` mutations are disabled).
|
|
821
|
+
- A `timeout` mutant always has an empty `killed_by`, and so does a `killed` mutant that failed without any test
|
|
822
|
+
failing (typically the mutated file no longer loads). Their killers are unknown, so a test must never be called
|
|
823
|
+
redundant because of them. Recipe 3 counts them; a higher `--timeout-factor` usually turns timeouts into real kills.
|
|
824
|
+
- A skipped test has `"status": "skipped"` in `tests[]` and is left out of the recipes.
|
|
825
|
+
- If the baseline passes but not a single test could be recorded (the test file defines no tests, or a plugin replaces
|
|
826
|
+
the framework's reporters), the run stops with an error instead of reporting an empty matrix.
|
|
827
|
+
|
|
828
|
+
The mode is an occasional audit, not a gate. Without the early stop a mutant that breaks something every test touches
|
|
829
|
+
pays for the whole test file, so expect a slower run and consider `--timeout-factor 10`. It cannot be combined with
|
|
830
|
+
`--fail-fast` (or `config.fail_fast`), which would stop the run at the first surviving mutant and leave the matrix
|
|
831
|
+
incomplete: the CLI rejects the pair as a usage error and a run configured from Ruby or rake fails before the baseline.
|
|
832
|
+
|
|
833
|
+
For a scheduled CI job that publishes the candidates, see
|
|
834
|
+
[`examples/github_actions/redundant_tests.yml`](examples/github_actions/redundant_tests.yml) and
|
|
835
|
+
[docs/ci.md](docs/ci.md#redundant-test-audit-scheduled-job).
|
|
836
|
+
|
|
754
837
|
## Pre-push hook
|
|
755
838
|
|
|
756
839
|
Gate your pushes locally: run mutation testing on the file(s) you touched and block the push when the score is under
|
|
@@ -801,6 +884,9 @@ The gem ships ready-to-copy GitHub Actions workflows (installed alongside the ge
|
|
|
801
884
|
- [`examples/github_actions/ai_mutation_gate.yml`](examples/github_actions/ai_mutation_gate.yml) is the AI gate: the
|
|
802
885
|
same pass/fail gate, plus it writes the surviving-mutant worklist to the GitHub job summary and uploads
|
|
803
886
|
`survivors.json` for an agent to turn into missing tests.
|
|
887
|
+
- [`examples/github_actions/redundant_tests.yml`](examples/github_actions/redundant_tests.yml) is a scheduled audit,
|
|
888
|
+
not a gate: it runs `--kill-matrix` and lists the tests that kill no mutant, or no mutant of their own, in the job
|
|
889
|
+
summary. See [Finding redundant tests](#finding-redundant-tests).
|
|
804
890
|
|
|
805
891
|
See [docs/ci.md](docs/ci.md) for the full recipes: a 5-minute setup, minimal inline and pull-request workflows, machine
|
|
806
892
|
mode as a gate and artifact, and the AI workflow.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mutation_tester
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kamil Dzierbicki
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: parallel
|
|
@@ -153,6 +153,7 @@ files:
|
|
|
153
153
|
- examples/calculator_spec.rb
|
|
154
154
|
- examples/github_actions/ai_mutation_gate.yml
|
|
155
155
|
- examples/github_actions/mutation_test.yml
|
|
156
|
+
- examples/github_actions/redundant_tests.yml
|
|
156
157
|
- examples/hooks/pre-push
|
|
157
158
|
- examples/run_example.rb
|
|
158
159
|
- examples/run_example_minitest.rb
|
|
@@ -170,6 +171,7 @@ files:
|
|
|
170
171
|
- lib/mutation_tester/framework_detector.rb
|
|
171
172
|
- lib/mutation_tester/in_memory_loader.rb
|
|
172
173
|
- lib/mutation_tester/minitest_fail_fast.rb
|
|
174
|
+
- lib/mutation_tester/minitest_load_hook.rb
|
|
173
175
|
- lib/mutation_tester/mutation_runner.rb
|
|
174
176
|
- lib/mutation_tester/mutator.rb
|
|
175
177
|
- lib/mutation_tester/progress_display.rb
|
|
@@ -181,6 +183,9 @@ files:
|
|
|
181
183
|
- lib/mutation_tester/reporters/html_reporter.rb
|
|
182
184
|
- lib/mutation_tester/reporters/json_reporter.rb
|
|
183
185
|
- lib/mutation_tester/test_command.rb
|
|
186
|
+
- lib/mutation_tester/test_recorder.rb
|
|
187
|
+
- lib/mutation_tester/test_recorder/minitest_hook.rb
|
|
188
|
+
- lib/mutation_tester/test_recorder/rspec_hook.rb
|
|
184
189
|
- lib/mutation_tester/version.rb
|
|
185
190
|
- lib/tasks/mutation_tester.rake
|
|
186
191
|
- readme.md
|