App-Test-Generator
view release on metacpan or search on metacpan
t/mutant_killers.t view on Meta::CPAN
#!/usr/bin/env perl
use strict;
use warnings;
use Test::Most;
use Test::Mockingbird;
use File::Temp qw(tempdir);
use File::Spec;
use Readonly;
use YAML::XS qw(DumpFile);
use Cwd qw(cwd);
use_ok('App::Test::Generator::Sample::Module');
use_ok('App::Test::Generator');
use_ok('App::Test::Generator::CoverageGuidedFuzzer');
use_ok('App::Test::Generator::Mutator');
use_ok('App::Test::Generator::SchemaExtractor');
# ===================================================================
# Constants matching the modules under test â never use magic numbers
# ===================================================================
Readonly my $MIN_EMAIL_LEN => 5;
Readonly my $MAX_EMAIL_LEN => 254;
Readonly my $MIN_BIRTH_YEAR => 1900;
Readonly my $MIN_NAME_LEN => 1;
Readonly my $MAX_NAME_LEN => 50;
Readonly my $MIN_SCORE => 0.0;
Readonly my $MAX_SCORE => 100.0;
Readonly my $PASS_THRESHOLD => 60.0;
Readonly my $DEFAULT_MAX_ARRAY => 4; # CoverageGuidedFuzzer max array length
Readonly my $RAND_ARRAY_RUNS => 200; # iterations to guarantee max-length coverage
# ===================================================================
# Helper: write a .pm source string to a temp file, return its path
# ===================================================================
sub _make_pm {
my ($src, $name) = @_;
$name //= 'TestModule.pm';
my $tmpdir = tempdir(CLEANUP => 1);
my $pm = File::Spec->catfile($tmpdir, $name);
open my $fh, '>', $pm or die "Cannot write $pm: $!";
print $fh $src;
close $fh;
return ($pm, $tmpdir);
}
# ===================================================================
# Helper: extract schemas from Perl source, returning the schema hash
# ===================================================================
sub _extract {
my ($src) = @_;
my ($pm) = _make_pm($src);
my $ex = App::Test::Generator::SchemaExtractor->new(input_file => $pm);
return $ex->extract_all(no_write => 1);
}
# ===================================================================
# Helper: capture generate() output to a scalar, return (output, err)
# ===================================================================
sub _make_schema_yml {
my (%schema) = @_;
my $tmpdir = tempdir(CLEANUP => 1);
my $yml = File::Spec->catfile($tmpdir, 'schema.yml');
DumpFile($yml, \%schema);
return $yml;
}
sub _capture_generate {
my (%schema) = @_;
# Write to a temp YAML file and use the legacy file-path API,
# avoiding the strict-param validation in the modern API path.
my $yml = _make_schema_yml(%schema);
local *STDOUT;
open STDOUT, '>', \my $out;
my $err = '';
local $@;
eval { App::Test::Generator->generate($yml) };
$err = $@ if $@;
return ($out, $err);
}
# ===================================================================
# Helper: build a CoverageGuidedFuzzer with sane defaults
# ===================================================================
sub _new_fuzzer {
my (%args) = @_;
return App::Test::Generator::CoverageGuidedFuzzer->new(
schema => $args{schema} // {input => {type => 'string'}},
target_sub => $args{target_sub} // sub { 1 },
iterations => $args{iterations} // 10,
seed => $args{seed} // 42,
);
}
# ===================================================================
# SECTION 1: App::Test::Generator::Sample::Module
# Kills: NUM_BOUNDARY_114, 115, 171, 221, 319, 320, 418, 421
# ===================================================================
subtest 'Sample::Module validate_email â exact boundary at MIN_EMAIL_LEN (line 114)' => sub {
# Kills NUM_BOUNDARY_114_50_> (>= to > / < / <=)
# With ">=" flipped to ">": length=MIN_EMAIL_LEN (5) would fail (5 > 5 is false).
# This subtest proves that exactly MIN_EMAIL_LEN characters must pass.
my $obj = new_ok('App::Test::Generator::Sample::Module');
# Exactly MIN_EMAIL_LEN: 5 chars, valid format
my $at_min = 'a@b.c';
is(length($at_min), $MIN_EMAIL_LEN, 'test string is exactly MIN_EMAIL_LEN chars');
ok($obj->validate_email($at_min), 'email of exactly MIN_EMAIL_LEN chars is accepted');
# One below MIN: must croak with "too short"
my $below_min = 'a@b.';
is(length($below_min), $MIN_EMAIL_LEN - 1, 'short email is MIN-1 chars');
throws_ok { $obj->validate_email($below_min) } qr/too short/i,
'email of MIN_EMAIL_LEN-1 chars croaks with "too short"';
};
subtest 'Sample::Module validate_email â exact boundary at MAX_EMAIL_LEN (line 115)' => sub {
# Kills NUM_BOUNDARY_115_50_< (<= to < / > / >=)
# With "<=" flipped to "<": length=MAX_EMAIL_LEN (254) would fail (254 < 254 is false).
my $obj = new_ok('App::Test::Generator::Sample::Module');
# Build an email of exactly 254 chars: 50-char local + @ + 199-char domain + .com = 254
my $at_max = ('x' x 50) . '@' . ('y' x 199) . '.com';
is(length($at_max), $MAX_EMAIL_LEN, 'test email is exactly MAX_EMAIL_LEN chars');
ok($obj->validate_email($at_max), 'email of exactly MAX_EMAIL_LEN chars is accepted');
# One above MAX: must croak with "too long"
my $over_max = $at_max . 'x';
is(length($over_max), $MAX_EMAIL_LEN + 1, 'long email is MAX+1 chars');
throws_ok { $obj->validate_email($over_max) } qr/too long/i,
'email of MAX_EMAIL_LEN+1 chars croaks with "too long"';
};
subtest 'Sample::Module calculate_age â exact boundary at MIN_BIRTH_YEAR and current_year (line 171)' => sub {
# Kills NUM_BOUNDARY_171_22_> (>= to > on lower, <= to < on upper)
# Lower: 1900 must pass; 1899 must croak.
# Upper: current_year must pass; current_year+1 must croak.
my $obj = new_ok('App::Test::Generator::Sample::Module');
my $current_year = (localtime)[5] + 1900;
# Lower bound: exactly MIN_BIRTH_YEAR
my $age = $obj->calculate_age($MIN_BIRTH_YEAR);
is($age, $current_year - $MIN_BIRTH_YEAR, 'birth year 1900 gives correct age');
throws_ok { $obj->calculate_age($MIN_BIRTH_YEAR - 1) } qr/out of range/i,
'birth year 1899 croaks';
t/mutant_killers.t view on Meta::CPAN
my $yml_no_out = _make_schema_yml(
module => 'Some::Module',
function => 'value',
'new' => {},
input => {value => {type => 'integer'}},
output => {},
accessor => {type => 'getset', property => 'value'},
);
throws_ok { App::Test::Generator->generate($yml_no_out) }
qr/getset must give one output/i,
'getset with empty output croaks';
# Getset with defined output: must NOT croak
my ($out, $err) = _capture_generate(
module => 'Some::Module',
function => 'value',
'new' => {},
input => {value => {type => 'integer'}},
output => {type => 'integer'},
accessor => {type => 'getset', property => 'value'},
);
ok(!$err, 'getset with defined output does not croak');
};
subtest 'generate() â getter with empty input adds property assertion (line 2019)' => sub {
# Kills NUM_BOUNDARY_2019_27_!= (if(scalar keys %input == 0) â == flipped to !=)
# With "==": empty input â getter gets cmp_ok assertion (correct)
# With "!=": non-empty input â getter gets assertion instead of empty input
# Getter with no input params: generated code must include getter-specific assertion
my ($out, $err) = _capture_generate(
module => 'Some::Module',
function => 'get_value',
'new' => {},
input => {},
output => {type => 'scalar'},
accessor => {type => 'getter', property => 'value'},
);
ok(!$err, 'getter with empty input generates without error') or diag "Error: $err";
like($out, qr/getter function returns correct item|cmp_ok.*eq.*\$obj->/,
'getter with empty input includes getter-specific assertion');
};
# ===================================================================
# SECTION 4: App::Test::Generator::CoverageGuidedFuzzer
# Kills: NUM_BOUNDARY_258, 667, 730, 762, 811, 819, 969, 970, 1069
# ===================================================================
subtest 'CoverageGuidedFuzzer run() â corpus mutation path exercised (line 258)' => sub {
# Kills NUM_BOUNDARY_258_37_> (rand() < CORPUS_MUTATE_RATIO â < flipped to >)
# With "<": when rand() < 0.70 AND corpus is non-empty, mutate from corpus.
# With ">": mutation only happens when rand() > 0.70 â corpus rarely used.
# This test verifies that run() completes correctly with a pre-populated corpus,
# which exercises the corpus-mutation code path.
my $call_count = 0;
my $fuzzer = _new_fuzzer(
schema => {input => {type => 'string'}},
target_sub => sub { $call_count++; length($_[0] // '') },
iterations => 20,
);
# Pre-populate corpus so the mutation path can trigger
push @{$fuzzer->{corpus}},
{input => 'hello', coverage => {b1 => 1}},
{input => 'world', coverage => {b2 => 1}};
my $stats = $fuzzer->run();
is(ref($stats), 'HASH', 'run() returns a hashref');
is($stats->{total_iterations}, 20, 'stats.total_iterations matches iterations');
is($call_count, 20, 'target_sub called once per iteration');
ok($stats->{bugs_found} >= 0, 'stats.bugs_found is non-negative');
};
subtest 'CoverageGuidedFuzzer _validate_hash_input â boundary at return 1 (line 1069)' => sub {
# Kills NUM_BOUNDARY_1069_12_!= (== to != â likely on return 1 via line drift)
# The real mutation target is the return value of _validate_hash_input.
# Valid input must return 1; missing required field must return 0.
my $fuzzer = _new_fuzzer(
schema => {input => {name => {type => 'string'}, age => {type => 'integer'}}}
);
my $spec = {name => {type => 'string'}, age => {type => 'integer'}};
# Valid hash with all required fields: must return 1
is($fuzzer->_validate_hash_input({name => 'Alice', age => 30}, $spec), 1,
'valid hash returns 1');
# Missing required field: must return 0
is($fuzzer->_validate_hash_input({name => 'Alice'}, $spec), 0,
'hash with missing required field returns 0');
# Wrong-type integer field: must return 0
is($fuzzer->_validate_hash_input({name => 'Alice', age => 'thirty'}, $spec), 0,
'hash with wrong-type integer returns 0');
# Optional missing field: must return 1
my $opt_spec = {name => {type => 'string'}, age => {type => 'integer', optional => 1}};
is($fuzzer->_validate_hash_input({name => 'Alice'}, $opt_spec), 1,
'hash with missing optional field returns 1');
# Undef input: must return 0
is($fuzzer->_validate_hash_input(undef, $spec), 0,
'undef input returns 0');
};
subtest 'CoverageGuidedFuzzer _rand_array â length bounded by DEFAULT_MAX_ARRAY (lines 969, 970)' => sub {
# Kills NUM_BOUNDARY_969_47_> and NUM_BOUNDARY_970_47_<
# These survivors are attributed (via line drift) to a boundary that controls
# the maximum array length. DEFAULT_MAX_ARRAY=4, so lengths must be in [0,4].
# Over RAND_ARRAY_RUNS iterations, length 4 must appear at least once;
# a mutation reducing the upper bound to 3 would make this fail.
my $fuzzer = _new_fuzzer(
schema => {input => {type => 'arrayref', items => {type => 'integer'}}}
);
my %seen_len;
for (1 .. $RAND_ARRAY_RUNS) {
my $arr = $fuzzer->_rand_array({items => {type => 'integer'}});
is(ref($arr), 'ARRAY', '_rand_array returns an arrayref');
my $len = scalar @$arr;
ok($len >= 0 && $len <= $DEFAULT_MAX_ARRAY,
"array length $len is in [0..$DEFAULT_MAX_ARRAY]");
$seen_len{$len}++;
}
diag('Length distribution: ' . join(', ', map { "$_=$seen_len{$_}" } sort { $a <=> $b } keys %seen_len))
if $ENV{TEST_VERBOSE};
# The maximum boundary (4) must be reachable
ok($seen_len{$DEFAULT_MAX_ARRAY},
"length $DEFAULT_MAX_ARRAY (the boundary maximum) was reached in $RAND_ARRAY_RUNS runs");
};
subtest 'CoverageGuidedFuzzer run() stats integrity â coverage tracking (lines 667, 730, 762, 811, 819)' => sub {
# Kills mutations on _run_one, _run_with_cover, _snapshot_cover, _update_covered.
# These functions affect how coverage is accumulated and how interesting inputs
# are selected. Corrupting them breaks the stats counters.
my $fuzzer = _new_fuzzer(
schema => {input => {type => 'string'}},
iterations => 30,
);
my $stats = $fuzzer->run();
is(ref($stats), 'HASH', 'run() returns a hashref');
is($stats->{total_iterations}, 30, 'stats.total_iterations matches iterations');
ok($stats->{bugs_found} >= 0 && $stats->{bugs_found} <= 30, 'bugs_found in [0..iterations]');
ok($stats->{interesting_inputs} >= 0 && $stats->{interesting_inputs} <= 30, 'interesting_inputs in [0..iterations]');
is(ref($fuzzer->{corpus}), 'ARRAY', 'corpus is an arrayref after run()');
# covered hash must have been updated (at least initialized)
is(ref($fuzzer->{covered}), 'HASH', 'covered hash is a hashref after run()');
};
# ===================================================================
# SECTION 5: App::Test::Generator::Mutator
# Kills: NUM_BOUNDARY_411 (run_tests() == 0)
# ===================================================================
subtest 'Mutator run_tests() â prove exit 0 gives true, non-zero gives false (line 414)' => sub {
# Kills NUM_BOUNDARY_414_35_!= (return system(...) == 0 â == flipped to !=)
# With "==": prove exits 0 (success) â run_tests() returns true (correct)
# With "!=": prove exits 0 â 0 != 0 is false â run_tests() returns false (wrong)
# We use a minimal temp dir with a known-passing test to get a real prove exit code.
my $tmpdir = tempdir(CLEANUP => 1);
my $lib_dir = File::Spec->catdir($tmpdir, 'lib');
my $t_dir = File::Spec->catdir($tmpdir, 't');
mkdir $lib_dir;
mkdir $t_dir;
# Trivial module so Mutator->new has a valid file
my $pm = File::Spec->catfile($lib_dir, 'Dummy.pm');
open my $pmfh, '>', $pm or die "Cannot write $pm: $!";
print $pmfh "package Dummy;\n1;\n";
close $pmfh;
# Always-passing test
my $passing_t = File::Spec->catfile($t_dir, 'pass.t');
open my $tfh, '>', $passing_t or die "Cannot write $passing_t: $!";
print $tfh "use Test::More; pass('always passes'); done_testing;\n";
close $tfh;
# Always-failing test
my $failing_t = File::Spec->catfile($t_dir, 'fail.t');
open my $ffh, '>', $failing_t or die "Cannot write $failing_t: $!";
print $ffh "use Test::More; fail('always fails'); done_testing;\n";
close $ffh;
my $orig_dir = cwd();
# prove passing: run_tests() must return true (1 == 0 is false, 0 == 0 is true)
{
chdir $tmpdir;
unlink $failing_t;
my $mutator = App::Test::Generator::Mutator->new(file => 'lib/Dummy.pm');
ok($mutator->run_tests(), 'run_tests() returns true for a passing test suite');
}
# prove failing: run_tests() must return false
{
open my $fh, '>', $failing_t or die $!;
print $fh "use Test::More; fail('always fails'); done_testing;\n";
close $fh;
my $mutator = App::Test::Generator::Mutator->new(file => 'lib/Dummy.pm');
ok(!$mutator->run_tests(), 'run_tests() returns false for a failing test suite');
}
( run in 1.739 second using v1.01-cache-2.11-cpan-f03e8824b8d )