Skip to content

Commit c289c43

Browse files
authored
Merge pull request #207 from andrewnicols/testcaseAttributes
Detect the use of Attributes for PHPUnit Sniffs
2 parents e7b7556 + 40a9ebf commit c289c43

20 files changed

+660
-91
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php
2+
3+
// This file is part of Moodle - https://moodle.org/
4+
//
5+
// Moodle is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU General Public License as published by
7+
// the Free Software Foundation, either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// Moodle is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU General Public License
16+
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
17+
18+
namespace MoodleHQ\MoodleCS\moodle\Sniffs\PHPUnit;
19+
20+
use MoodleHQ\MoodleCS\moodle\Util\Attributes;
21+
use MoodleHQ\MoodleCS\moodle\Util\MoodleUtil;
22+
use PHP_CodeSniffer\Sniffs\Sniff;
23+
use PHP_CodeSniffer\Files\File;
24+
use PHPCSUtils\Utils\ObjectDeclarations;
25+
26+
/**
27+
* Checks that a test file has the @coversxxx annotations properly defined.
28+
*
29+
* @copyright Andrew Lyons <[email protected]>
30+
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
31+
*/
32+
abstract class AbstractTestCaseSniff implements Sniff
33+
{
34+
/**
35+
* Check if the file is a test file that the sniff should check.
36+
*
37+
* @param \PHP_CodeSniffer\Files\File $file
38+
* @return bool
39+
*/
40+
protected function shouldCheckFile(File $file) {
41+
// Before starting any check, let's look for various things.
42+
43+
// If we aren't checking Moodle 4.0dev (400) and up, nothing to check.
44+
// Make and exception for codechecker phpunit tests, so they are run always.
45+
if (!MoodleUtil::meetsMinimumMoodleVersion($file, 400) && !MoodleUtil::isUnitTestRunning()) {
46+
return false; // @codeCoverageIgnore
47+
}
48+
49+
// If the file is not a unit test file, nothing to check.
50+
if (!MoodleUtil::isUnitTest($file) && !MoodleUtil::isUnitTestRunning()) {
51+
return false; // @codeCoverageIgnore
52+
}
53+
54+
return true;
55+
}
56+
57+
/**
58+
* Check if the sniff should check attributes on test cases.
59+
*
60+
* @param \PHP_CodeSniffer\Files\File $file
61+
* @return bool
62+
*/
63+
protected function shouldCheckTestCaseAttributes(File $file): bool {
64+
return MoodleUtil::meetsMinimumMoodleVersion($file, 500) !== false;
65+
}
66+
67+
/**
68+
* Get the test cases in a file.
69+
*
70+
* @param File $file
71+
* @param int $cStart
72+
* @return array
73+
*/
74+
protected function getTestCasesInFile(
75+
File $file,
76+
int $cStart = 0
77+
) {
78+
$tokens = $file->getTokens();
79+
$testClasses = [];
80+
while ($cStart = $file->findNext(T_CLASS, $cStart + 1)) {
81+
$className = $file->getDeclarationName($cStart);
82+
83+
// Only if the class is extending something.
84+
// TODO: We could add a list of valid classes once we have a class-map available.
85+
if (!$file->findNext(T_EXTENDS, $cStart + 1, $tokens[$cStart]['scope_opener'])) {
86+
continue;
87+
}
88+
89+
// Ignore any classname which does not end in "_test" or "_testcase".
90+
if (substr($className, -5) !== '_test' && substr($className, -9) !== '_testcase') {
91+
continue;
92+
}
93+
94+
$testClasses[$cStart] = $className;
95+
}
96+
97+
return $testClasses;
98+
}
99+
100+
/**
101+
* Get the test methods in a class.
102+
*
103+
* @param File $file
104+
* @param int $classPointer
105+
* @return array
106+
*/
107+
protected function getTestMethodsInClass(
108+
File $file,
109+
int $classPointer
110+
) {
111+
// Iterate over all the methods in the class.
112+
$methodPointers = ObjectDeclarations::getDeclaredMethods($file, $classPointer);
113+
114+
$testMethods = [];
115+
foreach ($methodPointers as $methodName => $methodPointer) {
116+
// The method must either:
117+
// 1. Start with 'test_'.
118+
// 2. Have a #[\PHPUnit\Framework\Attributes\Test] attribute
119+
120+
if (strpos($methodName, 'test_') === 0) {
121+
$testMethods[$methodName] = $methodPointer;
122+
unset($methodPointers[$methodName]);
123+
continue;
124+
}
125+
}
126+
127+
if ($this->shouldCheckTestCaseAttributes($file)) {
128+
$attributeName = \PHPUnit\Framework\Attributes\Test::class;
129+
foreach ($methodPointers as $methodName => $methodPointer) {
130+
if (Attributes::hasAttribute($file, $methodPointer, $attributeName)) {
131+
$testMethods[$methodName] = $methodPointer;
132+
}
133+
}
134+
}
135+
136+
return $testMethods;
137+
}
138+
}

moodle/Sniffs/PHPUnit/TestCaseCoversSniff.php

Lines changed: 78 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@
1717

1818
namespace MoodleHQ\MoodleCS\moodle\Sniffs\PHPUnit;
1919

20-
use MoodleHQ\MoodleCS\moodle\Util\MoodleUtil;
21-
use PHP_CodeSniffer\Sniffs\Sniff;
20+
use MoodleHQ\MoodleCS\moodle\Util\Attributes;
2221
use PHP_CodeSniffer\Files\File;
2322
use PHP_CodeSniffer\Util\Tokens;
2423
use PHPCSUtils\Utils\ObjectDeclarations;
@@ -29,7 +28,7 @@
2928
* @copyright 2022 onwards Eloy Lafuente (stronk7) {@link https://stronk7.com}
3029
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
3130
*/
32-
class TestCaseCoversSniff implements Sniff
31+
class TestCaseCoversSniff extends AbstractTestCaseSniff
3332
{
3433
/**
3534
* Register for open tag (only process once per file).
@@ -45,18 +44,12 @@ public function register() {
4544
* @param int $pointer The position in the stack.
4645
*/
4746
public function process(File $file, $pointer) {
48-
// Before starting any check, let's look for various things.
49-
50-
// If we aren't checking Moodle 4.0dev (400) and up, nothing to check.
51-
// Make and exception for codechecker phpunit tests, so they are run always.
52-
if (!MoodleUtil::meetsMinimumMoodleVersion($file, 400) && !MoodleUtil::isUnitTestRunning()) {
47+
if (!$this->shouldCheckFile($file)) {
48+
// Nothing to check.
5349
return; // @codeCoverageIgnore
5450
}
5551

56-
// If the file is not a unit test file, nothing to check.
57-
if (!MoodleUtil::isUnitTest($file) && !MoodleUtil::isUnitTestRunning()) {
58-
return; // @codeCoverageIgnore
59-
}
52+
$supportsAttributes = $this->shouldCheckTestCaseAttributes($file);
6053

6154
// We have all we need from core, let's start processing the file.
6255

@@ -73,29 +66,44 @@ public function process(File $file, $pointer) {
7366
return; // @codeCoverageIgnore
7467
}
7568

76-
// Iterate over all the classes (hopefully only one, but that's not this sniff problem).
77-
$cStart = $pointer;
78-
while ($cStart = $file->findNext(T_CLASS, $cStart + 1)) {
69+
foreach ($this->getTestCasesInFile($file, $pointer) as $cStart => $className) {
7970
$classInfo = ObjectDeclarations::getClassProperties($file, $cStart);
8071
if ($classInfo['is_abstract']) {
8172
// Abstract classes are not tested.
8273
// Coverage information belongs to the concrete classes that extend them.
8374
continue;
8475
}
76+
77+
8578
$class = $file->getDeclarationName($cStart);
8679
$classCovers = false; // To control when the class has a @covers tag.
8780
$classCoversNothing = false; // To control when the class has a @coversNothing tag.
8881
$classCoversDefaultClass = []; // To annotate all the existing @coversDefaultClass tags.
8982

90-
// Only if the class is extending something.
91-
// TODO: We could add a list of valid classes once we have a class-map available.
92-
if (!$file->findNext(T_EXTENDS, $cStart + 1, $tokens[$cStart]['scope_opener'])) {
93-
continue;
94-
}
95-
96-
// Ignore non ended "_test|_testcase" classes.
97-
if (substr($class, -5) !== '_test' && substr($class, -9) != '_testcase') {
98-
continue;
83+
if ($supportsAttributes) {
84+
// From PHPUnit 10 onwards, the class may be annotated with attributes.
85+
// The following attributes exist:
86+
// - #[\PHPUnit\Framework\Attributes\CoversClass]
87+
// - #[\PHPUnit\Framework\Attributes\CoversTrait]
88+
// - #[\PHPUnit\Framework\Attributes\CoversMethod]
89+
// - #[\PHPUnit\Framework\Attributes\CoversFunction]
90+
// - #[\PHPUnit\Framework\Attributes\CoversNothing]
91+
92+
// All of these may be at the class level.
93+
// Only the `CoversNothing` attribute is allowed to be used at the method level.
94+
95+
// Check if any valid Covers attributes are defined for this class.
96+
$validAttributes = [
97+
\PHPUnit\Framework\Attributes\CoversClass::class,
98+
\PHPUnit\Framework\Attributes\CoversTrait::class,
99+
\PHPUnit\Framework\Attributes\CoversMethod::class,
100+
\PHPUnit\Framework\Attributes\CoversFunction::class,
101+
\PHPUnit\Framework\Attributes\CoversNothing::class,
102+
];
103+
if ($this->containsCoversAttribute($file, $cStart, $validAttributes)) {
104+
// If the class has any of the valid attributes, we can skip the rest of the checks.
105+
continue;
106+
}
99107
}
100108

101109
// Let's see if the class has any phpdoc block (first non skip token must be end of phpdoc comment).
@@ -163,16 +171,24 @@ public function process(File $file, $pointer) {
163171
}
164172

165173
// Iterate over all the methods in the class.
166-
$mStart = $cStart;
167-
while ($mStart = $file->findNext(T_FUNCTION, $mStart + 1, $tokens[$cStart]['scope_closer'])) {
168-
$method = $file->getDeclarationName($mStart);
174+
// From PHPUnit 10 onwards, the class may be annotated with attributes.
175+
// The following method-level attributes exist:
176+
// - #[\PHPUnit\Framework\Attributes\CoversNothing]
177+
$validAttributes = [
178+
\PHPUnit\Framework\Attributes\CoversNothing::class,
179+
];
180+
foreach ($this->getTestMethodsInClass($file, $cStart) as $method => $mStart) {
181+
if ($supportsAttributes) {
182+
// Check if any valid Covers attributes are defined for this class.
183+
if ($this->containsCoversAttribute($file, $mStart, $validAttributes)) {
184+
// If the class has any of the valid attributes, we can skip the rest of the checks.
185+
continue;
186+
}
187+
}
188+
169189
$methodCovers = false; // To control when the method has a @covers tag.
170190
$methodCoversNothing = false; // To control when the method has a @coversNothing tag.
171191

172-
// Ignore non test_xxxx() methods.
173-
if (strpos($method, 'test_') !== 0) {
174-
continue;
175-
}
176192

177193
// Let's see if the method has any phpdoc block (first non skip token must be end of phpdoc comment).
178194
$docPointer = $file->findPrevious($skipTokens, $mStart - 1, null, true);
@@ -347,4 +363,35 @@ protected function checkCoversTagsSyntax(File $file, int $pointer, string $tag)
347363
}
348364
}
349365
}
366+
367+
/**
368+
* Checks if the class contains any of the valid @coversXXX attributes.
369+
*
370+
* @param File $file The file being scanned.
371+
* @param int $pointer The position in the stack.
372+
* @param array $validAttributes List of valid attributes to check against.
373+
* @return bool True if any valid attribute is found, false otherwise.
374+
*/
375+
protected function containsCoversAttribute(
376+
File $file,
377+
int $pointer,
378+
array $validAttributes
379+
): bool {
380+
// Find all the attributes for this class.
381+
$attributes = Attributes::getAttributePointers($file, $pointer);
382+
foreach ($attributes as $attributePtr) {
383+
$attribute = Attributes::getAttributeProperties($file, $attributePtr);
384+
if ($attribute === null) {
385+
// No attribute found, skip.
386+
continue; // @codeCoverageIgnore
387+
}
388+
389+
if (in_array($attribute['qualified_name'], $validAttributes)) {
390+
// Valid attribute found.
391+
return true;
392+
}
393+
}
394+
395+
return false;
396+
}
350397
}

0 commit comments

Comments
 (0)