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 )