From 3084eb55e7336249090256fb792543c5bedf01b5 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 20 Jul 2026 15:28:11 +0700 Subject: [PATCH 1/5] Fix mocked dependencies classified as integration when referenced in type declarations --- src/Detection/ConsumedUsageVisitor.php | 92 +++++++++++++++++-- .../Detection/ConsumedUsageExtractorTest.php | 66 +++++++++++++ tests/Detection/UsageClassificationTest.php | 5 + 3 files changed, 154 insertions(+), 9 deletions(-) diff --git a/src/Detection/ConsumedUsageVisitor.php b/src/Detection/ConsumedUsageVisitor.php index 6e79601..38a0c82 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 ($type instanceof IntersectionType && $this->intersectsMockResultType($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 intersectsMockResultType(IntersectionType $intersectionType): bool + { + foreach ($intersectionType->types as $type) { + if ($type instanceof Name && $this->isMockResultType($type)) { + 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..a2ac427 100644 --- a/tests/Detection/ConsumedUsageExtractorTest.php +++ b/tests/Detection/ConsumedUsageExtractorTest.php @@ -180,6 +180,72 @@ 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); + } + } + PHP_WRAP); + + $this->assertSame(['class:PHPUnit\Framework\MockObject\MockObject'], $usages); + } + + public function testItIgnoresMockResultIntersectionTypesWithoutAMockCreationCall(): 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' 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, From 75019e5a5912e7b5d0d8773c0e805718627d1f24 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 20 Jul 2026 15:28:18 +0700 Subject: [PATCH 2/5] fix --- .../MockedHeavyTypedPropertyFixture.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 tests/Fixtures/MockedHeavyTypedPropertyFixture.php 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'); + } +} From 017987dbb9229aa103ca647ff6c6e6ddec208b31 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 20 Jul 2026 15:30:25 +0700 Subject: [PATCH 3/5] handle union mock --- src/Detection/ConsumedUsageVisitor.php | 8 ++++---- tests/Detection/ConsumedUsageExtractorTest.php | 8 +++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Detection/ConsumedUsageVisitor.php b/src/Detection/ConsumedUsageVisitor.php index 38a0c82..015af02 100644 --- a/src/Detection/ConsumedUsageVisitor.php +++ b/src/Detection/ConsumedUsageVisitor.php @@ -191,7 +191,7 @@ private function addType(null|Identifier|Name|ComplexType $type): void } if ($type instanceof UnionType || $type instanceof IntersectionType) { - if ($type instanceof IntersectionType && $this->intersectsMockResultType($type)) { + if ($this->containsMockResultType($type)) { foreach ($type->types as $innerType) { if ($innerType instanceof Name && ! $this->isMockResultType($innerType)) { $this->addMockTarget($innerType); @@ -227,10 +227,10 @@ private function addMockTarget(Name $name): void $this->mockTargetUsages[$this->usageKey(UsageType::ClassLike, $usage)] = true; } - private function intersectsMockResultType(IntersectionType $intersectionType): bool + private function containsMockResultType(IntersectionType|UnionType $type): bool { - foreach ($intersectionType->types as $type) { - if ($type instanceof Name && $this->isMockResultType($type)) { + foreach ($type->types as $innerType) { + if ($innerType instanceof Name && $this->isMockResultType($innerType)) { return true; } } diff --git a/tests/Detection/ConsumedUsageExtractorTest.php b/tests/Detection/ConsumedUsageExtractorTest.php index a2ac427..a3cfa60 100644 --- a/tests/Detection/ConsumedUsageExtractorTest.php +++ b/tests/Detection/ConsumedUsageExtractorTest.php @@ -203,18 +203,20 @@ protected function setUp(): void $this->assertSame(['class:PHPUnit\Framework\MockObject\MockObject'], $usages); } - public function testItIgnoresMockResultIntersectionTypesWithoutAMockCreationCall(): void + public function testItIgnoresMockResultCompositeTypesWithoutAMockCreationCall(): void { $usages = $this->extract(<<<'PHP_WRAP' Date: Mon, 20 Jul 2026 15:31:05 +0700 Subject: [PATCH 4/5] more test --- tests/Detection/ConsumedUsageExtractorTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Detection/ConsumedUsageExtractorTest.php b/tests/Detection/ConsumedUsageExtractorTest.php index a3cfa60..c45d163 100644 --- a/tests/Detection/ConsumedUsageExtractorTest.php +++ b/tests/Detection/ConsumedUsageExtractorTest.php @@ -191,11 +191,13 @@ final class Example { private \Vendor\Mocked\TypedIntersection&MockObject $intersection; private \Vendor\Mocked\TypedOnly $typedOnly; - + private MockObject $mockObjectOnly; + protected function setUp(): void { - $this->intersection = $this->createMock(\Vendor\Mocked\TypedIntersection::class); - $this->typedOnly = $this->createMock(\Vendor\Mocked\TypedOnly::class); + $this->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); From f3dbe7257bbe6c782b93f30220de56ef8be7752b Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 20 Jul 2026 15:33:26 +0700 Subject: [PATCH 5/5] more test --- tests/Detection/ConsumedUsageExtractorTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Detection/ConsumedUsageExtractorTest.php b/tests/Detection/ConsumedUsageExtractorTest.php index c45d163..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; @@ -312,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 */