Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 83 additions & 9 deletions src/Detection/ConsumedUsageVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,6 +42,18 @@ final class ConsumedUsageVisitor extends NodeVisitorAbstract
/** @var array<string, true> */
private array $consumedUsages = [];

/** @var array<string, true> */
private array $typeOnlyUsages = [];

/** @var array<string, true> */
private array $mockTargetUsages = [];

/** @var array<string, true> */
private const MOCK_RESULT_TYPES = [
MockObject::class => true,
Stub::class => true,
];

/** @var array<string, true> */
private const MOCK_METHODS = [
'createMock' => true,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand All @@ -162,21 +191,56 @@ 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;
}

// 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
Expand All @@ -190,15 +254,25 @@ 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, '\\');

if ($usage === '' || isset(self::RESERVED_CLASS_NAMES[strtolower($usage)])) {
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
Expand Down
83 changes: 83 additions & 0 deletions tests/Detection/ConsumedUsageExtractorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -180,6 +182,76 @@ function_call(\Vendor\ClassConstant\FunctionArgument::class);
], $usages);
}

public function testItIgnoresTypeDeclarationsOfMockedClasses(): void
{
$usages = $this->extract(<<<'PHP_WRAP'
<?php

use PHPUnit\Framework\MockObject\MockObject;

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->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'
<?php

use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Stub;

final class Example
{
private \Vendor\Mocked\MockedDependency&MockObject $mock;
private \Vendor\Mocked\StubbedDependency&Stub $stub;
private \Vendor\Mocked\UnionDependency|MockObject $unionMock;
private \Vendor\Mocked\NullableUnionDependency|Stub|null $nullableUnionStub;
}
PHP_WRAP);

sort($usages);

$this->assertSame([
'class:PHPUnit\Framework\MockObject\MockObject',
'class:PHPUnit\Framework\MockObject\Stub',
], $usages);
}

public function testItKeepsMockedClassesUsedOutsideTypeDeclarations(): void
{
$usages = $this->extract(<<<'PHP'
<?php

final class Example
{
private \Vendor\Mocked\RealDependency $dependency;

public function method(): void
{
$this->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'
Expand Down Expand Up @@ -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<string>
*/
Expand Down
5 changes: 5 additions & 0 deletions tests/Detection/UsageClassificationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions tests/Fixtures/MockedHeavyTypedPropertyFixture.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Boundwize\Pyrameter\Tests\Fixtures;

use PDO;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

final class MockedHeavyTypedPropertyFixture extends TestCase
{
private PDO&MockObject $pdo;

protected function setUp(): void
{
$this->pdo = $this->createMock(PDO::class);
}

public function testItMocksAHeavyDependency(): void
{
$this->pdo->expects($this->never())->method('query');
}
}
Loading