TER1 (Statement): 95.70%
TER2 (Branch): 79.41%
TER3 (LCSAJ): 100.0% (11/11)
Approximate LCSAJ segments: 35
โ Covered โ this LCSAJ path was executed during testing.
โ Not covered โ this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start โ end โ jump
Uncovered paths show [NOT COVERED] in the tooltip.
1: package App::Test::Generator::Mutator; 2: 3: use strict; 4: use warnings; 5: use Carp qw(croak); 6: use Config; 7: use File::Copy qw(copy); 8: use File::Copy::Recursive qw(dircopy); 9: use File::Spec; 10: use File::Temp qw(tempdir); 11: use PPI; 12: use Readonly; 13: 14: use App::Test::Generator::Mutation::BooleanNegation; 15: use App::Test::Generator::Mutation::ConditionalInversion; 16: use App::Test::Generator::Mutation::NumericBoundary; 17: use App::Test::Generator::Mutation::ReturnUndef; 18: 19: # -------------------------------------------------- 20: # Valid mutation level values 21: # -------------------------------------------------- 22: Readonly my $LEVEL_FULL => 'full'; 23: Readonly my $LEVEL_FAST => 'fast'; 24: 25: # -------------------------------------------------- 26: # Default values for optional constructor arguments 27: # -------------------------------------------------- 28: Readonly my $DEFAULT_LIB_DIR => 'lib'; 29: Readonly my $DEFAULT_MUTATION_LEVEL => $LEVEL_FULL; 30: 31: our $VERSION = '0.36'; 32: 33: =head1 NAME 34: 35: App::Test::Generator::Mutator - Generate and apply mutation tests 36: 37: =head1 VERSION 38: 39: Version 0.36 40: 41: =head1 DESCRIPTION 42: 43: B<App::Test::Generator::Mutator> is a mutation engine that programmatically 44: alters Perl source files to evaluate the effectiveness of a project's test 45: suite. It analyses modules, generates systematic code mutations (such as 46: conditional inversions, logical operator changes, and numeric boundary 47: flips), and applies them within an isolated workspace so tests can be 48: executed safely against each modified variant. 49: 50: By tracking which mutants are killed (cause tests to fail) versus those that 51: survive (tests still pass), the module enables calculation of a mutation 52: score, providing a quantitative measure of how well the test suite detects 53: unintended behavioural changes. 54: 55: =head2 new 56: 57: Construct a new Mutator for a given source file. 58: 59: my $mutator = App::Test::Generator::Mutator->new( 60: file => 'lib/My/Module.pm', 61: lib_dir => 'lib', 62: mutation_level => 'full', 63: ); 64: 65: =head3 Arguments 66: 67: =over 4 68: 69: =item * C<file> 70: 71: Path to the Perl source file to mutate. Required. Must exist on disk. 72: 73: =item * C<lib_dir> 74: 75: Root library directory. Optional â defaults to C<lib>. 76: 77: =item * C<mutation_level> 78: 79: Controls the breadth of mutation. C<full> applies all mutations; 80: C<fast> deduplicates and removes redundant mutants first. 81: Optional â defaults to C<full>. 82: 83: =back 84: 85: =head3 Returns 86: 87: A blessed hashref. Croaks if C<file> is missing or does not exist. 88: 89: =head3 API specification 90: 91: =head4 input 92: 93: { 94: file => { type => SCALAR }, 95: lib_dir => { type => SCALAR, optional => 1 }, 96: mutation_level => { type => SCALAR, optional => 1 }, 97: } 98: 99: =head4 output 100: 101: { 102: type => OBJECT, 103: isa => 'App::Test::Generator::Mutator', 104: } 105: 106: =cut 107: 108: sub new { 109: my ($class, %args) = @_; 110: 111: # file is required and must exist on disk 112: croak 'file required' unless defined $args{file}; 113: croak "file not found: $args{file}" unless -f $args{file}; 114: 115: return bless { 116: file => $args{file}, 117: lib_dir => $args{lib_dir} || $DEFAULT_LIB_DIR, 118: mutation_level => $args{mutation_level} || $DEFAULT_MUTATION_LEVEL, 119: 120: # Instantiate all registered mutation strategies 121: mutations => [ 122: App::Test::Generator::Mutation::BooleanNegation->new(), 123: App::Test::Generator::Mutation::ReturnUndef->new(), 124: App::Test::Generator::Mutation::NumericBoundary->new(), 125: App::Test::Generator::Mutation::ConditionalInversion->new(), 126: ], 127: }, $class; 128: } 129: 130: =head2 generate_mutants 131: 132: Parse the target file and generate all mutants by running each registered 133: mutation strategy against the PPI document. 134: 135: my @mutants = $mutator->generate_mutants(); 136: 137: =head3 Arguments 138: 139: None beyond C<$self>. 140: 141: =head3 Returns 142: 143: A list of L<App::Test::Generator::Mutant> objects. In C<fast> mode, 144: redundant and duplicate mutants are removed before returning. 145: 146: =head3 API specification 147: 148: =head4 input 149: 150: { 151: self => { type => OBJECT, isa => 'App::Test::Generator::Mutator' }, 152: } 153: 154: =head4 output 155: 156: { 157: type => ARRAYREF, 158: elements => { type => OBJECT, isa => 'App::Test::Generator::Mutant' }, 159: } 160: 161: =cut 162: 163: sub generate_mutants { โ164 โ 172 โ 177โ164 โ 172 โ 0 164: my $self = $_[0]; 165: 166: # Parse the target file into a PPI document 167: my $doc = PPI::Document->new($self->{file}) or croak "Unable to parse $self->{file}"; 168: 169: my @mutants; 170: 171: # Run each registered mutation strategy against the document 172: for my $mutation (@{ $self->{mutations} }) { 173: push @mutants, $mutation->mutate($doc); 174: } 175: 176: # In fast mode deduplicate and remove redundant mutants โ177 โ 177 โ 181โ177 โ 177 โ 0 177: if($self->{mutation_level} eq $LEVEL_FAST) {178: return @{ _dedup_mutants(\@mutants) }; 179: } 180: โ181 โ 181 โ 0 181: return @mutants;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_177_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 2, Killed: 2, Survived: 0)
182: } 183: 184: =head2 prepare_workspace 185: 186: Prepare an isolated temporary workspace for a single mutation test run. 187: 188: The entire C<lib_dir> tree is copied into the workspace so that all module 189: dependencies resolve correctly when the test suite runs against the mutant. 190: Only after this copy is complete is the single target file overwritten by 191: C<apply_mutant>. 192: 193: my $workspace = $mutator->prepare_workspace(); 194: $mutator->apply_mutant($mutant); 195: local $ENV{PERL5LIB} = "$workspace/lib"; 196: my $survived = (system('prove', 't') == 0); 197: 198: =head3 Arguments 199: 200: None beyond C<$self>. 201: 202: =head3 Returns 203: 204: A string containing the absolute path to the temporary directory created. 205: The directory is automatically removed when the object goes out of scope 206: via L<File::Temp>'s C<CLEANUP =E<gt> 1> behaviour. 207: 208: =head3 Side effects 209: 210: Creates a temporary directory. Recursively copies C<lib_dir> into it. 211: Sets C<< $self->{workspace} >> and C<< $self->{relative} >>. 212: 213: =head3 Notes 214: 215: Call C<prepare_workspace> once per file, then C<apply_mutant> once per 216: mutant within that file. Do not store the returned path beyond the 217: lifetime of the enclosing scope. 218: 219: =head3 API specification 220: 221: =head4 input 222: 223: { 224: self => { type => OBJECT, isa => 'App::Test::Generator::Mutator' }, 225: } 226: 227: =head4 output 228: 229: { 230: type => SCALAR, 231: } 232: 233: =cut 234: 235: sub prepare_workspace { 236: my $self = $_[0]; 237: 238: # Create a self-cleaning temporary directory 239: my $tmp = tempdir(CLEANUP => 1); 240: 241: # Derive the file's path relative to lib_dir for use by apply_mutant 242: my $relative = $self->{file}; 243: $relative =~ s/^\Q$self->{lib_dir}\E\/?//; 244: 245: # Copy the entire lib tree so all dependencies resolve in the workspace 246: dircopy($self->{lib_dir}, File::Spec->catfile($tmp, $self->{lib_dir})) 247: or croak "dircopy failed: $!"; 248: 249: $self->{workspace} = $tmp; 250: $self->{relative} = $relative; 251: 252: return $tmp;
Mutants (Total: 2, Killed: 2, Survived: 0)
253: } 254: 255: =head2 apply_mutant 256: 257: Apply a single mutant's transform to the target file in the workspace. 258: 259: $mutator->apply_mutant($mutant); 260: 261: =head3 Arguments 262: 263: =over 4 264: 265: =item * C<$mutant> 266: 267: An L<App::Test::Generator::Mutant> object whose C<transform> closure 268: will be applied to the workspace copy of the target file. 269: 270: =back 271: 272: =head3 Returns 273: 274: Nothing. Modifies the workspace copy of the target file in place. 275: 276: =head3 Side effects 277: 278: Overwrites the target file in the workspace with the mutated version. 279: 280: =head3 API specification 281: 282: =head4 input 283: 284: { 285: self => { type => OBJECT, isa => 'App::Test::Generator::Mutator' }, 286: mutant => { type => OBJECT, isa => 'App::Test::Generator::Mutant' }, 287: } 288: 289: =head4 output 290: 291: { type => UNDEF } 292: 293: =cut 294: 295: sub apply_mutant { 296: my ($self, $mutant) = @_; 297: 298: # Workspace must be prepared before applying any mutant 299: my $workspace = $self->{workspace} 300: or croak 'Workspace not prepared â call prepare_workspace first'; 301: 302: my $relative = $self->{relative} 303: or croak 'Relative path not set â call prepare_workspace first'; 304: 305: # Construct the full path to the file in the workspace 306: my $target = File::Spec->catfile( 307: $workspace, 308: $self->{lib_dir}, 309: $relative, 310: ); 311: 312: # Parse the workspace copy and apply the mutation transform 313: my $doc = PPI::Document->new($target) 314: or croak "Failed to parse $target"; 315: 316: $mutant->{transform}->($doc); 317: 318: $doc->save($target); 319: } 320: 321: =head2 run_tests 322: 323: Run the test suite against the current workspace and return whether all 324: tests passed. 325: 326: my $survived = $mutator->run_tests(); 327: 328: =head3 Arguments 329: 330: None beyond C<$self>. 331: 332: =head3 Returns 333: 334: 1 if all tests passed (mutant survived), 0 if any test failed (mutant 335: killed). 336: 337: =head3 Side effects 338: 339: Executes an external process running the test suite. 340: 341: =head3 Notes 342: 343: Uses C<prove> found on PATH. Sets C<PERL5LIB> to include the workspace 344: lib directory before running. 345: 346: =head3 API specification 347: 348: =head4 input 349: 350: { 351: self => { type => OBJECT, isa => 'App::Test::Generator::Mutator' }, 352: } 353: 354: =head4 output 355: 356: { type => SCALAR } 357: 358: =cut 359: 360: sub run_tests { 361: my $self = $_[0]; 362: 363: # Locate prove on PATH â fall back to bare 'prove' and let shell find it 364: my $prove = File::Spec->catfile($Config{bin}, 'prove'); 365: $prove = 'prove' unless -x $prove; 366: 367: return system($prove, '-l', 't') == 0;
368: } 369: 370: # -------------------------------------------------- 371: # _dedup_mutants 372: # 373: # Purpose: Remove duplicate and redundant mutants 374: # from a list, used in fast mutation mode 375: # to reduce the number of mutants to run. 376: # 377: # Entry: $mutants - arrayref of Mutant objects. 378: # 379: # Exit: Returns an arrayref of deduplicated 380: # Mutant objects. 381: # 382: # Side effects: None. 383: # 384: # Notes: Deduplication key uses line, original, 385: # and description rather than the transform 386: # coderef, which is not stable as a string. 387: # -------------------------------------------------- 388: sub _dedup_mutants { โ389 โ 393 โ 406โ389 โ 393 โ 0 389: my ($mutants) = @_; 390: my @rc; 391: my %seen; 392: 393: for my $m (@{$mutants}) { 394: # Build a stable key from metadata â not from the coderef 395: my $key = join '|', 396: $m->{line} // '', 397: $m->{original} // '', 398: $m->{description} // ''; 399: 400: next if $seen{$key}++; 401: next if _is_redundant_mutation($m); 402: 403: push @rc, $m; 404: } 405: โ406 โ 406 โ 0 406: return \@rc; 407: } 408: 409: # -------------------------------------------------- 410: # _is_redundant_mutation 411: # 412: # Purpose: Return true if a mutant is considered 413: # redundant and should be skipped in fast 414: # mutation mode. 415: # 416: # Entry: $m - a Mutant hashref. 417: # 418: # Exit: Returns 1 if redundant, 0 otherwise. 419: # 420: # Side effects: None. 421: # 422: # Notes: Checks for arithmetic no-ops, double 423: # negation inside conditionals, boolean 424: # literal flips, mutations inside comments, 425: # and equivalent numeric comparisons. 426: # Does not compare transform coderefs â 427: # they are not meaningful as strings. 428: # -------------------------------------------------- 429: sub _is_redundant_mutation { โ430 โ 440 โ 445โ430 โ 440 โ 0 430: my ($m) = @_; 431: 432: my $orig = $m->{original} // ''; 433: 434: # Arithmetic no-ops add nothing to mutation coverage 435: return 1 if $orig =~ /\+\s*0$/;Mutants (Total: 1, Killed: 0, Survived: 1)
- NUM_BOUNDARY_367_35_!=: Numeric boundary flip == to !=
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );Mutants (Total: 2, Killed: 2, Survived: 0)
436: return 1 if $orig =~ /-\s*0$/;
Mutants (Total: 2, Killed: 2, Survived: 0)
437: 438: # Double negation inside conditionals forces boolean context 439: # in Perl and is not a meaningful mutation 440: if($m->{context} && $m->{context} eq 'conditional') {
Mutants (Total: 1, Killed: 1, Survived: 0)
441: return 1 if $orig =~ /^\!\!/;
Mutants (Total: 2, Killed: 2, Survived: 0)
442: } 443: 444: # Boolean literal flip on a standalone 1 or 0 is trivial โ445 โ 450 โ 0 445: return 1 if $orig =~ /^\s*(?:1|0)\s*$/;
Mutants (Total: 2, Killed: 2, Survived: 0)
446: 447: # Mutations inside comments are unreachable code 448: return 1 if $m->{line_content} && $m->{line_content} =~ /^\s*#/;
Mutants (Total: 2, Killed: 2, Survived: 0)
449: 450: return 0;
Mutants (Total: 2, Killed: 2, Survived: 0)
451: } 452: 453: =head1 SEE ALSO 454: 455: =over 4 456: 457: =item C<bin/test-generator-mutate> 458: 459: =item L<Devel::Mutator> 460: 461: =back 462: 463: =head1 AUTHOR 464: 465: Nigel Horne, C<< <njh at nigelhorne.com> >> 466: 467: Portions of this module's initial design and documentation were created 468: with the assistance of AI. 469: 470: =head1 LICENCE AND COPYRIGHT 471: 472: Copyright 2026 Nigel Horne. 473: 474: Usage is subject to the terms of GPL2. 475: If you use it, 476: please let me know. 477: 478: =cut 479: 480: 1;