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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
<?php

declare(strict_types=1);

namespace DrevOps\Sniffs\TestingPractices;

use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;

/**
* Enforces that data provider method names match test method names.
*
* This sniff ensures that data provider methods have names that match the
* test methods they serve. The provider name must end with the exact test
* name (after removing "test" prefix).
*
* Examples:
* - testUserLogin -> must use provider ending with "UserLogin"
* - dataProviderUserLogin ✓
* - providerUserLogin ✓
* - userLogin ✓
* - dataProviderUserLoginCases ✗ (has suffix)
* - dataProviderLogin ✗ (partial match).
*/
class DataProviderMatchesTestNameSniff implements Sniff {

/**
* Error code for invalid provider name.
*/
private const CODE_INVALID_PROVIDER_NAME = 'InvalidProviderName';

/**
* {@inheritdoc}
*/
public function register(): array {
return [T_FUNCTION];
}

/**
* {@inheritdoc}
*/
public function process(File $phpcsFile, $stackPtr): void {
// Skip if not in a test class.
if (!$this->isTestClass($phpcsFile, $stackPtr)) {
return;
}

// Get the method name.
$method_name_ptr = $phpcsFile->findNext(T_STRING, $stackPtr + 1, $stackPtr + 3);
// @codeCoverageIgnoreStart
if ($method_name_ptr === FALSE) {
return;
}
// @codeCoverageIgnoreEnd
$method_name = $phpcsFile->getTokens()[$method_name_ptr]['content'];

// Skip if not a test method.
if (!$this->isTestMethod($method_name)) {
return;
}

// Extract test name (remove "test" prefix).
$test_name = $this->extractTestName($method_name);

// Find data provider annotation or attribute.
$provider_name = $this->findDataProviderAnnotation($phpcsFile, $stackPtr);
if ($provider_name === NULL) {
$provider_name = $this->findDataProviderAttribute($phpcsFile, $stackPtr);
}

// Skip if no provider found or external provider.
if ($provider_name === NULL) {
return;
}

// Check if provider name matches test name.
if (!$this->providerMatchesTest($provider_name, $test_name)) {
$error = 'Data provider method "%s" does not match test method "%s". Expected provider name to end with "%s"';
$data = [$provider_name, $method_name, $test_name];
$phpcsFile->addError($error, $method_name_ptr, self::CODE_INVALID_PROVIDER_NAME, $data);
}
}

/**
* Determines if the current file contains a test class.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile
* The file being scanned.
* @param int $stackPtr
* The position of the current token.
*
* @return bool
* TRUE if the file contains a test class, FALSE otherwise.
*/
private function isTestClass(File $phpcsFile, int $stackPtr): bool {
$tokens = $phpcsFile->getTokens();

// Find the class token.
$class_ptr = $phpcsFile->findPrevious(T_CLASS, $stackPtr);
if ($class_ptr === FALSE) {
return FALSE;
}

// Get the class name.
$class_name_ptr = $phpcsFile->findNext(T_STRING, $class_ptr + 1, $class_ptr + 3);
// @codeCoverageIgnoreStart
if ($class_name_ptr === FALSE) {
return FALSE;
}
// @codeCoverageIgnoreEnd
$class_name = $tokens[$class_name_ptr]['content'];

// Check if class name ends with Test or TestCase.
if (preg_match('/Test(Case)?$/', $class_name) === 1) {
return TRUE;
}

// Check if class extends TestCase or similar.
// @codeCoverageIgnoreStart
$extends_ptr = $phpcsFile->findNext(T_EXTENDS, $class_ptr + 1, $tokens[$class_ptr]['scope_opener']);
if ($extends_ptr !== FALSE) {
$parent_class_ptr = $phpcsFile->findNext(T_STRING, $extends_ptr + 1, $tokens[$class_ptr]['scope_opener']);
if ($parent_class_ptr !== FALSE) {
$parent_class = $tokens[$parent_class_ptr]['content'];
if (preg_match('/TestCase$/', $parent_class) === 1) {
return TRUE;
}
}
}

return FALSE;
// @codeCoverageIgnoreEnd
}
Comment on lines +84 to +133
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

isTestClass() can misclassify global test‑style functions as being in a test class

isTestClass() finds the nearest preceding T_CLASS but never checks that the current T_FUNCTION actually lies within that class’s scope. In a file that defines a test class followed by a global function like function testSomething() {}, isTestClass() will return true, so process() will treat that global function as a test method and enforce provider naming on it.

If your intention is to limit this sniff strictly to methods of test classes, consider tightening isTestClass() to ensure $stackPtr is inside the class’s scope_opener/scope_closer range before checking name/extends, or reusing the more robust implementation already used in DataProviderPrefixSniff.

🧰 Tools
🪛 PHPMD (2.15.0)

99-99: The variable $class_ptr is not named in camelCase. (undefined)

(CamelCaseVariableName)


105-105: The variable $class_name_ptr is not named in camelCase. (undefined)

(CamelCaseVariableName)


111-111: The variable $class_name is not named in camelCase. (undefined)

(CamelCaseVariableName)


120-120: The variable $extends_ptr is not named in camelCase. (undefined)

(CamelCaseVariableName)


122-122: The variable $parent_class_ptr is not named in camelCase. (undefined)

(CamelCaseVariableName)


124-124: The variable $parent_class is not named in camelCase. (undefined)

(CamelCaseVariableName)

🤖 Prompt for AI Agents
In src/DrevOps/Sniffs/TestingPractices/DataProviderMatchesTestNameSniff.php
around lines 84 to 133, isTestClass() currently finds the nearest preceding
T_CLASS but doesn't verify that the current token ($stackPtr) is inside that
class's scope, which causes global test-style functions after a class to be
misclassified; fix by ensuring the located class actually encloses $stackPtr:
after finding $class_ptr, check that the class has 'scope_opener' and
'scope_closer' in $tokens[$class_ptr] and return FALSE if they are missing or if
$stackPtr is not strictly between scope_opener and scope_closer, then proceed
with the existing name/extends checks (or, alternatively, walk previous classes
until you find one that contains $stackPtr or return FALSE if none do).


/**
* Checks if a method is a test method.
*
* @param string $methodName
* The method name to check.
*
* @return bool
* TRUE if the method is a test method, FALSE otherwise.
*/
private function isTestMethod(string $methodName): bool {
// Test methods must start with "test" followed by uppercase letter.
return preg_match('/^test[A-Z]/', $methodName) === 1;
}

/**
* Extracts the test name from a test method name.
*
* @param string $methodName
* The test method name (e.g., "testUserLogin").
*
* @return string
* The test name without "test" prefix (e.g., "UserLogin").
*/
private function extractTestName(string $methodName): string {
return substr($methodName, 4);
}

/**
* Finds data provider from @dataProvider annotation.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile
* The file being scanned.
* @param int $functionPtr
* The position of the function token.
*
* @return string|null
* The provider method name, or NULL if not found or external.
*/
private function findDataProviderAnnotation(File $phpcsFile, int $functionPtr): ?string {
$tokens = $phpcsFile->getTokens();

// Search backward for docblock before function.
$comment_end = $phpcsFile->findPrevious(T_DOC_COMMENT_CLOSE_TAG, $functionPtr - 1);
if ($comment_end === FALSE) {
return NULL;
}

$comment_start = $tokens[$comment_end]['comment_opener'] ?? NULL;
// @codeCoverageIgnoreStart
if ($comment_start === NULL) {
return NULL;
}
// @codeCoverageIgnoreEnd
// Look for @dataProvider tag in the docblock.
for ($i = $comment_start; $i <= $comment_end; $i++) {
// @codeCoverageIgnoreStart
if ($tokens[$i]['code'] !== T_DOC_COMMENT_TAG) {
continue;
}

if ($tokens[$i]['content'] !== '@dataProvider') {
continue;
}
// @codeCoverageIgnoreEnd
// Find the method name after the tag.
$string_ptr = $phpcsFile->findNext(T_DOC_COMMENT_STRING, $i + 1, $i + 3);
// @codeCoverageIgnoreStart
if ($string_ptr === FALSE) {
continue;
}
// @codeCoverageIgnoreEnd
$method_name = trim($tokens[$string_ptr]['content']);

// Skip external providers (ClassName::methodName).
if (strpos($method_name, '::') !== FALSE) {
return NULL;
}

return $method_name;
}

// @codeCoverageIgnoreStart
return NULL;
// @codeCoverageIgnoreEnd
}

/**
* Finds data provider from #[DataProvider] attribute.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile
* The file being scanned.
* @param int $functionPtr
* The position of the function token.
*
* @return string|null
* The provider method name, or NULL if not found or external.
*/
private function findDataProviderAttribute(File $phpcsFile, int $functionPtr): ?string {
$tokens = $phpcsFile->getTokens();

// Search backward for attribute before function.
$attribute_ptr = $phpcsFile->findPrevious(T_ATTRIBUTE, $functionPtr - 1);
if ($attribute_ptr === FALSE) {
return NULL;
}

// Check if attribute is close enough to function (within 10 tokens).
// @codeCoverageIgnoreStart
if ($functionPtr - $attribute_ptr > 10) {
return NULL;
}
// @codeCoverageIgnoreEnd
// Find the attribute name.
$name_ptr = $phpcsFile->findNext(T_STRING, $attribute_ptr + 1, $functionPtr);
// @codeCoverageIgnoreStart
if ($name_ptr === FALSE || $tokens[$name_ptr]['content'] !== 'DataProvider') {
return NULL;
}
// @codeCoverageIgnoreEnd
// Find the opening parenthesis of attribute.
$open_paren = $phpcsFile->findNext(T_OPEN_PARENTHESIS, $name_ptr + 1, $functionPtr);
// @codeCoverageIgnoreStart
if ($open_paren === FALSE) {
return NULL;
}
// @codeCoverageIgnoreEnd
// Find the string inside attribute (provider method name).
$string_ptr = $phpcsFile->findNext(T_CONSTANT_ENCAPSED_STRING, $open_paren + 1, $functionPtr);
// @codeCoverageIgnoreStart
if ($string_ptr === FALSE) {
return NULL;
}
// @codeCoverageIgnoreEnd
// Extract method name from string (remove quotes).
$method_name = trim($tokens[$string_ptr]['content'], '\'"');

// Skip external providers (ClassName::methodName).
// @codeCoverageIgnoreStart
if (strpos($method_name, '::') !== FALSE) {
return NULL;
}
// @codeCoverageIgnoreEnd
return $method_name;
}
Comment on lines +173 to +278
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Provider extraction logic duplicated with DataProviderOrderSniff

findDataProviderAnnotation() and findDataProviderAttribute() here are effectively the same as in DataProviderOrderSniff. That’s fine functionally, but it does mean any future change to how providers are discovered (e.g., new attribute forms, different “closeness” rules, or external-provider handling) must be kept in sync manually.

If you plan to evolve these sniffs further, consider extracting this logic into a shared helper/trait so both sniffs stay aligned with a single implementation.

🧰 Tools
🪛 PHPMD (2.15.0)

177-177: The variable $comment_end is not named in camelCase. (undefined)

(CamelCaseVariableName)


182-182: The variable $comment_start is not named in camelCase. (undefined)

(CamelCaseVariableName)


200-200: The variable $string_ptr is not named in camelCase. (undefined)

(CamelCaseVariableName)


206-206: The variable $method_name is not named in camelCase. (undefined)

(CamelCaseVariableName)


236-236: The variable $attribute_ptr is not named in camelCase. (undefined)

(CamelCaseVariableName)


248-248: The variable $name_ptr is not named in camelCase. (undefined)

(CamelCaseVariableName)


255-255: The variable $open_paren is not named in camelCase. (undefined)

(CamelCaseVariableName)


262-262: The variable $string_ptr is not named in camelCase. (undefined)

(CamelCaseVariableName)


269-269: The variable $method_name is not named in camelCase. (undefined)

(CamelCaseVariableName)

🤖 Prompt for AI Agents
In src/DrevOps/Sniffs/TestingPractices/DataProviderMatchesTestNameSniff.php
lines 173-278 the provider-extraction logic in findDataProviderAnnotation() and
findDataProviderAttribute() duplicates the same code in DataProviderOrderSniff;
extract this logic into a single shared helper (either a new utility class or a
trait, e.g. src/DrevOps/Utils/DataProviderResolver.php or
src/DrevOps/Sniffs/TestingPractices/DataProviderTrait.php) that exposes a method
like resolveProviderName(File $phpcsFile, int $functionPtr): ?string and move
the annotation/attribute parsing into it, then update both sniffs to call that
shared method and remove the duplicated implementations; ensure the helper
preserves existing behavior (docblock and attribute parsing, proximity checks,
external provider filtering) and update imports/namespace references
accordingly.


/**
* Checks if provider name matches test name.
*
* @param string $providerName
* The provider method name.
* @param string $testName
* The test name (without "test" prefix).
*
* @return bool
* TRUE if provider ends with exact test name, FALSE otherwise.
*/
private function providerMatchesTest(string $providerName, string $testName): bool {
// Provider name must end with exact test name (case-sensitive).
return str_ends_with($providerName, $testName);
}

}
Loading