diff --git a/lib/GPH.pm b/lib/GPH.pm index 1555e5f..f0f4fab 100644 --- a/lib/GPH.pm +++ b/lib/GPH.pm @@ -3,7 +3,7 @@ package GPH; use strict; use warnings FATAL => 'all'; -our $VERSION = '1.4.0'; +our $VERSION = '1.4.1'; 1; @@ -55,7 +55,7 @@ generate custom configuration file for L =head1 VERSION -1.4.0 +1.4.1 =head1 AUTHOR diff --git a/lib/GPH/PHPUnit/Teardown.pm b/lib/GPH/PHPUnit/Teardown.pm new file mode 100644 index 0000000..e61c51b --- /dev/null +++ b/lib/GPH/PHPUnit/Teardown.pm @@ -0,0 +1,178 @@ +package GPH::PHPUnit::Teardown; + +use strict; +use warnings FATAL => 'all'; + +use GPH::PHPUnit::Teared; + +sub new { + my ($proto, %args) = @_; + + exists($args{files}) or die "file must be defined: $!"; + + my $self = bless { + files => $args{files}, + debug => $args{debug} // 0, + strict => $args{strict} // 0, + teared => {}, + }, $proto; + + return ($self); +} + +sub parse { + my ($self) = @_; + my ($fh); + + foreach my $file (@{$self->{files}}) { + next unless $file =~ /Test|TestCase$/; + + open($fh, '<', $file) or die "$!"; + + print "processing file: $file\n" unless $self->{debug} == 0; + + my $teardown = 0; + my %properties = (); + my %teared = (); + my $in_teardown = 0; + my $seen_test = 0; + + while (<$fh>) { + chomp $_; + + # ignore comments and blank lines + next if $_ =~ /^[\s]{0,}[\/]{0,1}[\*]{1,2}/ or $_ eq '' or $_ =~ /^[\s]*\/\//; + + # collect properties. strict mode uses all properties while in non strict mode non initialized & empty properties are used + my $pattern = $self->{strict} == 0 + ? '^[\s]*(?:private|public|protected)\s(?:static ){0,}([^\s]{0,})\s*\$([^\s;]+(?=;|\s=\s(?:\[\]|null)))' + : '^[\s]*(?:private|public|protected)\s(?:static ){0,}([^\s]{0,})\s*\$([^\s;]+)'; + + if ($seen_test == 0 && $_ =~ /$pattern/) { + $properties{$2} = $1; + print " property: $2 type: $1\n" unless $self->{debug} == 0; + + next; + } + + # assuming class properties are not defined all over the place + if ($_ =~ 'public function test') { + $seen_test = 1; + } + + # check teardown methods + if ($_ =~ '([\s]+)(?:protected |public )function tearDown\(\): void' + or $_ =~ '([\s]+)(?:protected |public )static function tearDownAfterClass\(\): void' + ) { + $teardown = 1; + $in_teardown = 1; + my $spaces = $1; + + print " has teardown\n" unless $self->{debug} == 0; + + while ($in_teardown == 1) { + my $line = <$fh>; + chomp $line; + + my @matches = $line =~ /\$this->(\w+(?=(?:[ ,\)]|$)))/g; + my @statics = $line =~ /self::\$(\w+(?=(?:[ ,]|$)))/g; + + foreach my $match (@matches, @statics) { + print " property: $match was found in teardown\n" unless $self->{debug} == 0; + $teared{$match} = 1; + } + + if ($line =~ /$spaces}$/) { + $in_teardown = 0; + last; + } + } + } + } + + close($fh); + + $self->{teared}{$file} = GPH::PHPUnit::Teared->new(( + file => $file, + teardown => $teardown, + properties => \%properties, + teared => \%teared, + )); + } + + return ($self); +}; + +sub validate { + my ($self) = @_; + my $exit = 0; + + foreach my $teared (sort keys %{$self->{teared}}) { + if ($self->{teared}{$teared}->isValid() != 1 && $exit == 0) { + $exit = 1; + } + } + + return ($exit); +}; + +1; + +__END__ + +=head1 NAME + +GPH::PHPUnit::Teardown - module to validate correct teardown behaviour of PHPUnit test classes. + +see https://docs.phpunit.de/en/10.5/fixtures.html#more-setup-than-teardown for further information + +=head1 SYNOPSIS + + use GPH::PHPUnit::Teardown; + + my $teardown = GPH::PHPUnit::Teardown->new((files => ['foo.php', 'bar.php'], debug => 1); + $teardown->parse(); + +=head1 METHODS + +=over 4 + +=item C<< -Enew(%args) >> + +the C method returns a GPH::PHPUnit::Teardown object. it takes a hash of options, valid option keys include: + +=over + +=item files B<(required)> + +an array of file paths of files which you'd like to analyse. + +=item debug + +boolean whether or not to debug the parsing process. + +=item strict + +boolean whether or not to parse in strict mode (i.e. use all class properties regardless of initialisation state). + +=back + +=item C<< -Eparse() >> + +parse the files defined in C<< $self->{files} >> + +=item C<< -Evalidate() >> + +validate the parsed files. returns exit code 0 when all files are valid, 1 if one or more files are invalid + +=back + +=head1 AUTHOR + +the GPH::PHPUnit::Teardown module was written by wicliff wolda + +=head1 COPYRIGHT AND LICENSE + +this library is free software; you can redistribute it and/or modify it under the same terms as Perl itself. + +=cut diff --git a/lib/GPH/PHPUnit/Teared.pm b/lib/GPH/PHPUnit/Teared.pm new file mode 100644 index 0000000..7bcaed2 --- /dev/null +++ b/lib/GPH/PHPUnit/Teared.pm @@ -0,0 +1,111 @@ +package GPH::PHPUnit::Teared; + +use strict; +use warnings FATAL => 'all'; + +sub new { + my ($proto, %args) = @_; + + exists($args{file}) or die "file must be defined: $!"; + + my $self = bless { + file => $args{file}, + teardown => $args{teardown} // 0, + properties => $args{properties} // {}, + teared => $args{teared} // {}, + }, $proto; + + return ($self); +} + +sub isValid { + my ($self) = @_; + my $properties = keys %{$self->{properties}}; + + if ($properties > 0 && $self->{teardown} == 0) { + print "file $self->{file} is invalid: has properties, but no teardown\n"; + + return(0); + } + + my @missing = (); + foreach (keys %{$self->{properties}}) { + push (@missing, $_) unless exists($self->{teared}{$_}); + } + + @missing = sort @missing; + + my $missing = @missing; + + if ($missing > 0) { + print "file $self->{file} is invalid: propert" . ($missing == 1 ? "y '": "ies '") . join("', '", @missing) . "' " . ($missing == 1 ? "is ": "are ") . "not teared down\n"; + + return(0); + } + + return(1); +}; + +1; + +__END__ + +=head1 NAME + +GPH::PHPUnit::Teared - data object for GPH::PHPUnit::Teardown module + +=head1 SYNOPSIS + + use GPH::PHPUnit::Teared; + + my $teared = GPH::PHPUnit::Teared->new((file => 'foo.php', teardown => 1, properties => ('bar' => 1), teared => ('bar' => 1))); + $teared->isValid(); + +=head1 METHODS + +=over 4 + +=item C<< -Enew(%args) >> + +the C method returns a GPH::PHPUnit::Teared object. it takes a hash of options, valid option keys include: + +=over + +=item file B<(required)> + +file (path) name used for validation output. + +=item teardown + +boolean whether or not the file contains a teardown method (can be tearDown or tearDownAfterClass). + +=item properties + +hash of class properties of the file + +=item teared + +hash of class properties which are 'touched' within a teardown method. + +=back + +=item C<< -EisValid() >> + +validates the teardown behaviour of the file: + + - if it has properties, a teardown method is required. + - if it has properties and one or more teardown methods, all properties need to be 'touched' within those methods. + +returns 1 if valid, 0 otherwise. + +=back + +=head1 AUTHOR + +the GPH::PHPUnit::Teared module was written by wicliff wolda + +=head1 COPYRIGHT AND LICENSE + +this library is free software; you can redistribute it and/or modify it under the same terms as Perl itself. + +=cut diff --git a/t/share/PHPUnit/InvalidTeardownTestCase.php b/t/share/PHPUnit/InvalidTeardownTestCase.php new file mode 100644 index 0000000..7f6b8ee --- /dev/null +++ b/t/share/PHPUnit/InvalidTeardownTestCase.php @@ -0,0 +1,29 @@ + + */ +class InvalidTeardownTestCase extends TestCase +{ + private ?int $foo = null; + private static array $fixtures = []; + private $bar; + + public function tearDown(): void + { + unset($this->foo); + } + + public function testFooBar(): void + { + } + + protected static function tearDownAfterClass(): void + { + self::$fixtures = []; + } +} diff --git a/t/share/PHPUnit/TeardownTest.php b/t/share/PHPUnit/TeardownTest.php new file mode 100644 index 0000000..4d926d9 --- /dev/null +++ b/t/share/PHPUnit/TeardownTest.php @@ -0,0 +1,39 @@ + + */ +class TeardownTest extends TestCase +{ + private ?int $foo = null; + private array $history = ['Foo', 'Bar']; + private string $bar = 'qux'; + // comment + private array $fixtures = []; + private Configuration $config; + public static ?FooProvider $fooProvider; + public static BarProvider $barProvider; + + public function tearDown(): void + { + unset($this->foo, $this->fixtures); + + $this->config->reset(); + } + + public function testFooBar(): void + { + } + + protected static function tearDownAfterClass(): void + { + self::$barProvider->reset(); + self::$fooProvider = null; + } + + private Processor $processor; +} diff --git a/t/share/PHPUnit/ValidTeardownTestCase.php b/t/share/PHPUnit/ValidTeardownTestCase.php new file mode 100644 index 0000000..23fd3a5 --- /dev/null +++ b/t/share/PHPUnit/ValidTeardownTestCase.php @@ -0,0 +1,28 @@ + + */ +class ValidTeardownTestCase extends TestCase +{ + private ?int $foo = null; + private static array $fixtures = []; + + public function tearDown(): void + { + unset($this->foo); + } + + public function testFooBar(): void + { + } + + protected static function tearDownAfterClass(): void + { + self::$fixtures = []; + } +} diff --git a/t/unit/GPH/PHPUnit/Teardown.t b/t/unit/GPH/PHPUnit/Teardown.t new file mode 100644 index 0000000..1f24213 --- /dev/null +++ b/t/unit/GPH/PHPUnit/Teardown.t @@ -0,0 +1,291 @@ +#!/usr/bin/perl +package t::unit::GPH::PHPUnit::Teardown; + +use strict; +use warnings; + +use Test2::V0 -target => 'GPH::PHPUnit::Teardown'; +use Test2::Tools::Spec; + +use Data::Dumper; + +describe "class `$CLASS` instantiation" => sub { + tests 'it can be instantiated' => sub { + can_ok($CLASS, 'new'); + }; + + tests "mandatory config options" => sub { + ok(dies {$CLASS->new()}, 'died with missing files option') or note($@); + }; + + tests 'instantiation with default values' => sub { + my ($object, $exception, $warnings); + + $exception = dies { + $warnings = warns { + $object = $CLASS->new((files => ['foo.php'])); + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is( + $object, + object { + field files => array { + item 'foo.php'; + end; + }; + field debug => 0; + field strict => 0; + field teared => hash { + end; + }; + end; + }, + 'object as expected', + Dumper($object) + ); + }; + + tests 'instantiation with given values' => sub { + my ($object, $exception, $warnings); + + $exception = dies { + $warnings = warns { + $object = $CLASS->new((files => ['foo.php'], debug => 1, strict => 1)); + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is( + $object, + object { + field files => array { + item 'foo.php'; + end; + }; + field debug => 1; + field strict => 1; + field teared => hash { + end; + }; + end; + }, + 'object as expected', + Dumper($object) + ); + }; +}; + +describe "class `$CLASS` parse method" => sub { + my $file = 't/share/PHPUnit/TeardownTest.php'; + + tests "parse non existing file" => sub { + ok(dies {$CLASS->new(files => ['fooTest.php'])->parse()}, 'died with non existing file') or note($@); + }; + + tests 'parse text file' => sub { + my ($object, $exception, $warnings); + + $exception = dies { + $warnings = warns { + $object = $CLASS->new((files => ['t/share/PHPUnit/phpunit-baseline.txt']))->parse(); + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is( + $object, + object { + field files => array { + item 't/share/PHPUnit/phpunit-baseline.txt'; + end; + }; + field debug => 0; + field strict => 0; + field teared => hash { + end; + }; + end; + }, + 'object as expected', + Dumper($object) + ); + }; + + tests 'parse test class' => sub { + my ($object, $exception, $warnings); + + $exception = dies { + $warnings = warns { + $object = $CLASS->new((files => [$file]))->parse(); + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is( + $object, + object { + field files => array { + item $file; + end; + }; + field debug => 0; + field strict => 0; + field teared => hash { + field $file => object { + prop blessed => 'GPH::PHPUnit::Teared'; + + field file => $file; + field teardown => 1; + field properties => hash { + field 'foo' => '?int'; + field 'fixtures' => 'array'; + field 'config' => 'Configuration'; + field 'fooProvider' => '?FooProvider'; + field 'barProvider' => 'BarProvider'; + end; + }; + field teared => hash { + field 'foo' => 1; + field 'fixtures' => 1; + field 'fooProvider' => 1; + end; + }; + }; + end; + }; + end; + }, + 'object as expected', + Dumper($object) + ); + }; + + tests 'parse test class with debug' => sub { + my ($object, $exception, $warnings, $stdout); + + my $expected = "processing file: t/share/PHPUnit/TeardownTest.php + property: foo type: ?int + property: fixtures type: array + property: config type: Configuration + property: fooProvider type: ?FooProvider + property: barProvider type: BarProvider + has teardown + property: foo was found in teardown + property: fixtures was found in teardown + has teardown + property: fooProvider was found in teardown"; + + $exception = dies { + $warnings = warns { + local *STDOUT; + + open *STDOUT, '>', \$stdout; + + $object = $CLASS->new((files => [$file], debug => 1))->parse(); + + close *STDOUT; + chomp $stdout; + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is($stdout, $expected, 'stdout as expected') or diag Dumper($stdout); + }; + + tests 'parse test class in strict mode' => sub { + my ($object, $exception, $warnings); + + $exception = dies { + $warnings = warns { + $object = $CLASS->new((files => [$file], strict => 1))->parse(); + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is( + $object, + object { + field files => array { + item $file; + end; + }; + field debug => 0; + field strict => 1; + field teared => hash { + field $file => object { + prop blessed => 'GPH::PHPUnit::Teared'; + + field file => $file; + field teardown => 1; + field properties => hash { + field 'fixtures' => 'array'; + field 'history' => 'array'; + field 'config' => 'Configuration'; + field 'foo' => '?int'; + field 'barProvider' => 'BarProvider'; + field 'bar' => 'string'; + field 'fooProvider' => '?FooProvider'; + end; + }; + field teared => hash { + field 'foo' => 1; + field 'fixtures' => 1; + field 'fooProvider' => 1; + end; + }; + }; + end; + }; + end; + }, + 'object as expected', + Dumper($object) + ); + }; +}; +describe "class `$CLASS` validate method" => sub { + tests 'validate' => sub { + my ($result, $object, $stdout, $exception, $warnings); + + $exception = dies { + $warnings = warns { + local *STDOUT; + + open *STDOUT, '>', \$stdout; + + $object = $CLASS + ->new((files => ['t/share/PHPUnit/ValidTeardownTestCase.php', 't/share/PHPUnit/TeardownTest.php', 't/share/PHPUnit/InvalidTeardownTestCase.php'])) + ->parse(); + + $result = $object->validate(); + + close *STDOUT; + chomp $stdout; + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is($result, 1, 'teardown check has invalid files') or diag Dumper($object); + is($stdout, 'file t/share/PHPUnit/InvalidTeardownTestCase.php is invalid: property \'bar\' is not teared down +file t/share/PHPUnit/TeardownTest.php is invalid: properties \'barProvider\', \'config\' are not teared down', 'stdout as expected') or diag Dumper($stdout); + }; +}; + +done_testing(); + diff --git a/t/unit/GPH/PHPUnit/Teared.t b/t/unit/GPH/PHPUnit/Teared.t new file mode 100644 index 0000000..badcb3f --- /dev/null +++ b/t/unit/GPH/PHPUnit/Teared.t @@ -0,0 +1,201 @@ +#!/usr/bin/perl +package t::unit::GPH::PHPUnit::Teared; + +use strict; +use warnings; + +use Test2::V0 -target => 'GPH::PHPUnit::Teared'; +use Test2::Tools::Spec; + +use Data::Dumper; + +describe "class `$CLASS` instantiation" => sub { + tests 'it can be instantiated' => sub { + can_ok($CLASS, 'new'); + }; + + tests "mandatory config options" => sub { + ok(dies {$CLASS->new()}, 'died with missing file option') or note($@); + }; + + tests 'instantiation with default values' => sub { + my ($object, $exception, $warnings); + + $exception = dies { + $warnings = warns { + $object = $CLASS->new((file => 'foo.php')); + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is( + $object, + object { + field file => 'foo.php'; + field teardown => 0; + field properties => hash { + end; + }; + field teared => hash { + end; + }; + end; + }, + 'object as expected', + Dumper($object) + ); + }; + + tests 'instantiation with given values' => sub { + my ($object, $exception, $warnings); + + $exception = dies { + $warnings = warns { + $object = $CLASS->new(( + file => 'foo.php', + teardown => 1, + properties => { 'foo' => 1 }, + teared => { 'foo' => 1 }, + )); + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is( + $object, + object { + field file => 'foo.php'; + field teardown => 1; + field properties => hash { + field 'foo' => 1; + end; + }; + field teared => hash { + field 'foo' => 1; + end; + }; + end; + }, + 'object as expected', + Dumper($object) + ); + }; +}; + +describe "class `$CLASS` is valid method without teardown" => sub { + tests 'invalid no teardown' => sub { + my ($object, $exception, $warnings, $stdout); + + $exception = dies { + $warnings = warns { + local *STDOUT; + + open *STDOUT, '>', \$stdout; + + $object = $CLASS->new(( + file => 'foo.php', + teardown => 0, + properties => { 'foo' => 1 }, + ))->isValid(); + + close *STDOUT; + chomp $stdout; + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is($object, 0, 'file is not valid') or diag Dumper($object); + is($stdout, 'file foo.php is invalid: has properties, but no teardown', 'stdout correct') or diag Dumper($stdout); + }; +}; + +describe "class `$CLASS` is valid method without teared down properties" => sub { + my ($object, $exception, $warnings, $stdout, %properties, $output); + + case 'single property' => sub { + %properties = ( + foo => 1, + ); + $output = 'file foo.php is invalid: property \'foo\' is not teared down'; + }; + + case 'multiple properties' => sub { + %properties = ( + foo => 1, + bar => 1, + ); + $output = 'file foo.php is invalid: properties \'bar\', \'foo\' are not teared down'; + }; + + tests 'invalid properties not touched' => sub { + $exception = dies { + $warnings = warns { + local *STDOUT; + + open *STDOUT, '>', \$stdout; + + $object = $CLASS->new(( + file => 'foo.php', + teardown => 1, + properties => \%properties, + ))->isValid(); + + close *STDOUT; + chomp $stdout; + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is($object, 0, 'file is not valid') or diag Dumper($object); + is($stdout, $output, 'stdout correct') or diag Dumper($stdout); + }; +}; + +describe "class `$CLASS` is valid" => sub { + my ($object, $exception, $warnings); + + tests 'valid all properties are teared down' => sub { + $exception = dies { + $warnings = warns { + $object = $CLASS->new(( + file => 'foo.php', + teardown => 1, + properties => { 'foo' => 1 }, + teared => { 'foo' => 1 }, + ))->isValid(); + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is($object, 1, 'file is valid') or diag Dumper($object); + }; + + tests 'valid no properties defined' => sub { + $exception = dies { + $warnings = warns { + $object = $CLASS->new(( + file => 'foo.php', + teardown => 0, + ))->isValid(); + }; + }; + + is($exception, undef, 'no exception thrown'); + is($warnings, 0, 'no warnings generated'); + + is($object, 1, 'file is valid') or diag Dumper($object); + }; +}; + +done_testing(); +