diff --git a/src/Detection/ConsumedUsageVisitor.php b/src/Detection/ConsumedUsageVisitor.php index 6e79601..015af02 100644 --- a/src/Detection/ConsumedUsageVisitor.php +++ b/src/Detection/ConsumedUsageVisitor.php @@ -29,6 +29,8 @@ use PhpParser\Node\Stmt\TraitUse; use PhpParser\Node\UnionType; use PhpParser\NodeVisitorAbstract; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use function array_keys; use function ltrim; @@ -40,6 +42,18 @@ final class ConsumedUsageVisitor extends NodeVisitorAbstract /** @var array */ private array $consumedUsages = []; + /** @var array */ + private array $typeOnlyUsages = []; + + /** @var array */ + private array $mockTargetUsages = []; + + /** @var array */ + private const MOCK_RESULT_TYPES = [ + MockObject::class => true, + Stub::class => true, + ]; + /** @var array */ private const MOCK_METHODS = [ 'createMock' => true, @@ -96,9 +110,12 @@ public function enterNode(Node $node): null $node instanceof ClassConstFetch && $node->class instanceof Name && $this->isClassConstant($node) - && $node->getAttribute('isMockTarget', false) !== true ) { - $this->addName($node->class); + if ($node->getAttribute('isMockTarget', false) === true) { + $this->addMockTarget($node->class); + } else { + $this->addName($node->class); + } } if ($node instanceof MethodCall || $node instanceof StaticCall) { @@ -141,18 +158,30 @@ public function enterNode(Node $node): null */ public function consumedUsages(): array { - return array_keys($this->consumedUsages); + $consumedUsages = $this->consumedUsages; + + // A mocked class only referenced by type declarations (`private PDO&MockObject $pdo`) + // is not a real dependency of the test, so it must not trigger heavier rules. + foreach (array_keys($this->mockTargetUsages) as $usageKey) { + if (isset($this->typeOnlyUsages[$usageKey])) { + unset($consumedUsages[$usageKey]); + } + } + + return array_keys($consumedUsages); } public function reset(): void { - $this->consumedUsages = []; + $this->consumedUsages = []; + $this->typeOnlyUsages = []; + $this->mockTargetUsages = []; } private function addType(null|Identifier|Name|ComplexType $type): void { if ($type instanceof Name) { - $this->addName($type); + $this->addName($type, fromType: true); return; } @@ -162,13 +191,21 @@ private function addType(null|Identifier|Name|ComplexType $type): void } if ($type instanceof UnionType || $type instanceof IntersectionType) { + if ($this->containsMockResultType($type)) { + foreach ($type->types as $innerType) { + if ($innerType instanceof Name && ! $this->isMockResultType($innerType)) { + $this->addMockTarget($innerType); + } + } + } + foreach ($type->types as $innerType) { $this->addType($innerType); } } } - private function addName(?Name $name): void + private function addName(?Name $name, bool $fromType = false): void { if (! $name instanceof Name) { return; @@ -176,7 +213,34 @@ private function addName(?Name $name): void // NameResolver runs with replaceNodes = true, so names are already resolved // in place and toString() yields the fully-qualified name. - $this->addUsage($name->toString()); + $this->addUsage($name->toString(), $fromType); + } + + private function addMockTarget(Name $name): void + { + $usage = ltrim($name->toString(), '\\'); + + if ($usage === '') { + return; + } + + $this->mockTargetUsages[$this->usageKey(UsageType::ClassLike, $usage)] = true; + } + + private function containsMockResultType(IntersectionType|UnionType $type): bool + { + foreach ($type->types as $innerType) { + if ($innerType instanceof Name && $this->isMockResultType($innerType)) { + return true; + } + } + + return false; + } + + private function isMockResultType(Name $name): bool + { + return isset(self::MOCK_RESULT_TYPES[ltrim($name->toString(), '\\')]); } private function addFunctionName(Name $name): void @@ -190,7 +254,7 @@ private function addFunctionName(Name $name): void $this->consumedUsages[$this->usageKey(UsageType::Function, $functionName)] = true; } - private function addUsage(string $usage): void + private function addUsage(string $usage, bool $fromType = false): void { $usage = ltrim($usage, '\\'); @@ -198,7 +262,17 @@ private function addUsage(string $usage): void return; } - $this->consumedUsages[$this->usageKey(UsageType::ClassLike, $usage)] = true; + $usageKey = $this->usageKey(UsageType::ClassLike, $usage); + + if ($fromType) { + if (! isset($this->consumedUsages[$usageKey])) { + $this->typeOnlyUsages[$usageKey] = true; + } + } else { + unset($this->typeOnlyUsages[$usageKey]); + } + + $this->consumedUsages[$usageKey] = true; } private function usageKey(UsageType $usageType, string $usage): string diff --git a/tests/Detection/ConsumedUsageExtractorTest.php b/tests/Detection/ConsumedUsageExtractorTest.php index d87f728..e0a1152 100644 --- a/tests/Detection/ConsumedUsageExtractorTest.php +++ b/tests/Detection/ConsumedUsageExtractorTest.php @@ -6,7 +6,9 @@ use Boundwize\Pyrameter\Detection\ConsumedUsageExtractor; use Boundwize\Pyrameter\Detection\ConsumedUsageVisitor; +use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\ParserFactory; use PHPUnit\Framework\TestCase; @@ -180,6 +182,76 @@ function_call(\Vendor\ClassConstant\FunctionArgument::class); ], $usages); } + public function testItIgnoresTypeDeclarationsOfMockedClasses(): void + { + $usages = $this->extract(<<<'PHP_WRAP' + intersection = $this->createMock(\Vendor\Mocked\TypedIntersection::class); + $this->typedOnly = $this->createMock(\Vendor\Mocked\TypedOnly::class); + $this->mockObjectOnly = $this->createMock(\Vendor\Mocked\MockObjectOnlyTyped::class); + } + } + PHP_WRAP); + + $this->assertSame(['class:PHPUnit\Framework\MockObject\MockObject'], $usages); + } + + public function testItIgnoresMockResultCompositeTypesWithoutAMockCreationCall(): void + { + $usages = $this->extract(<<<'PHP_WRAP' + assertSame([ + 'class:PHPUnit\Framework\MockObject\MockObject', + 'class:PHPUnit\Framework\MockObject\Stub', + ], $usages); + } + + public function testItKeepsMockedClassesUsedOutsideTypeDeclarations(): void + { + $usages = $this->extract(<<<'PHP' +createMock(\Vendor\Mocked\RealDependency::class); + new \Vendor\Mocked\RealDependency(); + } +} +PHP); + + $this->assertSame(['class:Vendor\Mocked\RealDependency'], $usages); + } + public function testItExtractsFunctionCalls(): void { $usages = $this->extract(<<<'PHP' @@ -242,6 +314,17 @@ public function testItIgnoresEmptyFunctionNames(): void $this->assertSame([], $consumedUsageVisitor->consumedUsages()); } + public function testItIgnoresEmptyMockTargetClassNames(): void + { + $classConstFetch = new ClassConstFetch(new Name(['']), new Identifier('class')); + $classConstFetch->setAttribute('isMockTarget', true); + + $consumedUsageVisitor = new ConsumedUsageVisitor(); + $consumedUsageVisitor->enterNode($classConstFetch); + + $this->assertSame([], $consumedUsageVisitor->consumedUsages()); + } + /** * @return list */ diff --git a/tests/Detection/UsageClassificationTest.php b/tests/Detection/UsageClassificationTest.php index cb8a37a..42f906f 100644 --- a/tests/Detection/UsageClassificationTest.php +++ b/tests/Detection/UsageClassificationTest.php @@ -17,6 +17,7 @@ use Boundwize\Pyrameter\Tests\Fixtures\FunctionalAndIntegrationFixture; use Boundwize\Pyrameter\Tests\Fixtures\IntegrationAndE2EFixture; use Boundwize\Pyrameter\Tests\Fixtures\MockedHeavyFixture; +use Boundwize\Pyrameter\Tests\Fixtures\MockedHeavyTypedPropertyFixture; use Boundwize\Pyrameter\Tests\Fixtures\MysqliRealUsageFixture; use Boundwize\Pyrameter\Tests\Fixtures\PantherE2EFixture; use Boundwize\Pyrameter\Tests\Fixtures\PdoRealUsageFixture; @@ -70,6 +71,10 @@ public static function classificationCases(): iterable yield 'Panther usage means e2e' => [PantherE2EFixture::class, TestKind::E2E]; yield 'WebDriver usage means e2e' => [WebDriverE2EFixture::class, TestKind::E2E]; yield 'mocked heavy class stays unit' => [MockedHeavyFixture::class, TestKind::Unit]; + yield 'mocked heavy class in typed property stays unit' => [ + MockedHeavyTypedPropertyFixture::class, + TestKind::Unit, + ]; yield 'container class fetch is consumed' => [ContainerGetHeavyFixture::class, TestKind::Integration]; yield 'functional plus integration chooses integration' => [ FunctionalAndIntegrationFixture::class, diff --git a/tests/Fixtures/MockedHeavyTypedPropertyFixture.php b/tests/Fixtures/MockedHeavyTypedPropertyFixture.php new file mode 100644 index 0000000..192aaf5 --- /dev/null +++ b/tests/Fixtures/MockedHeavyTypedPropertyFixture.php @@ -0,0 +1,24 @@ +pdo = $this->createMock(PDO::class); + } + + public function testItMocksAHeavyDependency(): void + { + $this->pdo->expects($this->never())->method('query'); + } +}