lib/App/Test/Generator/Mutator.pm

Structural Coverage (Approximate)

TER1 (Statement): 95.70%
TER2 (Branch): 79.41%
TER3 (LCSAJ): 100.0% (11/11)
Approximate LCSAJ segments: 35

LCSAJ Legend

โ— 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.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    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) {

					
Mutants (Total: 1, Killed: 0, Survived: 1)
178: return @{ _dedup_mutants(\@mutants) }; 179: } 180: โ—181 โ†’ 181 โ†’ 0 181: return @mutants;

Mutants (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;

Mutants (Total: 1, Killed: 0, Survived: 1)
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: 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;