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 @@
+