Skip to content
Open
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
5 changes: 5 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ services:
class: PHPStan\Type\PHPUnit\MockBuilderDynamicReturnTypeExtension
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
-
class: PHPStan\Type\PHPUnit\MockForIntersectionDynamicReturnTypeExtension
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
- phpstan.broker.dynamicStaticMethodReturnTypeExtension
-
class: PHPStan\Rules\PHPUnit\CoversHelper
-
Expand Down
92 changes: 92 additions & 0 deletions src/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\PHPUnit;

use PhpParser\Node\Arg;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use function count;
use function strtolower;

class MockForIntersectionDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension, DynamicStaticMethodReturnTypeExtension
{

public function getClass(): string
{
return TestCase::class;
}

public function isMethodSupported(MethodReflection $methodReflection): bool
{
return strtolower($methodReflection->getName()) === 'createmockforintersectionofinterfaces';
}

public function isStaticMethodSupported(MethodReflection $methodReflection): bool
{
return strtolower($methodReflection->getName()) === 'createstubforintersectionofinterfaces';
}

public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type
{
return $this->getTypeFromCall($methodReflection, $methodCall->getArgs(), $scope);
}

public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
{
return $this->getTypeFromCall($methodReflection, $methodCall->getArgs(), $scope);
}

/**
* @param array<Arg> $args
*/
private function getTypeFromCall(MethodReflection $methodReflection, array $args, Scope $scope): ?Type
{
if (!isset($args[0])) {
return null;
}

$interfaces = $scope->getType($args[0]->value);
$constantArrays = $interfaces->getConstantArrays();
if (count($constantArrays) !== 1) {
return null;
}

$constantArray = $constantArrays[0];
if (count($constantArray->getOptionalKeys()) > 0) {
return null;
}

$result = [];
if ($methodReflection->getName() === 'createMockForIntersectionOfInterfaces') {
$result[] = new ObjectType(MockObject::class);
} else {
$result[] = new ObjectType(Stub::class);
}

foreach ($constantArray->getValueTypes() as $valueType) {
if (!$valueType->isClassString()->yes()) {
return null;
}

$values = $valueType->getConstantScalarValues();
if (count($values) !== 1) {
return null;
}

$result[] = new ObjectType((string) $values[0]);
}

return TypeCombinator::intersect(...$result);
}

}
13 changes: 13 additions & 0 deletions tests/Rules/PHPUnit/MockMethodCallRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\TestCase;
use function method_exists;
use const PHP_VERSION_ID;

/**
Expand Down Expand Up @@ -34,6 +36,17 @@ public function testRule(): void
],
];

if (method_exists(TestCase::class, 'createMockForIntersectionOfInterfaces')) { // @phpstan-ignore-line function.alreadyNarrowedType
$expectedErrors[] = [
'Trying to mock an undefined method bazMethod() on class MockMethodCall\FooInterface&MockMethodCall\BarInterface.',
49,
];
$expectedErrors[] = [
'Trying to mock an undefined method bazMethod() on class MockMethodCall\FooInterface&MockMethodCall\BarInterface.',
57,
];
}

$this->analyse([__DIR__ . '/data/mock-method-call.php'], $expectedErrors);
}

Expand Down
24 changes: 24 additions & 0 deletions tests/Rules/PHPUnit/data/mock-method-call.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ public function testMockObject(\PHPUnit\Framework\MockObject\MockObject $mock)
$mock->method('doFoo');
}

public function testMockForIntersection()
{
$mock = $this->createMockForIntersectionOfInterfaces([FooInterface::class, BarInterface::class]);
$mock->method('fooMethod');
$mock->method('barMethod');
$mock->method('bazMethod');
}

public function testStubForIntersection()
{
$stub = static::createStubForIntersectionOfInterfaces([FooInterface::class, BarInterface::class]);
$stub->method('fooMethod');
$stub->method('barMethod');
$stub->method('bazMethod');
}

}

class Bar {
Expand Down Expand Up @@ -71,3 +87,11 @@ public function testMockFinalClass()
}

}

interface FooInterface {
public function fooMethod(): int;
}

interface BarInterface {
public function barMethod(): string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\PHPUnit;

use PHPStan\Testing\TypeInferenceTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use function method_exists;

class MockForIntersectionDynamicReturnTypeExtensionTest extends TypeInferenceTestCase
{

/** @return mixed[] */
public static function dataFileAsserts(): iterable
{
if (method_exists(TestCase::class, 'createMockForIntersectionOfInterfaces')) { // @phpstan-ignore-line function.alreadyNarrowedType
yield from self::gatherAssertTypes(__DIR__ . '/data/mock-for-intersection.php');
}

return [];
}

/**
* @dataProvider dataFileAsserts
* @param mixed ...$args
*/
#[DataProvider('dataFileAsserts')]
public function testFileAsserts(
string $assertType,
string $file,
...$args
): void
{
$this->assertFileAsserts($assertType, $file, ...$args);
}

public static function getAdditionalConfigFiles(): array
{
return [__DIR__ . '/../../../extension.neon'];
}

}
37 changes: 37 additions & 0 deletions tests/Type/PHPUnit/data/mock-for-intersection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace MockForIntersection;

use PHPUnit\Framework\TestCase;

use function PHPStan\Testing\assertType;

class Foo extends TestCase
{

public function testFoo(bool $bool): void
{
assertType(
'MockForIntersection\BarInterface&MockForIntersection\FooInterface&PHPUnit\Framework\MockObject\MockObject',
$this->createMockForIntersectionOfInterfaces([FooInterface::class, BarInterface::class]),
);
assertType(
'MockForIntersection\BarInterface&MockForIntersection\FooInterface&PHPUnit\Framework\MockObject\Stub',
self::createStubForIntersectionOfInterfaces([FooInterface::class, BarInterface::class]),
);


assertType(
'PHPUnit\Framework\MockObject\MockObject',
$this->createMockForIntersectionOfInterfaces($bool ? [FooInterface::class, BarInterface::class] : [FooInterface::class]),
);
assertType(
'PHPUnit\Framework\MockObject\MockObject',
$this->createMockForIntersectionOfInterfaces($bool ? [FooInterface::class] : [BarInterface::class]),
);
}

}

interface FooInterface {}
interface BarInterface {}