diff --git a/README.md b/README.md index 3e37027..7425e2d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ Lendable PHPUnit Extensions -======================== +=========================== > [!WARNING] > This library is still in early development. diff --git a/composer.json b/composer.json index 499feaa..bc315d0 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "autoload-dev": { "psr-4": { "Tests\\Fixtures\\Lendable\\PHPUnitExtensions\\": "tests/fixtures/", + "Tests\\Phpstan\\Lendable\\PHPUnitExtensions\\": "tests/phpstan/", "Tests\\Unit\\Lendable\\PHPUnitExtensions\\": "tests/unit/" } }, @@ -61,6 +62,9 @@ "phpstan": [ "phpstan analyse --ansi --no-progress --memory-limit=-1" ], + "phpunit:phpstan": [ + "phpunit --colors --testsuite=phpstan" + ], "phpunit:unit": [ "phpunit --colors --testsuite=unit" ], @@ -77,7 +81,8 @@ "@rector:check" ], "tests": [ - "@tests:unit" + "@tests:unit", + "@phpunit:phpstan" ], "tests:unit": [ "@phpunit:unit" diff --git a/phpstan.neon b/phpstan.neon index b87a965..001b146 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,6 +3,7 @@ includes: - vendor/phpstan/phpstan-deprecation-rules/rules.neon - vendor/phpstan/phpstan-strict-rules/rules.neon - phar://vendor/phpstan/phpstan/phpstan.phar/conf/bleedingEdge.neon + - phpstan/rules.neon parameters: tmpDir: tmp/phpstan @@ -11,3 +12,9 @@ parameters: - tests level: max checkExplicitMixed: true + excludePaths: + - %currentWorkingDirectory%/tests/phpstan + lendable_phpunit: + enforceStrictMocking: + pardoned: + - Tests\Unit\Lendable\PHPUnitExtensions\TestCaseTest diff --git a/phpstan/rules.neon b/phpstan/rules.neon new file mode 100644 index 0000000..9d15959 --- /dev/null +++ b/phpstan/rules.neon @@ -0,0 +1,23 @@ +conditionalTags: + Lendable\PHPUnitExtensions\Phpstan\Rule\EnforceStrictMocking: + phpstan.rules.rule: %lendable_phpunit.enforceStrictMocking.enabled% + +parametersSchema: + lendable_phpunit: structure([ + enforceStrictMocking: structure([ + enabled: bool() + pardoned: listOf(string()) + ]) + ]) + +parameters: + lendable_phpunit: + enforceStrictMocking: + enabled: true + pardoned: [] + +services: + - + class: Lendable\PHPUnitExtensions\Phpstan\Rule\EnforceStrictMocking + arguments: + pardoned: %lendable_phpunit.enforceStrictMocking.pardoned% diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3ca1769..bd3b7e1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,5 +17,9 @@ ./tests/unit/ + + ./tests/phpstan/ + ./tests/phpstan/data/ + diff --git a/src/Phpstan/Rule/EnforceStrictMocking.php b/src/Phpstan/Rule/EnforceStrictMocking.php new file mode 100644 index 0000000..5742a5d --- /dev/null +++ b/src/Phpstan/Rule/EnforceStrictMocking.php @@ -0,0 +1,91 @@ + + */ +final class EnforceStrictMocking implements Rule +{ + /** + * @var array + */ + private readonly array $pardoned; + + /** + * @param list $pardoned + */ + public function __construct(array $pardoned) + { + $this->pardoned = \array_flip($pardoned); + } + + public function getNodeType(): string + { + return Class_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->namespacedName instanceof Name) { + return []; + } + + if (!$node->extends instanceof Name) { + return []; + } + + if ($node->isAbstract()) { + return []; + } + + $className = $node->namespacedName->toString(); + if (!\str_ends_with($className, 'Test')) { + return []; + } + + if (isset($this->pardoned[$className])) { + return []; + } + + $reflection = $scope->resolveTypeByName($node->namespacedName)->getClassReflection(); + if (!$reflection instanceof ClassReflection) { + return []; + } + + $parents = $reflection->getParentClassesNames(); + if (!\in_array(TestCase::class, $parents, true)) { + return []; + } + + if (\in_array(StrictMockingTestCase::class, $parents, true)) { + return []; + } + + if (isset($reflection->getTraits(true)[StrictMockingTrait::class])) { + return []; + } + + $ruleErrorBuilder = RuleErrorBuilder::message(\sprintf( + 'Class "%s" must either extend "%s" or use "%s" trait.', + $className, + StrictMockingTestCase::class, + StrictMockingTrait::class, + )); + + return [$ruleErrorBuilder->build()]; + } +} diff --git a/tests/phpstan/Rule/EnforceExtendedClassTest.php b/tests/phpstan/Rule/EnforceExtendedClassTest.php new file mode 100644 index 0000000..8a7d2df --- /dev/null +++ b/tests/phpstan/Rule/EnforceExtendedClassTest.php @@ -0,0 +1,79 @@ +analyse([__DIR__.'/../data/TestCaseTest.php'], [ + [ + $this->errorMessageFor(TestCaseTest::class), + 9, + ], + ]); + } + + #[Test] + public function does_not_report_abstract_test_directly_extending_phpunits_test_case(): void + { + $this->analyse([__DIR__.'/../data/AbstractTestCaseTest.php'], []); + } + + #[Test] + public function reports_test_indirectly_extending_phpunits_test_case(): void + { + $this->analyse([__DIR__.'/../data/IndirectlyExtendingTest.php'], [ + [ + $this->errorMessageFor(IndirectlyExtendingTest::class), + 7, + ], + ]); + } + + #[Test] + public function does_not_report_test_extending_strict_mocking(): void + { + $this->analyse([__DIR__.'/../data/StrictMockingTestCaseTest.php'], []); + } + + #[Test] + public function does_not_report_test_directly_using_strict_mocking_trait(): void + { + $this->analyse([__DIR__.'/../data/StrictMockingTraitTest.php'], []); + } + + #[Test] + public function does_not_report_test_indirectly_using_strict_mocking_trait(): void + { + $this->analyse([__DIR__.'/../data/IndirectStrictMockingTraitTest.php'], []); + } + + protected function getRule(): EnforceStrictMocking + { + return new EnforceStrictMocking([]); + } + + private function errorMessageFor(string $class): string + { + return \sprintf( + 'Class "%s" must either extend "%s" or use "%s" trait.', + $class, + TestCase::class, + StrictMocking::class, + ); + } +} diff --git a/tests/phpstan/data/AbstractTestCaseTest.php b/tests/phpstan/data/AbstractTestCaseTest.php new file mode 100644 index 0000000..e4ab258 --- /dev/null +++ b/tests/phpstan/data/AbstractTestCaseTest.php @@ -0,0 +1,9 @@ +