Skip to content

Commit dec4d55

Browse files
SanderMullerclaude
andcommitted
Report a data provider that provides no data sets
PHPUnit 10 and newer rejects a data provider that provides nothing. PHPUnit 9 skipped the test instead. EmptyDataProviderRule reports a provider method whose every return statement returns an iterable that is provably empty. It skips generators, because whether a generator yields at all depends on control flow the rule cannot decide. Closes #251 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 48c4b05 commit dec4d55

5 files changed

Lines changed: 521 additions & 0 deletions

File tree

rules.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ conditionalTags:
1919
PHPStan\Rules\PHPUnit\ClassAttributeRequiresPhpVersionRule:
2020
phpstan.rules.rule: %featureToggles.bleedingEdge%
2121

22+
PHPStan\Rules\PHPUnit\EmptyDataProviderRule:
23+
phpstan.rules.rule: %featureToggles.bleedingEdge%
24+
2225
services:
2326
-
2427
class: PHPStan\Rules\PHPUnit\DataProviderDeclarationRule
@@ -41,3 +44,6 @@ services:
4144

4245
-
4346
class: PHPStan\Rules\PHPUnit\DataProviderDataRule
47+
48+
-
49+
class: PHPStan\Rules\PHPUnit\EmptyDataProviderRule
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\PHPUnit;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Node\MethodReturnStatementsNode;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Rules\RuleErrorBuilder;
10+
use PHPUnit\Framework\TestCase;
11+
use function sprintf;
12+
use function strcasecmp;
13+
14+
/**
15+
* @implements Rule<MethodReturnStatementsNode>
16+
*/
17+
class EmptyDataProviderRule implements Rule
18+
{
19+
20+
private TestMethodsHelper $testMethodsHelper;
21+
22+
private DataProviderHelper $dataProviderHelper;
23+
24+
private PHPUnitVersion $PHPUnitVersion;
25+
26+
public function __construct(
27+
TestMethodsHelper $testMethodsHelper,
28+
DataProviderHelper $dataProviderHelper,
29+
PHPUnitVersion $PHPUnitVersion
30+
)
31+
{
32+
$this->testMethodsHelper = $testMethodsHelper;
33+
$this->dataProviderHelper = $dataProviderHelper;
34+
$this->PHPUnitVersion = $PHPUnitVersion;
35+
}
36+
37+
public function getNodeType(): string
38+
{
39+
return MethodReturnStatementsNode::class;
40+
}
41+
42+
public function processNode(Node $node, Scope $scope): array
43+
{
44+
if ($this->PHPUnitVersion->rejectsEmptyDataProviders()->no()) {
45+
return [];
46+
}
47+
48+
if (!$node->getClassReflection()->is(TestCase::class)) {
49+
return [];
50+
}
51+
52+
// A generator yields its data sets one at a time, so whether it provides
53+
// any depends on control flow this rule cannot decide.
54+
if ($node->isGenerator()) {
55+
return [];
56+
}
57+
58+
$returnStatements = $node->getReturnStatements();
59+
if ($returnStatements === []) {
60+
return [];
61+
}
62+
63+
foreach ($returnStatements as $returnStatement) {
64+
$returnExpr = $returnStatement->getReturnNode()->expr;
65+
if ($returnExpr === null) {
66+
return [];
67+
}
68+
69+
$returnType = $returnStatement->getScope()->getType($returnExpr);
70+
if (!$returnType->isIterable()->yes() || !$returnType->isIterableAtLeastOnce()->no()) {
71+
return [];
72+
}
73+
}
74+
75+
if (!$this->isDataProvider($node, $scope)) {
76+
return [];
77+
}
78+
79+
return [
80+
RuleErrorBuilder::message(sprintf(
81+
'Data provider method %s() provides no data sets, which is an error in PHPUnit 10 and newer.',
82+
$node->getMethodName(),
83+
))
84+
->identifier('phpunit.dataProviderEmpty')
85+
->build(),
86+
];
87+
}
88+
89+
private function isDataProvider(MethodReturnStatementsNode $node, Scope $scope): bool
90+
{
91+
$classReflection = $node->getClassReflection();
92+
93+
foreach ($this->testMethodsHelper->getTestMethods($classReflection, $scope) as $testMethod) {
94+
foreach ($this->dataProviderHelper->getDataProviderMethods($scope, $testMethod, $classReflection) as [$providerClassReflection, $providerMethodName]) {
95+
// A provider declared in another class is checked when that class is analysed.
96+
if ($providerClassReflection === null || $providerClassReflection->getName() !== $classReflection->getName()) {
97+
continue;
98+
}
99+
100+
if (strcasecmp($providerMethodName, $node->getMethodName()) === 0) {
101+
return true;
102+
}
103+
}
104+
}
105+
106+
return false;
107+
}
108+
109+
}

src/Rules/PHPUnit/PHPUnitVersion.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ public function requiresStaticDataProviders(): TrinaryLogic
5858
return TrinaryLogic::createFromBoolean($this->majorVersion >= 10);
5959
}
6060

61+
public function rejectsEmptyDataProviders(): TrinaryLogic
62+
{
63+
if ($this->majorVersion === null) {
64+
return TrinaryLogic::createMaybe();
65+
}
66+
return TrinaryLogic::createFromBoolean($this->majorVersion >= 10);
67+
}
68+
6169
public function supportsNamedArgumentsInDataProvider(): TrinaryLogic
6270
{
6371
if ($this->majorVersion === null) {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\PHPUnit;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
use PHPStan\Type\FileTypeMapper;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use const PHP_VERSION_ID;
10+
11+
/**
12+
* @extends RuleTestCase<EmptyDataProviderRule>
13+
*/
14+
class EmptyDataProviderRuleTest extends RuleTestCase
15+
{
16+
17+
private ?int $phpunitVersion;
18+
19+
protected function getRule(): Rule
20+
{
21+
$phpunitVersion = new PHPUnitVersion($this->phpunitVersion, 0);
22+
23+
return new EmptyDataProviderRule(
24+
new TestMethodsHelper(
25+
self::getContainer()->getByType(FileTypeMapper::class),
26+
$phpunitVersion,
27+
),
28+
new DataProviderHelper(
29+
$this->createReflectionProvider(),
30+
self::getContainer()->getByType(FileTypeMapper::class),
31+
self::getContainer()->getService('defaultAnalysisParser'),
32+
$phpunitVersion,
33+
),
34+
$phpunitVersion,
35+
);
36+
}
37+
38+
/**
39+
* @dataProvider provideVersions
40+
*/
41+
#[DataProvider('provideVersions')]
42+
public function testRule(?int $version): void
43+
{
44+
if (PHP_VERSION_ID < 80000) {
45+
self::markTestSkipped('Test requires PHP 8.0 for attributes.');
46+
}
47+
48+
$this->phpunitVersion = $version;
49+
50+
$errors = [];
51+
52+
// PHPUnit 9 skips an empty data provider instead of erroring.
53+
if ($version === null || $version >= 10) {
54+
$errors[] = [
55+
'Data provider method provideData() provides no data sets, which is an error in PHPUnit 10 and newer.',
56+
21,
57+
];
58+
$errors[] = [
59+
'Data provider method provideData() provides no data sets, which is an error in PHPUnit 10 and newer.',
60+
109,
61+
];
62+
$errors[] = [
63+
'Data provider method provideData() provides no data sets, which is an error in PHPUnit 10 and newer.',
64+
158,
65+
];
66+
}
67+
68+
// DataProviderHelper only reads the attribute when the detected major version supports it.
69+
if ($version !== null && $version >= 10) {
70+
$errors[] = [
71+
'Data provider method provideData() provides no data sets, which is an error in PHPUnit 10 and newer.',
72+
173,
73+
];
74+
}
75+
76+
$this->analyse([__DIR__ . '/data/empty-data-provider.php'], $errors);
77+
}
78+
79+
/**
80+
* @return iterable<array{?int}>
81+
*/
82+
public static function provideVersions(): iterable
83+
{
84+
yield [null];
85+
yield [9];
86+
yield [10];
87+
yield [11];
88+
yield [12];
89+
}
90+
91+
public static function getAdditionalConfigFiles(): array
92+
{
93+
return [__DIR__ . '/../../../extension.neon'];
94+
}
95+
96+
}

0 commit comments

Comments
 (0)