├── stubs ├── Stub.stub ├── MockObject.stub ├── AssertionFailedError.stub ├── ExpectationFailedException.stub ├── Assert.stub ├── MockBuilder.stub └── TestCase.stub ├── composer.json ├── src ├── Rules │ └── PHPUnit │ │ ├── DataProviderHelperFactory.php │ │ ├── NoMissingSpaceInClassAnnotationRule.php │ │ ├── NoMissingSpaceInMethodAnnotationRule.php │ │ ├── AssertRuleHelper.php │ │ ├── PHPUnitVersionDetector.php │ │ ├── AnnotationHelper.php │ │ ├── AssertSameNullExpectedRule.php │ │ ├── DataProviderDeclarationRule.php │ │ ├── PHPUnitVersion.php │ │ ├── ClassCoversExistsRule.php │ │ ├── AssertEqualsIsDiscouragedRule.php │ │ ├── AssertSameBooleanExpectedRule.php │ │ ├── ShouldCallParentMethodsRule.php │ │ ├── AttributeRequiresPhpVersionRule.php │ │ ├── MockMethodCallRule.php │ │ ├── TestMethodsHelper.php │ │ ├── ClassMethodCoversExistsRule.php │ │ ├── AssertSameWithCountRule.php │ │ ├── CoversHelper.php │ │ ├── DataProviderDataRule.php │ │ └── DataProviderHelper.php ├── Type │ └── PHPUnit │ │ ├── MockBuilderDynamicReturnTypeExtension.php │ │ ├── Assert │ │ ├── AssertMethodTypeSpecifyingExtension.php │ │ ├── AssertStaticMethodTypeSpecifyingExtension.php │ │ ├── AssertFunctionTypeSpecifyingExtension.php │ │ └── AssertTypeSpecifyingExtensionHelper.php │ │ └── DataProviderReturnTypeIgnoreExtension.php └── PhpDoc │ └── PHPUnit │ └── MockObjectTypeNodeResolverExtension.php ├── LICENSE ├── rules.neon ├── extension.neon └── README.md /stubs/Stub.stub: -------------------------------------------------------------------------------- 1 | $array 9 | * 10 | * @throws ExpectationFailedException 11 | */ 12 | final public static function assertIsList(mixed $array, string $message = ''): void {} 13 | } 14 | -------------------------------------------------------------------------------- /stubs/MockBuilder.stub: -------------------------------------------------------------------------------- 1 | $type 16 | */ 17 | public function __construct(TestCase $testCase, $type) {} 18 | 19 | /** 20 | * @phpstan-return MockObject&TMockedClass 21 | */ 22 | public function getMock() {} 23 | 24 | /** 25 | * @phpstan-return MockObject&TMockedClass 26 | */ 27 | public function getMockForAbstractClass() {} 28 | 29 | } 30 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phpstan/phpstan-phpunit", 3 | "type": "phpstan-extension", 4 | "description": "PHPUnit extensions and rules for PHPStan", 5 | "license": [ 6 | "MIT" 7 | ], 8 | "require": { 9 | "php": "^7.4 || ^8.0", 10 | "phpstan/phpstan": "^2.1.32" 11 | }, 12 | "conflict": { 13 | "phpunit/phpunit": "<7.0" 14 | }, 15 | "require-dev": { 16 | "nikic/php-parser": "^5", 17 | "php-parallel-lint/php-parallel-lint": "^1.2", 18 | "phpstan/phpstan-deprecation-rules": "^2.0", 19 | "phpstan/phpstan-strict-rules": "^2.0", 20 | "phpunit/phpunit": "^9.6" 21 | }, 22 | "config": { 23 | "sort-packages": true 24 | }, 25 | "extra": { 26 | "phpstan": { 27 | "includes": [ 28 | "extension.neon", 29 | "rules.neon" 30 | ] 31 | } 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "PHPStan\\": "src/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "classmap": [ 40 | "tests/" 41 | ] 42 | }, 43 | "minimum-stability": "dev", 44 | "prefer-stable": true 45 | } 46 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/DataProviderHelperFactory.php: -------------------------------------------------------------------------------- 1 | reflectionProvider = $reflectionProvider; 28 | $this->fileTypeMapper = $fileTypeMapper; 29 | $this->parser = $parser; 30 | $this->PHPUnitVersion = $PHPUnitVersion; 31 | } 32 | 33 | public function create(): DataProviderHelper 34 | { 35 | return new DataProviderHelper($this->reflectionProvider, $this->fileTypeMapper, $this->parser, $this->PHPUnitVersion); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php: -------------------------------------------------------------------------------- 1 | getName(), 25 | [ 26 | 'getMock', 27 | 'getMockForAbstractClass', 28 | 'getMockForTrait', 29 | ], 30 | true, 31 | ); 32 | } 33 | 34 | public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type 35 | { 36 | return $scope->getType($methodCall->var); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Ondřej Mirtes 4 | Copyright (c) 2025 PHPStan s.r.o. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRule.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class NoMissingSpaceInClassAnnotationRule implements Rule 15 | { 16 | 17 | /** 18 | * Covers helper. 19 | * 20 | */ 21 | private AnnotationHelper $annotationHelper; 22 | 23 | public function __construct(AnnotationHelper $annotationHelper) 24 | { 25 | $this->annotationHelper = $annotationHelper; 26 | } 27 | 28 | public function getNodeType(): string 29 | { 30 | return InClassNode::class; 31 | } 32 | 33 | public function processNode(Node $node, Scope $scope): array 34 | { 35 | $classReflection = $scope->getClassReflection(); 36 | if ($classReflection === null || $classReflection->is(TestCase::class) === false) { 37 | return []; 38 | } 39 | 40 | $docComment = $node->getDocComment(); 41 | if ($docComment === null) { 42 | return []; 43 | } 44 | 45 | return $this->annotationHelper->processDocComment($docComment); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRule.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class NoMissingSpaceInMethodAnnotationRule implements Rule 15 | { 16 | 17 | /** 18 | * Covers helper. 19 | * 20 | */ 21 | private AnnotationHelper $annotationHelper; 22 | 23 | public function __construct(AnnotationHelper $annotationHelper) 24 | { 25 | $this->annotationHelper = $annotationHelper; 26 | } 27 | 28 | public function getNodeType(): string 29 | { 30 | return InClassMethodNode::class; 31 | } 32 | 33 | public function processNode(Node $node, Scope $scope): array 34 | { 35 | $classReflection = $scope->getClassReflection(); 36 | if ($classReflection === null || $classReflection->is(TestCase::class) === false) { 37 | return []; 38 | } 39 | 40 | $docComment = $node->getDocComment(); 41 | if ($docComment === null) { 42 | return []; 43 | } 44 | 45 | return $this->annotationHelper->processDocComment($docComment); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/AssertRuleHelper.php: -------------------------------------------------------------------------------- 1 | getType($node->var); 18 | } elseif ($node instanceof Node\Expr\StaticCall) { 19 | if ($node->class instanceof Node\Name) { 20 | $class = (string) $node->class; 21 | if ( 22 | $scope->isInClass() 23 | && in_array( 24 | strtolower($class), 25 | [ 26 | 'self', 27 | 'static', 28 | 'parent', 29 | ], 30 | true, 31 | ) 32 | ) { 33 | $calledOnType = new ObjectType($scope->getClassReflection()->getName()); 34 | } else { 35 | $calledOnType = new ObjectType($class); 36 | } 37 | } else { 38 | $calledOnType = $scope->getType($node->class); 39 | } 40 | } else { 41 | return false; 42 | } 43 | 44 | $testCaseType = new ObjectType('PHPUnit\Framework\Assert'); 45 | 46 | return $testCaseType->isSuperTypeOf($calledOnType)->yes(); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /rules.neon: -------------------------------------------------------------------------------- 1 | rules: 2 | - PHPStan\Rules\PHPUnit\AssertSameBooleanExpectedRule 3 | - PHPStan\Rules\PHPUnit\AssertSameNullExpectedRule 4 | - PHPStan\Rules\PHPUnit\AssertSameWithCountRule 5 | - PHPStan\Rules\PHPUnit\ClassCoversExistsRule 6 | - PHPStan\Rules\PHPUnit\ClassMethodCoversExistsRule 7 | - PHPStan\Rules\PHPUnit\MockMethodCallRule 8 | - PHPStan\Rules\PHPUnit\NoMissingSpaceInClassAnnotationRule 9 | - PHPStan\Rules\PHPUnit\NoMissingSpaceInMethodAnnotationRule 10 | - PHPStan\Rules\PHPUnit\ShouldCallParentMethodsRule 11 | 12 | conditionalTags: 13 | PHPStan\Rules\PHPUnit\AssertEqualsIsDiscouragedRule: 14 | phpstan.rules.rule: [%strictRulesInstalled%, %featureToggles.bleedingEdge%] 15 | 16 | PHPStan\Rules\PHPUnit\DataProviderDataRule: 17 | phpstan.rules.rule: %featureToggles.bleedingEdge% 18 | 19 | services: 20 | - 21 | class: PHPStan\Rules\PHPUnit\DataProviderDeclarationRule 22 | arguments: 23 | checkFunctionNameCase: %checkFunctionNameCase% 24 | deprecationRulesInstalled: %deprecationRulesInstalled% 25 | tags: 26 | - phpstan.rules.rule 27 | 28 | - 29 | class: PHPStan\Rules\PHPUnit\AttributeRequiresPhpVersionRule 30 | arguments: 31 | deprecationRulesInstalled: %deprecationRulesInstalled% 32 | tags: 33 | - phpstan.rules.rule 34 | 35 | - 36 | class: PHPStan\Rules\PHPUnit\AssertEqualsIsDiscouragedRule 37 | 38 | - 39 | class: PHPStan\Rules\PHPUnit\DataProviderDataRule 40 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/PHPUnitVersionDetector.php: -------------------------------------------------------------------------------- 1 | getFileName(); 27 | } catch (ReflectionException $e) { 28 | // PHPUnit might not be installed 29 | } 30 | 31 | if ($file !== false) { 32 | $phpUnitRoot = dirname($file, 3); 33 | $phpUnitComposer = $phpUnitRoot . '/composer.json'; 34 | 35 | $composerJson = @file_get_contents($phpUnitComposer); 36 | if ($composerJson !== false) { 37 | $json = json_decode($composerJson, true); 38 | $version = $json['extra']['branch-alias']['dev-main'] ?? null; 39 | if ($version !== null) { 40 | $versionParts = explode('.', $version); 41 | $majorVersion = (int) $versionParts[0]; 42 | $minorVersion = (int) $versionParts[1]; 43 | } 44 | } 45 | } 46 | 47 | return new PHPUnitVersion($majorVersion, $minorVersion); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/Type/PHPUnit/Assert/AssertMethodTypeSpecifyingExtension.php: -------------------------------------------------------------------------------- 1 | typeSpecifier = $typeSpecifier; 22 | } 23 | 24 | public function getClass(): string 25 | { 26 | return 'PHPUnit\Framework\Assert'; 27 | } 28 | 29 | public function isMethodSupported( 30 | MethodReflection $methodReflection, 31 | MethodCall $node, 32 | TypeSpecifierContext $context 33 | ): bool 34 | { 35 | return AssertTypeSpecifyingExtensionHelper::isSupported( 36 | $methodReflection->getName(), 37 | $node->getArgs(), 38 | ); 39 | } 40 | 41 | public function specifyTypes( 42 | MethodReflection $functionReflection, 43 | MethodCall $node, 44 | Scope $scope, 45 | TypeSpecifierContext $context 46 | ): SpecifiedTypes 47 | { 48 | return AssertTypeSpecifyingExtensionHelper::specifyTypes( 49 | $this->typeSpecifier, 50 | $scope, 51 | $functionReflection->getName(), 52 | $node->getArgs(), 53 | ); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/Type/PHPUnit/Assert/AssertStaticMethodTypeSpecifyingExtension.php: -------------------------------------------------------------------------------- 1 | typeSpecifier = $typeSpecifier; 22 | } 23 | 24 | public function getClass(): string 25 | { 26 | return 'PHPUnit\Framework\Assert'; 27 | } 28 | 29 | public function isStaticMethodSupported( 30 | MethodReflection $methodReflection, 31 | StaticCall $node, 32 | TypeSpecifierContext $context 33 | ): bool 34 | { 35 | return AssertTypeSpecifyingExtensionHelper::isSupported( 36 | $methodReflection->getName(), 37 | $node->getArgs(), 38 | ); 39 | } 40 | 41 | public function specifyTypes( 42 | MethodReflection $functionReflection, 43 | StaticCall $node, 44 | Scope $scope, 45 | TypeSpecifierContext $context 46 | ): SpecifiedTypes 47 | { 48 | return AssertTypeSpecifyingExtensionHelper::specifyTypes( 49 | $this->typeSpecifier, 50 | $scope, 51 | $functionReflection->getName(), 52 | $node->getArgs(), 53 | ); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/Type/PHPUnit/DataProviderReturnTypeIgnoreExtension.php: -------------------------------------------------------------------------------- 1 | testMethodsHelper = $testMethodsHelper; 25 | $this->dataProviderHelper = $dataProviderHelper; 26 | } 27 | 28 | public function shouldIgnore(Error $error, Node $node, Scope $scope): bool 29 | { 30 | if ($error->getIdentifier() !== 'missingType.iterableValue') { 31 | return false; 32 | } 33 | 34 | if (!$scope->isInClass()) { 35 | return false; 36 | } 37 | $classReflection = $scope->getClassReflection(); 38 | 39 | $methodReflection = $scope->getFunction(); 40 | if ($methodReflection === null) { 41 | return false; 42 | } 43 | 44 | $testMethods = $this->testMethodsHelper->getTestMethods($classReflection, $scope); 45 | foreach ($testMethods as $testMethod) { 46 | foreach ($this->dataProviderHelper->getDataProviderMethods($scope, $testMethod, $classReflection) as [, $providerMethodName]) { 47 | if ($providerMethodName === $methodReflection->getName()) { 48 | return true; 49 | } 50 | } 51 | } 52 | 53 | return false; 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/Type/PHPUnit/Assert/AssertFunctionTypeSpecifyingExtension.php: -------------------------------------------------------------------------------- 1 | typeSpecifier = $typeSpecifier; 25 | } 26 | 27 | public function isFunctionSupported( 28 | FunctionReflection $functionReflection, 29 | FuncCall $node, 30 | TypeSpecifierContext $context 31 | ): bool 32 | { 33 | return AssertTypeSpecifyingExtensionHelper::isSupported( 34 | $this->trimName($functionReflection->getName()), 35 | $node->getArgs(), 36 | ); 37 | } 38 | 39 | public function specifyTypes( 40 | FunctionReflection $functionReflection, 41 | FuncCall $node, 42 | Scope $scope, 43 | TypeSpecifierContext $context 44 | ): SpecifiedTypes 45 | { 46 | return AssertTypeSpecifyingExtensionHelper::specifyTypes( 47 | $this->typeSpecifier, 48 | $scope, 49 | $this->trimName($functionReflection->getName()), 50 | $node->getArgs(), 51 | ); 52 | } 53 | 54 | private function trimName(string $functionName): string 55 | { 56 | $prefix = 'PHPUnit\\Framework\\'; 57 | if (strpos($functionName, $prefix) === 0) { 58 | return substr($functionName, strlen($prefix)); 59 | } 60 | 61 | return $functionName; 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php: -------------------------------------------------------------------------------- 1 | typeNodeResolver = $typeNodeResolver; 25 | } 26 | 27 | public function getCacheKey(): string 28 | { 29 | return 'phpunit-v1'; 30 | } 31 | 32 | public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type 33 | { 34 | if (!$typeNode instanceof UnionTypeNode) { 35 | return null; 36 | } 37 | 38 | static $mockClassNames = [ 39 | 'PHPUnit_Framework_MockObject_MockObject' => true, 40 | 'PHPUnit\Framework\MockObject\MockObject' => true, 41 | 'PHPUnit\Framework\MockObject\Stub' => true, 42 | ]; 43 | 44 | $types = $this->typeNodeResolver->resolveMultiple($typeNode->types, $nameScope); 45 | foreach ($types as $type) { 46 | $classNames = $type->getObjectClassNames(); 47 | if (count($classNames) !== 1) { 48 | continue; 49 | } 50 | 51 | if (array_key_exists($classNames[0], $mockClassNames)) { 52 | $resultType = TypeCombinator::intersect(...$types); 53 | if ($resultType instanceof NeverType) { 54 | continue; 55 | } 56 | 57 | return $resultType; 58 | } 59 | } 60 | 61 | return null; 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/AnnotationHelper.php: -------------------------------------------------------------------------------- 1 | errors 34 | */ 35 | public function processDocComment(Doc $docComment): array 36 | { 37 | $errors = []; 38 | $docCommentLines = preg_split("/((\r?\n)|(\r\n?))/", $docComment->getText()); 39 | if ($docCommentLines === false) { 40 | return []; 41 | } 42 | 43 | foreach ($docCommentLines as $docCommentLine) { 44 | // These annotations can't be retrieved using the getResolvedPhpDoc method on the FileTypeMapper as they are not present when they are invalid 45 | $annotation = preg_match('/(?@(?[a-zA-Z]+)(?\s*)(?.*))/', $docCommentLine, $matches); 46 | if ($annotation === false) { 47 | continue; // Line without annotation 48 | } 49 | 50 | if (array_key_exists('property', $matches) === false || array_key_exists('whitespace', $matches) === false || array_key_exists('annotation', $matches) === false) { 51 | continue; 52 | } 53 | 54 | if (!in_array($matches['property'], self::ANNOTATIONS_WITH_PARAMS, true) || $matches['whitespace'] !== '') { 55 | continue; 56 | } 57 | 58 | $errors[] = RuleErrorBuilder::message( 59 | 'Annotation "' . $matches['annotation'] . '" is invalid, "@' . $matches['property'] . '" should be followed by a space and a value.', 60 | )->identifier('phpunit.invalidPhpDoc')->build(); 61 | } 62 | 63 | return $errors; 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/AssertSameNullExpectedRule.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class AssertSameNullExpectedRule implements Rule 17 | { 18 | 19 | public function getNodeType(): string 20 | { 21 | return CallLike::class; 22 | } 23 | 24 | public function processNode(Node $node, Scope $scope): array 25 | { 26 | if (!$node instanceof Node\Expr\MethodCall && ! $node instanceof Node\Expr\StaticCall) { 27 | return []; 28 | } 29 | if (count($node->getArgs()) < 2) { 30 | return []; 31 | } 32 | if ($node->isFirstClassCallable()) { 33 | return []; 34 | } 35 | if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'assertsame') { 36 | return []; 37 | } 38 | 39 | if (!AssertRuleHelper::isMethodOrStaticCallOnAssert($node, $scope)) { 40 | return []; 41 | } 42 | 43 | $expectedArgumentValue = $node->getArgs()[0]->value; 44 | if (!($expectedArgumentValue instanceof ConstFetch)) { 45 | return []; 46 | } 47 | 48 | if ($expectedArgumentValue->name->toLowerString() === 'null') { 49 | return [ 50 | RuleErrorBuilder::message('You should use assertNull() instead of assertSame(null, $actual).') 51 | ->identifier('phpunit.assertNull') 52 | ->fixNode($node, static function (CallLike $node) { 53 | $node->name = new Node\Identifier('assertNull'); 54 | $node->args = self::rewriteArgs($node->args); 55 | 56 | return $node; 57 | }) 58 | ->build(), 59 | ]; 60 | } 61 | 62 | return []; 63 | } 64 | 65 | /** 66 | * @param array $args 67 | * @return list 68 | */ 69 | private static function rewriteArgs(array $args): array 70 | { 71 | $newArgs = []; 72 | for ($i = 1; $i < count($args); $i++) { 73 | $newArgs[] = $args[$i]; 74 | } 75 | return $newArgs; 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/DataProviderDeclarationRule.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class DataProviderDeclarationRule implements Rule 15 | { 16 | 17 | /** 18 | * Data provider helper. 19 | * 20 | */ 21 | private DataProviderHelper $dataProviderHelper; 22 | 23 | /** 24 | * When set to true, it reports data provider method with incorrect name case. 25 | * 26 | */ 27 | private bool $checkFunctionNameCase; 28 | 29 | /** 30 | * When phpstan-deprecation-rules is installed, it reports deprecated usages. 31 | * 32 | */ 33 | private bool $deprecationRulesInstalled; 34 | 35 | public function __construct( 36 | DataProviderHelper $dataProviderHelper, 37 | bool $checkFunctionNameCase, 38 | bool $deprecationRulesInstalled 39 | ) 40 | { 41 | $this->dataProviderHelper = $dataProviderHelper; 42 | $this->checkFunctionNameCase = $checkFunctionNameCase; 43 | $this->deprecationRulesInstalled = $deprecationRulesInstalled; 44 | } 45 | 46 | public function getNodeType(): string 47 | { 48 | return Node\Stmt\ClassMethod::class; 49 | } 50 | 51 | public function processNode(Node $node, Scope $scope): array 52 | { 53 | $classReflection = $scope->getClassReflection(); 54 | 55 | if ($classReflection === null || !$classReflection->is(TestCase::class)) { 56 | return []; 57 | } 58 | 59 | $errors = []; 60 | 61 | foreach ($this->dataProviderHelper->getDataProviderMethods($scope, $node, $classReflection) as $dataProviderValue => [$dataProviderClassReflection, $dataProviderMethodName, $lineNumber]) { 62 | $errors = array_merge( 63 | $errors, 64 | $this->dataProviderHelper->processDataProvider( 65 | $dataProviderValue, 66 | $dataProviderClassReflection, 67 | $dataProviderMethodName, 68 | $lineNumber, 69 | $this->checkFunctionNameCase, 70 | $this->deprecationRulesInstalled, 71 | ), 72 | ); 73 | } 74 | 75 | return $errors; 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/PHPUnitVersion.php: -------------------------------------------------------------------------------- 1 | majorVersion = $majorVersion; 17 | $this->minorVersion = $minorVersion; 18 | } 19 | 20 | public function supportsDataProviderAttribute(): TrinaryLogic 21 | { 22 | if ($this->majorVersion === null) { 23 | return TrinaryLogic::createMaybe(); 24 | } 25 | return TrinaryLogic::createFromBoolean($this->majorVersion >= 10); 26 | } 27 | 28 | public function supportsTestAttribute(): TrinaryLogic 29 | { 30 | if ($this->majorVersion === null) { 31 | return TrinaryLogic::createMaybe(); 32 | } 33 | return TrinaryLogic::createFromBoolean($this->majorVersion >= 10); 34 | } 35 | 36 | public function requiresStaticDataProviders(): TrinaryLogic 37 | { 38 | if ($this->majorVersion === null) { 39 | return TrinaryLogic::createMaybe(); 40 | } 41 | return TrinaryLogic::createFromBoolean($this->majorVersion >= 10); 42 | } 43 | 44 | public function supportsNamedArgumentsInDataProvider(): TrinaryLogic 45 | { 46 | if ($this->majorVersion === null) { 47 | return TrinaryLogic::createMaybe(); 48 | } 49 | return TrinaryLogic::createFromBoolean($this->majorVersion >= 11); 50 | } 51 | 52 | public function requiresPhpversionAttributeWithOperator(): TrinaryLogic 53 | { 54 | if ($this->majorVersion === null) { 55 | return TrinaryLogic::createMaybe(); 56 | } 57 | return TrinaryLogic::createFromBoolean($this->majorVersion >= 13); 58 | } 59 | 60 | public function deprecatesPhpversionAttributeWithoutOperator(): TrinaryLogic 61 | { 62 | return $this->minVersion(12, 4); 63 | } 64 | 65 | private function minVersion(int $major, int $minor): TrinaryLogic 66 | { 67 | if ($this->majorVersion === null || $this->minorVersion === null) { 68 | return TrinaryLogic::createMaybe(); 69 | } 70 | 71 | if ($this->majorVersion > $major) { 72 | return TrinaryLogic::createYes(); 73 | } 74 | 75 | if ($this->majorVersion === $major && $this->minorVersion >= $minor) { 76 | return TrinaryLogic::createYes(); 77 | } 78 | 79 | return TrinaryLogic::createNo(); 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/ClassCoversExistsRule.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class ClassCoversExistsRule implements Rule 21 | { 22 | 23 | /** 24 | * Covers helper. 25 | * 26 | */ 27 | private CoversHelper $coversHelper; 28 | 29 | /** 30 | * Reflection provider. 31 | * 32 | */ 33 | private ReflectionProvider $reflectionProvider; 34 | 35 | public function __construct( 36 | CoversHelper $coversHelper, 37 | ReflectionProvider $reflectionProvider 38 | ) 39 | { 40 | $this->reflectionProvider = $reflectionProvider; 41 | $this->coversHelper = $coversHelper; 42 | } 43 | 44 | public function getNodeType(): string 45 | { 46 | return InClassNode::class; 47 | } 48 | 49 | public function processNode(Node $node, Scope $scope): array 50 | { 51 | $classReflection = $node->getClassReflection(); 52 | 53 | if (!$classReflection->is(TestCase::class)) { 54 | return []; 55 | } 56 | 57 | $classPhpDoc = $classReflection->getResolvedPhpDoc(); 58 | [$classCovers, $classCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($classPhpDoc); 59 | 60 | if (count($classCoversDefaultClasses) >= 2) { 61 | return [ 62 | RuleErrorBuilder::message(sprintf( 63 | '@coversDefaultClass is defined multiple times.', 64 | ))->identifier('phpunit.coversDuplicate')->build(), 65 | ]; 66 | } 67 | 68 | $errors = []; 69 | $coversDefaultClass = array_shift($classCoversDefaultClasses); 70 | 71 | if ($coversDefaultClass !== null) { 72 | $className = (string) $coversDefaultClass->value; 73 | if (!$this->reflectionProvider->hasClass($className)) { 74 | $errors[] = RuleErrorBuilder::message(sprintf( 75 | '@coversDefaultClass references an invalid class %s.', 76 | $className, 77 | ))->identifier('phpunit.coversClass')->build(); 78 | } 79 | } 80 | 81 | foreach ($classCovers as $covers) { 82 | $errors = array_merge( 83 | $errors, 84 | $this->coversHelper->processCovers($node, $covers, null), 85 | ); 86 | } 87 | 88 | return $errors; 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/AssertEqualsIsDiscouragedRule.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class AssertEqualsIsDiscouragedRule implements Rule 21 | { 22 | 23 | public function getNodeType(): string 24 | { 25 | return CallLike::class; 26 | } 27 | 28 | public function processNode(Node $node, Scope $scope): array 29 | { 30 | if (!$node instanceof Node\Expr\MethodCall && ! $node instanceof Node\Expr\StaticCall) { 31 | return []; 32 | } 33 | if (count($node->getArgs()) < 2) { 34 | return []; 35 | } 36 | if ($node->isFirstClassCallable()) { 37 | return []; 38 | } 39 | 40 | if ( 41 | !$node->name instanceof Node\Identifier 42 | || !in_array(strtolower($node->name->name), ['assertequals', 'assertnotequals'], true) 43 | ) { 44 | return []; 45 | } 46 | 47 | if (!AssertRuleHelper::isMethodOrStaticCallOnAssert($node, $scope)) { 48 | return []; 49 | } 50 | 51 | $leftType = TypeCombinator::removeNull($scope->getType($node->getArgs()[0]->value)); 52 | $rightType = TypeCombinator::removeNull($scope->getType($node->getArgs()[1]->value)); 53 | 54 | if ($leftType->isConstantScalarValue()->yes()) { 55 | $leftType = $leftType->generalize(GeneralizePrecision::lessSpecific()); 56 | } 57 | if ($rightType->isConstantScalarValue()->yes()) { 58 | $rightType = $rightType->generalize(GeneralizePrecision::lessSpecific()); 59 | } 60 | 61 | if ( 62 | ($leftType->isScalar()->yes() && $rightType->isScalar()->yes()) 63 | && ($leftType->isSuperTypeOf($rightType)->yes()) 64 | && ($rightType->isSuperTypeOf($leftType)->yes()) 65 | ) { 66 | $correctName = strtolower($node->name->name) === 'assertnotequals' ? 'assertNotSame' : 'assertSame'; 67 | return [ 68 | RuleErrorBuilder::message( 69 | sprintf( 70 | 'You should use %s() instead of %s(), because both values are scalars of the same type', 71 | $correctName, 72 | $node->name->name, 73 | ), 74 | )->identifier('phpunit.assertEquals') 75 | ->fixNode($node, static function (CallLike $node) use ($correctName) { 76 | $node->name = new Node\Identifier($correctName); 77 | 78 | return $node; 79 | }) 80 | ->build(), 81 | ]; 82 | } 83 | 84 | return []; 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /extension.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | phpunit: 3 | convertUnionToIntersectionType: true 4 | reportMissingDataProviderReturnType: false 5 | additionalConstructors: 6 | - PHPUnit\Framework\TestCase::setUp 7 | earlyTerminatingMethodCalls: 8 | PHPUnit\Framework\Assert: 9 | - fail 10 | - markTestIncomplete 11 | - markTestSkipped 12 | stubFiles: 13 | - stubs/Assert.stub 14 | - stubs/AssertionFailedError.stub 15 | - stubs/ExpectationFailedException.stub 16 | - stubs/MockBuilder.stub 17 | - stubs/MockObject.stub 18 | - stubs/Stub.stub 19 | - stubs/TestCase.stub 20 | exceptions: 21 | uncheckedExceptionRegexes: 22 | - '#^PHPUnit\\#' 23 | - '#^SebastianBergmann\\#' 24 | 25 | parametersSchema: 26 | phpunit: structure([ 27 | convertUnionToIntersectionType: bool(), 28 | reportMissingDataProviderReturnType: bool(), 29 | ]) 30 | 31 | services: 32 | - 33 | class: PHPStan\PhpDoc\PHPUnit\MockObjectTypeNodeResolverExtension 34 | - 35 | class: PHPStan\Type\PHPUnit\Assert\AssertFunctionTypeSpecifyingExtension 36 | tags: 37 | - phpstan.typeSpecifier.functionTypeSpecifyingExtension 38 | - 39 | class: PHPStan\Type\PHPUnit\Assert\AssertMethodTypeSpecifyingExtension 40 | tags: 41 | - phpstan.typeSpecifier.methodTypeSpecifyingExtension 42 | - 43 | class: PHPStan\Type\PHPUnit\Assert\AssertStaticMethodTypeSpecifyingExtension 44 | tags: 45 | - phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension 46 | - 47 | class: PHPStan\Type\PHPUnit\MockBuilderDynamicReturnTypeExtension 48 | tags: 49 | - phpstan.broker.dynamicMethodReturnTypeExtension 50 | - 51 | class: PHPStan\Rules\PHPUnit\CoversHelper 52 | - 53 | class: PHPStan\Rules\PHPUnit\AnnotationHelper 54 | 55 | - 56 | class: PHPStan\Rules\PHPUnit\TestMethodsHelper 57 | 58 | - 59 | class: PHPStan\Rules\PHPUnit\PHPUnitVersion 60 | factory: @PHPStan\Rules\PHPUnit\PHPUnitVersionDetector::createPHPUnitVersion() 61 | - 62 | class: PHPStan\Rules\PHPUnit\PHPUnitVersionDetector 63 | 64 | - 65 | class: PHPStan\Rules\PHPUnit\DataProviderHelper 66 | factory: @PHPStan\Rules\PHPUnit\DataProviderHelperFactory::create() 67 | - 68 | class: PHPStan\Rules\PHPUnit\DataProviderHelperFactory 69 | arguments: 70 | parser: @defaultAnalysisParser 71 | 72 | - 73 | class: PHPStan\Type\PHPUnit\DataProviderReturnTypeIgnoreExtension 74 | 75 | conditionalTags: 76 | PHPStan\PhpDoc\PHPUnit\MockObjectTypeNodeResolverExtension: 77 | phpstan.phpDoc.typeNodeResolverExtension: %phpunit.convertUnionToIntersectionType% 78 | PHPStan\Type\PHPUnit\DataProviderReturnTypeIgnoreExtension: 79 | phpstan.ignoreErrorExtension: [%featureToggles.bleedingEdge%, not(%phpunit.reportMissingDataProviderReturnType%)] 80 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class AssertSameBooleanExpectedRule implements Rule 17 | { 18 | 19 | public function getNodeType(): string 20 | { 21 | return CallLike::class; 22 | } 23 | 24 | public function processNode(Node $node, Scope $scope): array 25 | { 26 | if (!$node instanceof Node\Expr\MethodCall && ! $node instanceof Node\Expr\StaticCall) { 27 | return []; 28 | } 29 | if (count($node->getArgs()) < 2) { 30 | return []; 31 | } 32 | if ($node->isFirstClassCallable()) { 33 | return []; 34 | } 35 | if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'assertsame') { 36 | return []; 37 | } 38 | 39 | $expectedArgumentValue = $node->getArgs()[0]->value; 40 | if (!($expectedArgumentValue instanceof ConstFetch)) { 41 | return []; 42 | } 43 | 44 | if (!AssertRuleHelper::isMethodOrStaticCallOnAssert($node, $scope)) { 45 | return []; 46 | } 47 | 48 | if ($expectedArgumentValue->name->toLowerString() === 'true') { 49 | return [ 50 | RuleErrorBuilder::message('You should use assertTrue() instead of assertSame() when expecting "true"') 51 | ->identifier('phpunit.assertTrue') 52 | ->fixNode($node, static function (CallLike $node) { 53 | $node->name = new Node\Identifier('assertTrue'); 54 | $node->args = self::rewriteArgs($node->args); 55 | 56 | return $node; 57 | }) 58 | ->build(), 59 | ]; 60 | } 61 | 62 | if ($expectedArgumentValue->name->toLowerString() === 'false') { 63 | return [ 64 | RuleErrorBuilder::message('You should use assertFalse() instead of assertSame() when expecting "false"') 65 | ->identifier('phpunit.assertFalse') 66 | ->fixNode($node, static function (CallLike $node) { 67 | $node->name = new Node\Identifier('assertFalse'); 68 | $node->args = self::rewriteArgs($node->args); 69 | 70 | return $node; 71 | }) 72 | ->build(), 73 | ]; 74 | } 75 | 76 | return []; 77 | } 78 | 79 | /** 80 | * @param array $args 81 | * @return list 82 | */ 83 | private static function rewriteArgs(array $args): array 84 | { 85 | $newArgs = []; 86 | for ($i = 1; $i < count($args); $i++) { 87 | $newArgs[] = $args[$i]; 88 | } 89 | return $newArgs; 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /stubs/TestCase.stub: -------------------------------------------------------------------------------- 1 | $originalClassName 15 | * @phpstan-return Stub&T 16 | */ 17 | public function createStub($originalClassName) {} 18 | 19 | /** 20 | * @template T 21 | * @phpstan-param class-string $originalClassName 22 | * @phpstan-return MockObject&T 23 | */ 24 | public function createMock($originalClassName) {} 25 | 26 | /** 27 | * @template T 28 | * @phpstan-param class-string $className 29 | * @phpstan-return MockBuilder 30 | */ 31 | public function getMockBuilder(string $className) {} 32 | 33 | /** 34 | * @template T 35 | * @phpstan-param class-string $originalClassName 36 | * @phpstan-return MockObject&T 37 | */ 38 | public function createConfiguredMock($originalClassName) {} 39 | 40 | /** 41 | * @template T 42 | * @phpstan-param class-string $originalClassName 43 | * @phpstan-param string[] $methods 44 | * @phpstan-return MockObject&T 45 | */ 46 | public function createPartialMock($originalClassName, array $methods) {} 47 | 48 | /** 49 | * @template T 50 | * @phpstan-param class-string $originalClassName 51 | * @phpstan-return MockObject&T 52 | */ 53 | public function createTestProxy($originalClassName) {} 54 | 55 | /** 56 | * @template T 57 | * @phpstan-param class-string $originalClassName 58 | * @phpstan-param mixed[] $arguments 59 | * @phpstan-param string $mockClassName 60 | * @phpstan-param bool $callOriginalConstructor 61 | * @phpstan-param bool $callOriginalClone 62 | * @phpstan-param bool $callAutoload 63 | * @phpstan-param string[] $mockedMethods 64 | * @phpstan-param bool $cloneArguments 65 | * @phpstan-return MockObject&T 66 | */ 67 | protected function getMockForAbstractClass($originalClassName, array $arguments = [], $mockClassName = '', $callOriginalConstructor = true, $callOriginalClone = true, $callAutoload = true, $mockedMethods = [], $cloneArguments = false) {} 68 | 69 | /** 70 | * @template T 71 | * @phpstan-param string $wsdlFile 72 | * @phpstan-param class-string $originalClassName 73 | * @phpstan-param string $mockClassName 74 | * @phpstan-param string[] $methods 75 | * @phpstan-param bool $callOriginalConstructor 76 | * @phpstan-param mixed[] $options 77 | * @phpstan-return MockObject&T 78 | */ 79 | protected function getMockFromWsdl($wsdlFile, $originalClassName = '', $mockClassName = '', array $methods = [], $callOriginalConstructor = true, array $options = []) {} 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/ShouldCallParentMethodsRule.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class ShouldCallParentMethodsRule implements Rule 19 | { 20 | 21 | public function getNodeType(): string 22 | { 23 | return InClassMethodNode::class; 24 | } 25 | 26 | public function processNode(Node $node, Scope $scope): array 27 | { 28 | $methodName = $node->getOriginalNode()->name->name; 29 | if (!in_array(strtolower($methodName), ['setup', 'teardown'], true)) { 30 | return []; 31 | } 32 | if ($scope->getClassReflection() === null) { 33 | return []; 34 | } 35 | 36 | if (!$scope->getClassReflection()->is(TestCase::class)) { 37 | return []; 38 | } 39 | 40 | $parentClass = $scope->getClassReflection()->getParentClass(); 41 | 42 | if ($parentClass === null) { 43 | return []; 44 | } 45 | if (!$parentClass->hasNativeMethod($methodName)) { 46 | return []; 47 | } 48 | 49 | $parentMethod = $parentClass->getNativeMethod($methodName); 50 | if ($parentMethod->getDeclaringClass()->getName() === TestCase::class) { 51 | return []; 52 | } 53 | 54 | $hasParentCall = $this->hasParentClassCall($node->getOriginalNode()->getStmts(), strtolower($methodName)); 55 | 56 | if (!$hasParentCall) { 57 | return [ 58 | RuleErrorBuilder::message( 59 | sprintf('Missing call to parent::%s() method.', $methodName), 60 | )->identifier('phpunit.callParent')->build(), 61 | ]; 62 | } 63 | 64 | return []; 65 | } 66 | 67 | /** 68 | * @param Node\Stmt[]|null $stmts 69 | * 70 | */ 71 | private function hasParentClassCall(?array $stmts, string $methodName): bool 72 | { 73 | if ($stmts === null) { 74 | return false; 75 | } 76 | 77 | foreach ($stmts as $stmt) { 78 | if (! $stmt instanceof Node\Stmt\Expression) { 79 | continue; 80 | } 81 | 82 | if (! $stmt->expr instanceof Node\Expr\StaticCall) { 83 | continue; 84 | } 85 | 86 | if (! $stmt->expr->class instanceof Node\Name) { 87 | continue; 88 | } 89 | 90 | $class = (string) $stmt->expr->class; 91 | 92 | if (strtolower($class) !== 'parent') { 93 | continue; 94 | } 95 | 96 | if (! $stmt->expr->name instanceof Node\Identifier) { 97 | continue; 98 | } 99 | 100 | if ($stmt->expr->name->toLowerString() === $methodName) { 101 | return true; 102 | } 103 | } 104 | 105 | return false; 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class AttributeRequiresPhpVersionRule implements Rule 20 | { 21 | 22 | private PHPUnitVersion $PHPUnitVersion; 23 | 24 | private TestMethodsHelper $testMethodsHelper; 25 | 26 | /** 27 | * When phpstan-deprecation-rules is installed, it reports deprecated usages. 28 | */ 29 | private bool $deprecationRulesInstalled; 30 | 31 | public function __construct( 32 | PHPUnitVersion $PHPUnitVersion, 33 | TestMethodsHelper $testMethodsHelper, 34 | bool $deprecationRulesInstalled 35 | ) 36 | { 37 | $this->PHPUnitVersion = $PHPUnitVersion; 38 | $this->testMethodsHelper = $testMethodsHelper; 39 | $this->deprecationRulesInstalled = $deprecationRulesInstalled; 40 | } 41 | 42 | public function getNodeType(): string 43 | { 44 | return InClassMethodNode::class; 45 | } 46 | 47 | public function processNode(Node $node, Scope $scope): array 48 | { 49 | $classReflection = $scope->getClassReflection(); 50 | if ($classReflection === null || $classReflection->is(TestCase::class) === false) { 51 | return []; 52 | } 53 | 54 | $reflectionMethod = $this->testMethodsHelper->getTestMethodReflection($classReflection, $node->getMethodReflection(), $scope); 55 | if ($reflectionMethod === null) { 56 | return []; 57 | } 58 | 59 | /** @phpstan-ignore function.alreadyNarrowedType */ 60 | if (!method_exists($reflectionMethod, 'getAttributes')) { 61 | return []; 62 | } 63 | 64 | $errors = []; 65 | foreach ($reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) { 66 | $args = $attr->getArguments(); 67 | if (count($args) !== 1) { 68 | continue; 69 | } 70 | 71 | if ( 72 | !is_numeric($args[0]) 73 | ) { 74 | continue; 75 | } 76 | 77 | if ($this->PHPUnitVersion->requiresPhpversionAttributeWithOperator()->yes()) { 78 | $errors[] = RuleErrorBuilder::message( 79 | sprintf('Version requirement is missing operator.'), 80 | ) 81 | ->identifier('phpunit.attributeRequiresPhpVersion') 82 | ->build(); 83 | } elseif ( 84 | $this->deprecationRulesInstalled 85 | && $this->PHPUnitVersion->deprecatesPhpversionAttributeWithoutOperator()->yes() 86 | ) { 87 | $errors[] = RuleErrorBuilder::message( 88 | sprintf('Version requirement without operator is deprecated.'), 89 | ) 90 | ->identifier('phpunit.attributeRequiresPhpVersion') 91 | ->build(); 92 | } 93 | 94 | } 95 | 96 | return $errors; 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/MockMethodCallRule.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class MockMethodCallRule implements Rule 24 | { 25 | 26 | public function getNodeType(): string 27 | { 28 | return Node\Expr\MethodCall::class; 29 | } 30 | 31 | public function processNode(Node $node, Scope $scope): array 32 | { 33 | if (!$node->name instanceof Node\Identifier || $node->name->name !== 'method') { 34 | return []; 35 | } 36 | 37 | if (count($node->getArgs()) < 1) { 38 | return []; 39 | } 40 | 41 | $argType = $scope->getType($node->getArgs()[0]->value); 42 | if (count($argType->getConstantStrings()) === 0) { 43 | return []; 44 | } 45 | 46 | $errors = []; 47 | foreach ($argType->getConstantStrings() as $constantString) { 48 | $method = $constantString->getValue(); 49 | $type = $scope->getType($node->var); 50 | 51 | $error = $this->checkCallOnType($scope, $type, $method); 52 | if ($error !== null) { 53 | $errors[] = $error; 54 | continue; 55 | } 56 | 57 | if (!$node->var instanceof MethodCall) { 58 | continue; 59 | } 60 | 61 | if (!$node->var->name instanceof Node\Identifier) { 62 | continue; 63 | } 64 | 65 | if ($node->var->name->toLowerString() !== 'expects') { 66 | continue; 67 | } 68 | 69 | $varType = $scope->getType($node->var->var); 70 | $error = $this->checkCallOnType($scope, $varType, $method); 71 | if ($error === null) { 72 | continue; 73 | } 74 | 75 | $errors[] = $error; 76 | } 77 | 78 | return $errors; 79 | } 80 | 81 | private function checkCallOnType(Scope $scope, Type $type, string $method): ?IdentifierRuleError 82 | { 83 | $methodReflection = $scope->getMethodReflection($type, $method); 84 | if ($methodReflection !== null) { 85 | return null; 86 | } 87 | 88 | if ( 89 | in_array(MockObject::class, $type->getObjectClassNames(), true) 90 | || in_array(Stub::class, $type->getObjectClassNames(), true) 91 | ) { 92 | $mockClasses = array_filter($type->getObjectClassNames(), static fn (string $class): bool => $class !== MockObject::class && $class !== Stub::class); 93 | if (count($mockClasses) === 0) { 94 | return null; 95 | } 96 | 97 | return RuleErrorBuilder::message(sprintf( 98 | 'Trying to mock an undefined method %s() on class %s.', 99 | $method, 100 | implode('&', $mockClasses), 101 | ))->identifier('phpunit.mockMethod')->build(); 102 | } 103 | 104 | return null; 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/TestMethodsHelper.php: -------------------------------------------------------------------------------- 1 | fileTypeMapper = $fileTypeMapper; 28 | $this->PHPUnitVersion = $PHPUnitVersion; 29 | } 30 | 31 | public function getTestMethodReflection(ClassReflection $classReflection, MethodReflection $methodReflection, Scope $scope): ?ReflectionMethod 32 | { 33 | foreach ($this->getTestMethods($classReflection, $scope) as $testMethod) { 34 | if ($testMethod->getName() === $methodReflection->getName()) { 35 | return $testMethod; 36 | } 37 | } 38 | 39 | return null; 40 | } 41 | 42 | /** 43 | * @return array 44 | */ 45 | public function getTestMethods(ClassReflection $classReflection, Scope $scope): array 46 | { 47 | if (!$classReflection->is(TestCase::class)) { 48 | return []; 49 | } 50 | 51 | $testMethods = []; 52 | foreach ($classReflection->getNativeReflection()->getMethods() as $reflectionMethod) { 53 | if (!$reflectionMethod->isPublic()) { 54 | continue; 55 | } 56 | 57 | if (str_starts_with(strtolower($reflectionMethod->getName()), 'test')) { 58 | $testMethods[] = $reflectionMethod; 59 | continue; 60 | } 61 | 62 | $docComment = $reflectionMethod->getDocComment(); 63 | if ($docComment !== false) { 64 | $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( 65 | $scope->getFile(), 66 | $classReflection->getName(), 67 | $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, 68 | $reflectionMethod->getName(), 69 | $docComment, 70 | ); 71 | 72 | if ($this->hasTestAnnotation($methodPhpDoc)) { 73 | $testMethods[] = $reflectionMethod; 74 | continue; 75 | } 76 | } 77 | 78 | if ($this->PHPUnitVersion->supportsTestAttribute()->no()) { 79 | continue; 80 | } 81 | 82 | $testAttributes = $reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\Test'); // @phpstan-ignore argument.type 83 | if ($testAttributes === []) { 84 | continue; 85 | } 86 | 87 | $testMethods[] = $reflectionMethod; 88 | } 89 | 90 | return $testMethods; 91 | } 92 | 93 | private function hasTestAnnotation(?ResolvedPhpDocBlock $phpDoc): bool 94 | { 95 | if ($phpDoc === null) { 96 | return false; 97 | } 98 | 99 | $phpDocNodes = $phpDoc->getPhpDocNodes(); 100 | 101 | foreach ($phpDocNodes as $docNode) { 102 | $tags = $docNode->getTagsByName('@test'); 103 | if ($tags !== []) { 104 | return true; 105 | } 106 | } 107 | 108 | return false; 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/ClassMethodCoversExistsRule.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class ClassMethodCoversExistsRule implements Rule 23 | { 24 | 25 | /** 26 | * Covers helper. 27 | * 28 | */ 29 | private CoversHelper $coversHelper; 30 | 31 | /** 32 | * The file type mapper. 33 | * 34 | */ 35 | private FileTypeMapper $fileTypeMapper; 36 | 37 | public function __construct( 38 | CoversHelper $coversHelper, 39 | FileTypeMapper $fileTypeMapper 40 | ) 41 | { 42 | $this->coversHelper = $coversHelper; 43 | $this->fileTypeMapper = $fileTypeMapper; 44 | } 45 | 46 | public function getNodeType(): string 47 | { 48 | return Node\Stmt\ClassMethod::class; 49 | } 50 | 51 | public function processNode(Node $node, Scope $scope): array 52 | { 53 | $classReflection = $scope->getClassReflection(); 54 | 55 | if ($classReflection === null) { 56 | return []; 57 | } 58 | 59 | if (!$classReflection->is(TestCase::class)) { 60 | return []; 61 | } 62 | 63 | $classPhpDoc = $classReflection->getResolvedPhpDoc(); 64 | [$classCovers, $classCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($classPhpDoc); 65 | 66 | $classCoversStrings = array_map(static fn (PhpDocTagNode $covers): string => (string) $covers->value, $classCovers); 67 | 68 | $docComment = $node->getDocComment(); 69 | if ($docComment === null) { 70 | return []; 71 | } 72 | 73 | $coversDefaultClass = count($classCoversDefaultClasses) === 1 74 | ? array_shift($classCoversDefaultClasses) 75 | : null; 76 | 77 | $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( 78 | $scope->getFile(), 79 | $classReflection->getName(), 80 | $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, 81 | $node->name->toString(), 82 | $docComment->getText(), 83 | ); 84 | 85 | [$methodCovers, $methodCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($methodPhpDoc); 86 | 87 | $errors = []; 88 | 89 | if (count($methodCoversDefaultClasses) > 0) { 90 | $errors[] = RuleErrorBuilder::message(sprintf( 91 | '@coversDefaultClass defined on class method %s.', 92 | $node->name, 93 | ))->identifier('phpunit.covers')->build(); 94 | } 95 | 96 | foreach ($methodCovers as $covers) { 97 | if (in_array((string) $covers->value, $classCoversStrings, true)) { 98 | $errors[] = RuleErrorBuilder::message(sprintf( 99 | 'Class already @covers %s so the method @covers is redundant.', 100 | $covers->value, 101 | ))->identifier('phpunit.coversDuplicate')->build(); 102 | } 103 | 104 | $errors = array_merge( 105 | $errors, 106 | $this->coversHelper->processCovers($node, $covers, $coversDefaultClass), 107 | ); 108 | } 109 | 110 | return $errors; 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/AssertSameWithCountRule.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class AssertSameWithCountRule implements Rule 22 | { 23 | 24 | public function getNodeType(): string 25 | { 26 | return CallLike::class; 27 | } 28 | 29 | public function processNode(Node $node, Scope $scope): array 30 | { 31 | if (!$node instanceof Node\Expr\MethodCall && ! $node instanceof Node\Expr\StaticCall) { 32 | return []; 33 | } 34 | if (count($node->getArgs()) < 2) { 35 | return []; 36 | } 37 | if ($node->isFirstClassCallable()) { 38 | return []; 39 | } 40 | if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'assertsame') { 41 | return []; 42 | } 43 | 44 | if (!AssertRuleHelper::isMethodOrStaticCallOnAssert($node, $scope)) { 45 | return []; 46 | } 47 | 48 | $right = $node->getArgs()[1]->value; 49 | if (self::isCountFunctionCall($right, $scope)) { 50 | return [ 51 | RuleErrorBuilder::message('You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, count($variable)).') 52 | ->identifier('phpunit.assertCount') 53 | ->build(), 54 | ]; 55 | } 56 | 57 | if (self::isCountableMethodCall($right, $scope)) { 58 | return [ 59 | RuleErrorBuilder::message('You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, $variable->count()).') 60 | ->identifier('phpunit.assertCount') 61 | ->build(), 62 | ]; 63 | } 64 | 65 | return []; 66 | } 67 | 68 | /** 69 | * @phpstan-assert-if-true Node\Expr\FuncCall $expr 70 | */ 71 | private static function isCountFunctionCall(Node\Expr $expr, Scope $scope): bool 72 | { 73 | return $expr instanceof Node\Expr\FuncCall 74 | && $expr->name instanceof Node\Name 75 | && $expr->name->toLowerString() === 'count' 76 | && count($expr->getArgs()) >= 1 77 | && self::isNormalCount($expr, $scope->getType($expr->getArgs()[0]->value), $scope)->yes(); 78 | } 79 | 80 | /** 81 | * @phpstan-assert-if-true Node\Expr\MethodCall $expr 82 | */ 83 | private static function isCountableMethodCall(Node\Expr $expr, Scope $scope): bool 84 | { 85 | if ( 86 | $expr instanceof Node\Expr\MethodCall 87 | && $expr->name instanceof Node\Identifier 88 | && $expr->name->toLowerString() === 'count' 89 | && count($expr->getArgs()) === 0 90 | ) { 91 | $type = $scope->getType($expr->var); 92 | 93 | if ((new ObjectType(Countable::class))->isSuperTypeOf($type)->yes()) { 94 | return true; 95 | } 96 | } 97 | 98 | return false; 99 | } 100 | 101 | private static function isNormalCount(Node\Expr\FuncCall $countFuncCall, Type $countedType, Scope $scope): TrinaryLogic 102 | { 103 | if (count($countFuncCall->getArgs()) === 1) { 104 | $isNormalCount = TrinaryLogic::createYes(); 105 | } else { 106 | $mode = $scope->getType($countFuncCall->getArgs()[1]->value); 107 | $isNormalCount = (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->result->or($countedType->getIterableValueType()->isArray()->negate()); 108 | } 109 | return $isNormalCount; 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/CoversHelper.php: -------------------------------------------------------------------------------- 1 | reflectionProvider = $reflectionProvider; 29 | } 30 | 31 | /** 32 | * Gathers @covers and @coversDefaultClass annotations from phpdocs. 33 | * 34 | * @return array{PhpDocTagNode[], PhpDocTagNode[]} 35 | */ 36 | public function getCoverAnnotations(?ResolvedPhpDocBlock $phpDoc): array 37 | { 38 | if ($phpDoc === null) { 39 | return [[], []]; 40 | } 41 | 42 | $phpDocNodes = $phpDoc->getPhpDocNodes(); 43 | 44 | $covers = []; 45 | $coversDefaultClasses = []; 46 | 47 | foreach ($phpDocNodes as $docNode) { 48 | $covers = array_merge( 49 | $covers, 50 | $docNode->getTagsByName('@covers'), 51 | ); 52 | 53 | $coversDefaultClasses = array_merge( 54 | $coversDefaultClasses, 55 | $docNode->getTagsByName('@coversDefaultClass'), 56 | ); 57 | } 58 | 59 | return [$covers, $coversDefaultClasses]; 60 | } 61 | 62 | /** 63 | * @return list errors 64 | */ 65 | public function processCovers( 66 | Node $node, 67 | PhpDocTagNode $phpDocTag, 68 | ?PhpDocTagNode $coversDefaultClass 69 | ): array 70 | { 71 | $errors = []; 72 | $covers = (string) $phpDocTag->value; 73 | 74 | if ($covers === '') { 75 | $errors[] = RuleErrorBuilder::message('@covers value does not specify anything.') 76 | ->identifier('phpunit.covers') 77 | ->build(); 78 | 79 | return $errors; 80 | } 81 | 82 | $isMethod = strpos($covers, '::') !== false; 83 | $fullName = $covers; 84 | 85 | if ($isMethod) { 86 | [$className, $method] = explode('::', $covers); 87 | } else { 88 | $className = $covers; 89 | } 90 | 91 | if ($className === '' && $node instanceof Node\Stmt\ClassMethod && $coversDefaultClass !== null) { 92 | $className = (string) $coversDefaultClass->value; 93 | $fullName = $className . $covers; 94 | } 95 | 96 | if ($this->reflectionProvider->hasClass($className)) { 97 | $class = $this->reflectionProvider->getClass($className); 98 | 99 | if ($class->isInterface()) { 100 | $errors[] = RuleErrorBuilder::message(sprintf( 101 | '@covers value %s references an interface.', 102 | $fullName, 103 | ))->identifier('phpunit.coversInterface')->build(); 104 | } 105 | 106 | if (isset($method) && $method !== '' && !$class->hasMethod($method)) { 107 | $errors[] = RuleErrorBuilder::message(sprintf( 108 | '@covers value %s references an invalid method.', 109 | $fullName, 110 | ))->identifier('phpunit.coversMethod')->build(); 111 | } 112 | } elseif (isset($method) && $this->reflectionProvider->hasFunction(new Name($method, []), null)) { 113 | return $errors; 114 | } elseif (!isset($method) && $this->reflectionProvider->hasFunction(new Name($className, []), null)) { 115 | return $errors; 116 | } else { 117 | $error = RuleErrorBuilder::message(sprintf( 118 | '@covers value %s references an invalid %s.', 119 | $fullName, 120 | $isMethod ? 'method' : 'class or function', 121 | ))->identifier(sprintf('phpunit.covers%s', $isMethod ? 'Method' : '')); 122 | 123 | if (strpos($className, '\\') === false) { 124 | $error->tip('The @covers annotation requires a fully qualified name.'); 125 | } 126 | 127 | $errors[] = $error->build(); 128 | } 129 | return $errors; 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHPStan PHPUnit extensions and rules 2 | 3 | [![Build](https://github.com/phpstan/phpstan-phpunit/workflows/Build/badge.svg)](https://github.com/phpstan/phpstan-phpunit/actions) 4 | [![Latest Stable Version](https://poser.pugx.org/phpstan/phpstan-phpunit/v/stable)](https://packagist.org/packages/phpstan/phpstan-phpunit) 5 | [![License](https://poser.pugx.org/phpstan/phpstan-phpunit/license)](https://packagist.org/packages/phpstan/phpstan-phpunit) 6 | 7 | * [PHPStan](https://phpstan.org/) 8 | * [PHPUnit](https://phpunit.de) 9 | 10 | This extension provides following features: 11 | 12 | * `createMock()`, `getMockForAbstractClass()` and `getMockFromWsdl()` methods return an intersection type (see the [detailed explanation of intersection types](https://phpstan.org/blog/union-types-vs-intersection-types)) of the mock object and the mocked class so that both methods from the mock object (like `expects`) and from the mocked class are available on the object. 13 | * `getMock()` called on `MockBuilder` is also supported. 14 | * Interprets `Foo|MockObject` in phpDoc so that it results in an intersection type instead of a union type. 15 | * Defines early terminating method calls for the `PHPUnit\Framework\TestCase` class to prevent undefined variable errors. 16 | * Specifies types of expressions passed to various `assert` methods like `assertInstanceOf`, `assertTrue`, `assertInternalType` etc. 17 | * Combined with PHPStan's level 4, it points out always-true and always-false asserts like `assertTrue(true)` etc. 18 | 19 | It also contains this strict framework-specific rules (can be enabled separately): 20 | 21 | * Check that you are not using `assertSame()` with `true` as expected value. `assertTrue()` should be used instead. 22 | * Check that you are not using `assertSame()` with `false` as expected value. `assertFalse()` should be used instead. 23 | * Check that you are not using `assertSame()` with `null` as expected value. `assertNull()` should be used instead. 24 | * Check that you are not using `assertSame()` with `count($variable)` as second parameter. `assertCount($variable)` should be used instead. 25 | * Check that you are not using `assertEquals()` with same types (`assertSame()` should be used) 26 | * Check that you are not using `assertNotEquals()` with same types (`assertNotSame()` should be used) 27 | 28 | ## How to document mock objects in phpDocs? 29 | 30 | If you need to configure the mock even after you assign it to a property or return it from a method, you should add `\PHPUnit\Framework\MockObject\MockObject` to the type: 31 | 32 | ```php 33 | private function createFooMock(): Foo&\PHPUnit\Framework\MockObject\MockObject 34 | { 35 | return $this->createMock(Foo::class); 36 | } 37 | 38 | public function testSomething(): void 39 | { 40 | $fooMock = $this->createFooMock(); 41 | $fooMock->method('doFoo')->will($this->returnValue('test')); 42 | $fooMock->doFoo(); 43 | } 44 | ``` 45 | 46 | If you cannot use native intersection types yet, you can use PHPDoc instead. 47 | 48 | ```php 49 | /** 50 | * @return Foo&\PHPUnit\Framework\MockObject\MockObject 51 | */ 52 | private function createFooMock(): Foo 53 | { 54 | return $this->createMock(Foo::class); 55 | } 56 | ``` 57 | 58 | Please note that the correct syntax for intersection types is `Foo&\PHPUnit\Framework\MockObject\MockObject`. `Foo|\PHPUnit\Framework\MockObject\MockObject` is also supported, but only for ecosystem and legacy reasons. 59 | 60 | If the mock is fully configured and only the methods of the mocked class are supposed to be called on the value, it's fine to typehint only the mocked class: 61 | 62 | ```php 63 | private Foo $foo; 64 | 65 | protected function setUp(): void 66 | { 67 | $fooMock = $this->createMock(Foo::class); 68 | $fooMock->method('doFoo')->will($this->returnValue('test')); 69 | $this->foo = $fooMock; 70 | } 71 | 72 | public function testSomething(): void 73 | { 74 | $this->foo->doFoo(); 75 | // $this->foo->method() and expects() can no longer be called 76 | } 77 | ``` 78 | 79 | 80 | ## Installation 81 | 82 | To use this extension, require it in [Composer](https://getcomposer.org/): 83 | 84 | ``` 85 | composer require --dev phpstan/phpstan-phpunit 86 | ``` 87 | 88 | If you also install [phpstan/extension-installer](https://github.com/phpstan/extension-installer) then you're all set! 89 | 90 |
91 | Manual installation 92 | 93 | If you don't want to use `phpstan/extension-installer`, include extension.neon in your project's PHPStan config: 94 | 95 | ``` 96 | includes: 97 | - vendor/phpstan/phpstan-phpunit/extension.neon 98 | ``` 99 | 100 | To perform framework-specific checks, include also this file: 101 | 102 | ``` 103 | - vendor/phpstan/phpstan-phpunit/rules.neon 104 | ``` 105 | 106 |
107 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/DataProviderDataRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class DataProviderDataRule implements Rule 20 | { 21 | 22 | private TestMethodsHelper $testMethodsHelper; 23 | 24 | private DataProviderHelper $dataProviderHelper; 25 | 26 | private PHPUnitVersion $PHPUnitVersion; 27 | 28 | public function __construct( 29 | TestMethodsHelper $testMethodsHelper, 30 | DataProviderHelper $dataProviderHelper, 31 | PHPUnitVersion $PHPUnitVersion 32 | ) 33 | { 34 | $this->testMethodsHelper = $testMethodsHelper; 35 | $this->dataProviderHelper = $dataProviderHelper; 36 | $this->PHPUnitVersion = $PHPUnitVersion; 37 | } 38 | 39 | public function getNodeType(): string 40 | { 41 | return Node::class; 42 | } 43 | 44 | public function processNode(Node $node, Scope $scope): array 45 | { 46 | if ( 47 | !$node instanceof Node\Stmt\Return_ 48 | && !$node instanceof Node\Expr\Yield_ 49 | && !$node instanceof Node\Expr\YieldFrom 50 | ) { 51 | return []; 52 | } 53 | 54 | if ($scope->getFunction() === null) { 55 | return []; 56 | } 57 | if ($scope->isInAnonymousFunction()) { 58 | return []; 59 | } 60 | 61 | $arraysTypes = $this->buildArrayTypesFromNode($node, $scope); 62 | if ($arraysTypes === []) { 63 | return []; 64 | } 65 | 66 | $method = $scope->getFunction(); 67 | $classReflection = $scope->getClassReflection(); 68 | if ($classReflection === null) { 69 | return []; 70 | } 71 | 72 | $testsWithProvider = []; 73 | $testMethods = $this->testMethodsHelper->getTestMethods($classReflection, $scope); 74 | foreach ($testMethods as $testMethod) { 75 | foreach ($this->dataProviderHelper->getDataProviderMethods($scope, $testMethod, $classReflection) as [, $providerMethodName]) { 76 | if ($providerMethodName === $method->getName()) { 77 | $testsWithProvider[] = $testMethod; 78 | continue 2; 79 | } 80 | } 81 | } 82 | 83 | if (count($testsWithProvider) === 0) { 84 | return []; 85 | } 86 | 87 | $maxNumberOfParameters = null; 88 | foreach ($testsWithProvider as $testMethod) { 89 | $num = $testMethod->getNumberOfParameters(); 90 | if ($testMethod->isVariadic()) { 91 | $num = PHP_INT_MAX; 92 | } 93 | if ($maxNumberOfParameters === null) { 94 | $maxNumberOfParameters = $num; 95 | continue; 96 | } 97 | 98 | $maxNumberOfParameters = max($maxNumberOfParameters, $num); 99 | if ($num === PHP_INT_MAX) { 100 | break; 101 | } 102 | } 103 | 104 | foreach ($testsWithProvider as $testMethod) { 105 | $numberOfParameters = $testMethod->getNumberOfParameters(); 106 | 107 | foreach ($arraysTypes as [$startLine, $arraysType]) { 108 | $args = $this->arrayItemsToArgs($arraysType, $numberOfParameters); 109 | if ($args === null) { 110 | continue; 111 | } 112 | 113 | if ( 114 | !$testMethod->isVariadic() 115 | && $numberOfParameters !== $maxNumberOfParameters 116 | ) { 117 | $args = array_slice($args, 0, $numberOfParameters); 118 | } 119 | 120 | $scope->invokeNodeCallback(new Node\Expr\MethodCall( 121 | new TypeExpr(new ObjectType($classReflection->getName())), 122 | $testMethod->getName(), 123 | $args, 124 | ['startLine' => $startLine], 125 | )); 126 | } 127 | } 128 | 129 | return []; 130 | } 131 | 132 | /** 133 | * @return array 134 | */ 135 | private function arrayItemsToArgs(Type $array, int $numberOfParameters): ?array 136 | { 137 | $args = []; 138 | 139 | $constArrays = $array->getConstantArrays(); 140 | if ($constArrays !== [] && count($constArrays) === 1) { 141 | $keyTypes = $constArrays[0]->getKeyTypes(); 142 | $valueTypes = $constArrays[0]->getValueTypes(); 143 | } elseif ($array->isArray()->yes()) { 144 | $keyTypes = []; 145 | $valueTypes = []; 146 | for ($i = 0; $i < $numberOfParameters; ++$i) { 147 | $keyTypes[$i] = $array->getIterableKeyType(); 148 | $valueTypes[$i] = $array->getIterableValueType(); 149 | } 150 | } else { 151 | return null; 152 | } 153 | 154 | foreach ($valueTypes as $i => $valueType) { 155 | $key = $keyTypes[$i]->getConstantStrings(); 156 | if (count($key) > 1) { 157 | return null; 158 | } 159 | 160 | if (count($key) === 0 || !$this->PHPUnitVersion->supportsNamedArgumentsInDataProvider()->yes()) { 161 | $arg = new Node\Arg(new TypeExpr($valueType)); 162 | $args[] = $arg; 163 | continue; 164 | } 165 | 166 | $arg = new Node\Arg( 167 | new TypeExpr($valueType), 168 | false, 169 | false, 170 | [], 171 | new Node\Identifier($key[0]->getValue()), 172 | ); 173 | $args[] = $arg; 174 | } 175 | 176 | return $args; 177 | } 178 | 179 | /** 180 | * @param Node\Stmt\Return_|Node\Expr\Yield_|Node\Expr\YieldFrom $node 181 | * 182 | * @return list 183 | */ 184 | private function buildArrayTypesFromNode(Node $node, Scope $scope): array 185 | { 186 | $arraysTypes = []; 187 | 188 | // special case for providers only containing static data, so we get more precise error lines 189 | if ( 190 | ($node instanceof Node\Stmt\Return_ && $node->expr instanceof Node\Expr\Array_) 191 | || ($node instanceof Node\Expr\YieldFrom && $node->expr instanceof Node\Expr\Array_) 192 | ) { 193 | foreach ($node->expr->items as $item) { 194 | if (!$item->value instanceof Node\Expr\Array_) { 195 | $arraysTypes = []; 196 | break; 197 | } 198 | 199 | $constArrays = $scope->getType($item->value)->getConstantArrays(); 200 | if ($constArrays === []) { 201 | $arraysTypes = []; 202 | break; 203 | } 204 | 205 | foreach ($constArrays as $constArray) { 206 | $arraysTypes[] = [$item->value->getStartLine(), $constArray]; 207 | } 208 | } 209 | 210 | if ($arraysTypes !== []) { 211 | return $arraysTypes; 212 | } 213 | } 214 | 215 | // general case with less precise error message lines 216 | if ($node instanceof Node\Stmt\Return_ || $node instanceof Node\Expr\YieldFrom) { 217 | if ($node->expr === null) { 218 | return []; 219 | } 220 | 221 | $exprType = $scope->getType($node->expr); 222 | $exprConstArrays = $exprType->getConstantArrays(); 223 | foreach ($exprConstArrays as $constArray) { 224 | foreach ($constArray->getValueTypes() as $valueType) { 225 | foreach ($valueType->getConstantArrays() as $constValueArray) { 226 | $arraysTypes[] = [$node->getStartLine(), $constValueArray]; 227 | } 228 | } 229 | } 230 | 231 | if ($arraysTypes === []) { 232 | foreach ($exprType->getIterableValueType()->getArrays() as $arrayType) { 233 | $arraysTypes[] = [$node->getStartLine(), $arrayType]; 234 | } 235 | } 236 | } elseif ($node instanceof Node\Expr\Yield_) { 237 | if ($node->value === null) { 238 | return []; 239 | } 240 | 241 | $exprType = $scope->getType($node->value); 242 | foreach ($exprType->getConstantArrays() as $constValueArray) { 243 | $arraysTypes[] = [$node->getStartLine(), $constValueArray]; 244 | } 245 | } 246 | 247 | return $arraysTypes; 248 | } 249 | 250 | } 251 | -------------------------------------------------------------------------------- /src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php: -------------------------------------------------------------------------------- 1 | = count($resolverReflection->getMethod('__invoke')->getParameters()) - 1; 62 | } 63 | 64 | private static function trimName(string $name): string 65 | { 66 | if (strpos($name, 'assert') !== 0) { 67 | return $name; 68 | } 69 | 70 | $name = substr($name, strlen('assert')); 71 | 72 | if (strpos($name, 'Not') === 0) { 73 | return substr($name, 3); 74 | } 75 | 76 | if (strpos($name, 'IsNot') === 0) { 77 | return 'Is' . substr($name, 5); 78 | } 79 | 80 | return $name; 81 | } 82 | 83 | /** 84 | * @param Arg[] $args $args 85 | */ 86 | public static function specifyTypes( 87 | TypeSpecifier $typeSpecifier, 88 | Scope $scope, 89 | string $name, 90 | array $args 91 | ): SpecifiedTypes 92 | { 93 | $expression = self::createExpression($scope, $name, $args); 94 | if ($expression === null) { 95 | return new SpecifiedTypes([], []); 96 | } 97 | 98 | $bypassAlwaysTrueIssue = in_array(self::trimName($name), self::$resolversCausingAlwaysTrue, true); 99 | 100 | return $typeSpecifier->specifyTypesInCondition( 101 | $scope, 102 | $expression, 103 | TypeSpecifierContext::createTruthy(), 104 | )->setRootExpr($bypassAlwaysTrueIssue ? new Expr\BinaryOp\BooleanAnd($expression, new Expr\Variable('nonsense')) : $expression); 105 | } 106 | 107 | /** 108 | * @param Arg[] $args 109 | */ 110 | private static function createExpression( 111 | Scope $scope, 112 | string $name, 113 | array $args 114 | ): ?Expr 115 | { 116 | $trimmedName = self::trimName($name); 117 | $resolvers = self::getExpressionResolvers(); 118 | $resolver = $resolvers[$trimmedName]; 119 | $expression = $resolver($scope, ...$args); 120 | if ($expression === null) { 121 | return null; 122 | } 123 | 124 | if (strpos($name, 'Not') !== false) { 125 | $expression = new BooleanNot($expression); 126 | } 127 | 128 | return $expression; 129 | } 130 | 131 | /** 132 | * @return Closure[] 133 | */ 134 | private static function getExpressionResolvers(): array 135 | { 136 | if (self::$resolvers === null) { 137 | self::$resolvers = [ 138 | 'Count' => static fn (Scope $scope, Arg $expected, Arg $actual): Identical => new Identical( 139 | $expected->value, 140 | new FuncCall(new Name('count'), [$actual]), 141 | ), 142 | 'NotCount' => static fn (Scope $scope, Arg $expected, Arg $actual): BooleanNot => new BooleanNot( 143 | new Identical( 144 | $expected->value, 145 | new FuncCall(new Name('count'), [$actual]), 146 | ), 147 | ), 148 | 'InstanceOf' => static fn (Scope $scope, Arg $class, Arg $object): Instanceof_ => new Instanceof_( 149 | $object->value, 150 | $class->value, 151 | ), 152 | 'Same' => static fn (Scope $scope, Arg $expected, Arg $actual): Identical => new Identical( 153 | $expected->value, 154 | $actual->value, 155 | ), 156 | 'True' => static fn (Scope $scope, Arg $actual): Identical => new Identical( 157 | $actual->value, 158 | new ConstFetch(new Name('true')), 159 | ), 160 | 'False' => static fn (Scope $scope, Arg $actual): Identical => new Identical( 161 | $actual->value, 162 | new ConstFetch(new Name('false')), 163 | ), 164 | 'Null' => static fn (Scope $scope, Arg $actual): Identical => new Identical( 165 | $actual->value, 166 | new ConstFetch(new Name('null')), 167 | ), 168 | 'Empty' => static fn (Scope $scope, Arg $actual): Expr\BinaryOp\BooleanOr => new Expr\BinaryOp\BooleanOr( 169 | new Instanceof_($actual->value, new Name(EmptyIterator::class)), 170 | new Expr\BinaryOp\BooleanOr( 171 | new Expr\BinaryOp\BooleanAnd( 172 | new Instanceof_($actual->value, new Name(Countable::class)), 173 | new Identical(new FuncCall(new Name('count'), [new Arg($actual->value)]), new LNumber(0)), 174 | ), 175 | new Expr\Empty_($actual->value), 176 | ), 177 | ), 178 | 'IsArray' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_array'), [$actual]), 179 | 'IsBool' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_bool'), [$actual]), 180 | 'IsCallable' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_callable'), [$actual]), 181 | 'IsFloat' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_float'), [$actual]), 182 | 'IsInt' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_int'), [$actual]), 183 | 'IsIterable' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_iterable'), [$actual]), 184 | 'IsNumeric' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_numeric'), [$actual]), 185 | 'IsObject' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_object'), [$actual]), 186 | 'IsResource' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_resource'), [$actual]), 187 | 'IsString' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_string'), [$actual]), 188 | 'IsScalar' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_scalar'), [$actual]), 189 | 'InternalType' => static function (Scope $scope, Arg $type, Arg $value): ?FuncCall { 190 | $typeNames = $scope->getType($type->value)->getConstantStrings(); 191 | if (count($typeNames) !== 1) { 192 | return null; 193 | } 194 | 195 | switch ($typeNames[0]->getValue()) { 196 | case 'numeric': 197 | $functionName = 'is_numeric'; 198 | break; 199 | case 'integer': 200 | case 'int': 201 | $functionName = 'is_int'; 202 | break; 203 | 204 | case 'double': 205 | case 'float': 206 | case 'real': 207 | $functionName = 'is_float'; 208 | break; 209 | 210 | case 'string': 211 | $functionName = 'is_string'; 212 | break; 213 | 214 | case 'boolean': 215 | case 'bool': 216 | $functionName = 'is_bool'; 217 | break; 218 | 219 | case 'scalar': 220 | $functionName = 'is_scalar'; 221 | break; 222 | 223 | case 'null': 224 | $functionName = 'is_null'; 225 | break; 226 | 227 | case 'array': 228 | $functionName = 'is_array'; 229 | break; 230 | 231 | case 'object': 232 | $functionName = 'is_object'; 233 | break; 234 | 235 | case 'resource': 236 | $functionName = 'is_resource'; 237 | break; 238 | 239 | case 'callable': 240 | $functionName = 'is_callable'; 241 | break; 242 | default: 243 | return null; 244 | } 245 | 246 | return new FuncCall( 247 | new Name($functionName), 248 | [ 249 | $value, 250 | ], 251 | ); 252 | }, 253 | 'ArrayHasKey' => static fn (Scope $scope, Arg $key, Arg $array): Expr => new Expr\BinaryOp\BooleanOr( 254 | new Expr\BinaryOp\BooleanAnd( 255 | new Expr\Instanceof_($array->value, new Name('ArrayAccess')), 256 | new Expr\MethodCall($array->value, 'offsetExists', [$key]), 257 | ), 258 | new FuncCall(new Name('array_key_exists'), [$key, $array]), 259 | ), 260 | 'ObjectHasAttribute' => static fn (Scope $scope, Arg $property, Arg $object): FuncCall => new FuncCall(new Name('property_exists'), [$object, $property]), 261 | 'ObjectHasProperty' => static fn (Scope $scope, Arg $property, Arg $object): FuncCall => new FuncCall(new Name('property_exists'), [$object, $property]), 262 | 'Contains' => static fn (Scope $scope, Arg $needle, Arg $haystack): Expr => new Expr\BinaryOp\BooleanOr( 263 | new Expr\Instanceof_($haystack->value, new Name('Traversable')), 264 | new FuncCall(new Name('in_array'), [$needle, $haystack, new Arg(new ConstFetch(new Name('true')))]), 265 | ), 266 | 'ContainsEquals' => static fn (Scope $scope, Arg $needle, Arg $haystack): Expr => new Expr\BinaryOp\BooleanOr( 267 | new Expr\Instanceof_($haystack->value, new Name('Traversable')), 268 | new Expr\BinaryOp\BooleanAnd( 269 | new Expr\BooleanNot(new Expr\Empty_($haystack->value)), 270 | new FuncCall(new Name('in_array'), [$needle, $haystack, new Arg(new ConstFetch(new Name('false')))]), 271 | ), 272 | ), 273 | 'ContainsOnlyInstancesOf' => static fn (Scope $scope, Arg $className, Arg $haystack): Expr => new Expr\BinaryOp\BooleanOr( 274 | new Expr\Instanceof_($haystack->value, new Name('Traversable')), 275 | new Identical( 276 | $haystack->value, 277 | new FuncCall(new Name('array_filter'), [ 278 | $haystack, 279 | new Arg(new Expr\Closure([ 280 | 'static' => true, 281 | 'params' => [ 282 | new Param(new Expr\Variable('_')), 283 | ], 284 | 'stmts' => [ 285 | new Stmt\Return_( 286 | new FuncCall(new Name('is_a'), [new Arg(new Expr\Variable('_')), $className]), 287 | ), 288 | ], 289 | ])), 290 | ]), 291 | ), 292 | ), 293 | ]; 294 | } 295 | 296 | return self::$resolvers; 297 | } 298 | 299 | } 300 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/DataProviderHelper.php: -------------------------------------------------------------------------------- 1 | reflectionProvider = $reflectionProvider; 50 | $this->fileTypeMapper = $fileTypeMapper; 51 | $this->parser = $parser; 52 | $this->PHPUnitVersion = $PHPUnitVersion; 53 | } 54 | 55 | /** 56 | * @param ReflectionMethod|ClassMethod $testMethod 57 | * 58 | * @return iterable 59 | */ 60 | public function getDataProviderMethods( 61 | Scope $scope, 62 | $testMethod, 63 | ClassReflection $classReflection 64 | ): iterable 65 | { 66 | yield from $this->yieldDataProviderAnnotations($testMethod, $scope, $classReflection); 67 | 68 | if (!$this->PHPUnitVersion->supportsDataProviderAttribute()->yes()) { 69 | return; 70 | } 71 | 72 | yield from $this->yieldDataProviderAttributes($testMethod, $classReflection); 73 | } 74 | 75 | /** 76 | * @return array 77 | */ 78 | private function getDataProviderAnnotations(?ResolvedPhpDocBlock $phpDoc): array 79 | { 80 | if ($phpDoc === null) { 81 | return []; 82 | } 83 | 84 | $phpDocNodes = $phpDoc->getPhpDocNodes(); 85 | 86 | $annotations = []; 87 | 88 | foreach ($phpDocNodes as $docNode) { 89 | $annotations = array_merge( 90 | $annotations, 91 | $docNode->getTagsByName('@dataProvider'), 92 | ); 93 | } 94 | 95 | return $annotations; 96 | } 97 | 98 | /** 99 | * @return list errors 100 | */ 101 | public function processDataProvider( 102 | string $dataProviderValue, 103 | ?ClassReflection $classReflection, 104 | string $methodName, 105 | int $lineNumber, 106 | bool $checkFunctionNameCase, 107 | bool $deprecationRulesInstalled 108 | ): array 109 | { 110 | if ($classReflection === null) { 111 | return [ 112 | RuleErrorBuilder::message(sprintf( 113 | '@dataProvider %s related class not found.', 114 | $dataProviderValue, 115 | )) 116 | ->line($lineNumber) 117 | ->identifier('phpunit.dataProviderClass') 118 | ->build(), 119 | ]; 120 | } 121 | 122 | try { 123 | $dataProviderMethodReflection = $classReflection->getNativeMethod($methodName); 124 | } catch (MissingMethodFromReflectionException $missingMethodFromReflectionException) { 125 | return [ 126 | RuleErrorBuilder::message(sprintf( 127 | '@dataProvider %s related method not found.', 128 | $dataProviderValue, 129 | )) 130 | ->line($lineNumber) 131 | ->identifier('phpunit.dataProviderMethod') 132 | ->build(), 133 | ]; 134 | } 135 | 136 | $errors = []; 137 | 138 | if ($checkFunctionNameCase && $methodName !== $dataProviderMethodReflection->getName()) { 139 | $errors[] = RuleErrorBuilder::message(sprintf( 140 | '@dataProvider %s related method is used with incorrect case: %s.', 141 | $dataProviderValue, 142 | $dataProviderMethodReflection->getName(), 143 | )) 144 | ->line($lineNumber) 145 | ->identifier('method.nameCase') 146 | ->build(); 147 | } 148 | 149 | if (!$dataProviderMethodReflection->isPublic()) { 150 | $errors[] = RuleErrorBuilder::message(sprintf( 151 | '@dataProvider %s related method must be public.', 152 | $dataProviderValue, 153 | )) 154 | ->line($lineNumber) 155 | ->identifier('phpunit.dataProviderPublic') 156 | ->build(); 157 | } 158 | 159 | if ( 160 | $deprecationRulesInstalled 161 | && $this->PHPUnitVersion->requiresStaticDataProviders()->yes() 162 | && !$dataProviderMethodReflection->isStatic() 163 | ) { 164 | $errorBuilder = RuleErrorBuilder::message(sprintf( 165 | '@dataProvider %s related method must be static in PHPUnit 10 and newer.', 166 | $dataProviderValue, 167 | )) 168 | ->line($lineNumber) 169 | ->identifier('phpunit.dataProviderStatic'); 170 | 171 | $dataProviderMethodReflectionDeclaringClass = $dataProviderMethodReflection->getDeclaringClass(); 172 | if ($dataProviderMethodReflectionDeclaringClass->getFileName() !== null) { 173 | $stmts = $this->parser->parseFile($dataProviderMethodReflectionDeclaringClass->getFileName()); 174 | $nodeFinder = new NodeFinder(); 175 | /** @var ClassMethod|null $methodNode */ 176 | $methodNode = $nodeFinder->findFirst($stmts, static fn ($node) => $node instanceof ClassMethod && $node->name->toString() === $dataProviderMethodReflection->getName()); 177 | if ($methodNode !== null) { 178 | $errorBuilder->fixNode($methodNode, static function (ClassMethod $methodNode) { 179 | $methodNode->flags |= Modifiers::STATIC; 180 | 181 | return $methodNode; 182 | }); 183 | } 184 | } 185 | $errors[] = $errorBuilder->build(); 186 | } 187 | 188 | return $errors; 189 | } 190 | 191 | private function getDataProviderAnnotationValue(PhpDocTagNode $phpDocTag): ?string 192 | { 193 | if (preg_match('/^[^ \t]+/', (string) $phpDocTag->value, $matches) !== 1) { 194 | return null; 195 | } 196 | 197 | return $matches[0]; 198 | } 199 | 200 | /** 201 | * @return array{ClassReflection|null, string} 202 | */ 203 | private function parseDataProviderAnnotationValue(Scope $scope, string $dataProviderValue): array 204 | { 205 | $parts = explode('::', $dataProviderValue, 2); 206 | if (count($parts) <= 1) { 207 | return [$scope->getClassReflection(), $dataProviderValue]; 208 | } 209 | 210 | if ($this->reflectionProvider->hasClass($parts[0])) { 211 | return [$this->reflectionProvider->getClass($parts[0]), $parts[1]]; 212 | } 213 | 214 | return [null, $dataProviderValue]; 215 | } 216 | 217 | /** 218 | * @return array|null 219 | */ 220 | private function parseDataProviderExternalAttribute(Attribute $attribute): ?array 221 | { 222 | if (count($attribute->args) !== 2) { 223 | return null; 224 | } 225 | $methodNameArg = $attribute->args[1]->value; 226 | if (!$methodNameArg instanceof String_) { 227 | return null; 228 | } 229 | $classNameArg = $attribute->args[0]->value; 230 | if ($classNameArg instanceof ClassConstFetch && $classNameArg->class instanceof Name) { 231 | $className = $classNameArg->class->toString(); 232 | } elseif ($classNameArg instanceof String_) { 233 | $className = $classNameArg->value; 234 | } else { 235 | return null; 236 | } 237 | 238 | $dataProviderClassReflection = null; 239 | if ($this->reflectionProvider->hasClass($className)) { 240 | $dataProviderClassReflection = $this->reflectionProvider->getClass($className); 241 | $className = $dataProviderClassReflection->getName(); 242 | } 243 | 244 | return [ 245 | sprintf('%s::%s', $className, $methodNameArg->value) => [ 246 | $dataProviderClassReflection, 247 | $methodNameArg->value, 248 | $attribute->getStartLine(), 249 | ], 250 | ]; 251 | } 252 | 253 | /** 254 | * @return array|null 255 | */ 256 | private function parseDataProviderAttribute(Attribute $attribute, ClassReflection $classReflection): ?array 257 | { 258 | if (count($attribute->args) !== 1) { 259 | return null; 260 | } 261 | $methodNameArg = $attribute->args[0]->value; 262 | if (!$methodNameArg instanceof String_) { 263 | return null; 264 | } 265 | 266 | return [ 267 | $methodNameArg->value => [ 268 | $classReflection, 269 | $methodNameArg->value, 270 | $attribute->getStartLine(), 271 | ], 272 | ]; 273 | } 274 | 275 | /** 276 | * @param ReflectionMethod|ClassMethod $node 277 | * 278 | * @return iterable 279 | */ 280 | private function yieldDataProviderAttributes($node, ClassReflection $classReflection): iterable 281 | { 282 | if ( 283 | $node instanceof ReflectionMethod 284 | ) { 285 | /** @phpstan-ignore function.alreadyNarrowedType */ 286 | if (!method_exists($node, 'getAttributes')) { 287 | return; 288 | } 289 | 290 | foreach ($node->getAttributes('PHPUnit\Framework\Attributes\DataProvider') as $attr) { 291 | $args = $attr->getArguments(); 292 | if (count($args) !== 1) { 293 | continue; 294 | } 295 | 296 | $startLine = $node->getStartLine(); 297 | if ($startLine === false) { 298 | $startLine = -1; 299 | } 300 | 301 | yield [$classReflection, $args[0], $startLine]; 302 | } 303 | 304 | return; 305 | } 306 | 307 | foreach ($node->attrGroups as $attrGroup) { 308 | foreach ($attrGroup->attrs as $attr) { 309 | $dataProviderMethod = null; 310 | if ($attr->name->toLowerString() === 'phpunit\\framework\\attributes\\dataprovider') { 311 | $dataProviderMethod = $this->parseDataProviderAttribute($attr, $classReflection); 312 | } elseif ($attr->name->toLowerString() === 'phpunit\\framework\\attributes\\dataproviderexternal') { 313 | $dataProviderMethod = $this->parseDataProviderExternalAttribute($attr); 314 | } 315 | if ($dataProviderMethod === null) { 316 | continue; 317 | } 318 | 319 | yield from $dataProviderMethod; 320 | } 321 | } 322 | } 323 | 324 | /** 325 | * @param ReflectionMethod|ClassMethod $node 326 | * 327 | * @return iterable 328 | */ 329 | private function yieldDataProviderAnnotations($node, Scope $scope, ClassReflection $classReflection): iterable 330 | { 331 | $docComment = $node->getDocComment(); 332 | if ($docComment === null || $docComment === false) { 333 | return; 334 | } 335 | 336 | $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( 337 | $scope->getFile(), 338 | $classReflection->getName(), 339 | $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, 340 | $node instanceof ClassMethod ? $node->name->toString() : $node->getName(), 341 | $docComment instanceof Doc ? $docComment->getText() : $docComment, 342 | ); 343 | foreach ($this->getDataProviderAnnotations($methodPhpDoc) as $annotation) { 344 | $dataProviderValue = $this->getDataProviderAnnotationValue($annotation); 345 | if ($dataProviderValue === null) { 346 | // Missing value is already handled in NoMissingSpaceInMethodAnnotationRule 347 | continue; 348 | } 349 | 350 | $startLine = $node->getStartLine(); 351 | if ($startLine === false) { 352 | $startLine = -1; 353 | } 354 | 355 | $dataProviderMethod = $this->parseDataProviderAnnotationValue($scope, $dataProviderValue); 356 | $dataProviderMethod[] = $startLine; 357 | 358 | yield $dataProviderValue => $dataProviderMethod; 359 | } 360 | } 361 | 362 | } 363 | --------------------------------------------------------------------------------