From 1ebe62ae3b36ec8995a8d5409a2693cd71e4c835 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sun, 23 Nov 2025 12:45:10 +1100 Subject: [PATCH] Added 3 new checks. ``` DrevOps.TestingPractices.DataProviderPrefix DrevOps.TestingPractices.DataProviderMatchesTestName DrevOps.TestingPractices.DataProviderOrder ``` --- .../DataProviderMatchesTestNameSniff.php | 296 ++++++++ .../DataProviderOrderSniff.php | 421 ++++++++++++ .../DataProviderPrefixSniff.php | 292 ++++++++ src/DrevOps/ruleset.xml | 17 + tests/Fixtures/DataProviderInvalid.php | 128 ++++ .../Fixtures/DataProviderMatchingInvalid.php | 118 ++++ tests/Fixtures/DataProviderMatchingValid.php | 118 ++++ tests/Fixtures/DataProviderOrderInvalid.php | 75 ++ tests/Fixtures/DataProviderOrderValid.php | 82 +++ tests/Fixtures/DataProviderValid.php | 86 +++ ...iderMatchesTestNameSniffFunctionalTest.php | 72 ++ .../DataProviderOrderSniffFunctionalTest.php | 62 ++ .../DataProviderPrefixSniffFunctionalTest.php | 75 ++ .../DataProviderMatchesTestNameSniffTest.php | 649 ++++++++++++++++++ tests/Unit/DataProviderOrderSniffTest.php | 524 ++++++++++++++ tests/Unit/DataProviderPrefixSniffTest.php | 574 ++++++++++++++++ 16 files changed, 3589 insertions(+) create mode 100644 src/DrevOps/Sniffs/TestingPractices/DataProviderMatchesTestNameSniff.php create mode 100644 src/DrevOps/Sniffs/TestingPractices/DataProviderOrderSniff.php create mode 100644 src/DrevOps/Sniffs/TestingPractices/DataProviderPrefixSniff.php create mode 100644 tests/Fixtures/DataProviderInvalid.php create mode 100644 tests/Fixtures/DataProviderMatchingInvalid.php create mode 100644 tests/Fixtures/DataProviderMatchingValid.php create mode 100644 tests/Fixtures/DataProviderOrderInvalid.php create mode 100644 tests/Fixtures/DataProviderOrderValid.php create mode 100644 tests/Fixtures/DataProviderValid.php create mode 100644 tests/Functional/DataProviderMatchesTestNameSniffFunctionalTest.php create mode 100644 tests/Functional/DataProviderOrderSniffFunctionalTest.php create mode 100644 tests/Functional/DataProviderPrefixSniffFunctionalTest.php create mode 100644 tests/Unit/DataProviderMatchesTestNameSniffTest.php create mode 100644 tests/Unit/DataProviderOrderSniffTest.php create mode 100644 tests/Unit/DataProviderPrefixSniffTest.php diff --git a/src/DrevOps/Sniffs/TestingPractices/DataProviderMatchesTestNameSniff.php b/src/DrevOps/Sniffs/TestingPractices/DataProviderMatchesTestNameSniff.php new file mode 100644 index 0000000..79a6f9a --- /dev/null +++ b/src/DrevOps/Sniffs/TestingPractices/DataProviderMatchesTestNameSniff.php @@ -0,0 +1,296 @@ + 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 + } + + /** + * 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; + } + + /** + * 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); + } + +} diff --git a/src/DrevOps/Sniffs/TestingPractices/DataProviderOrderSniff.php b/src/DrevOps/Sniffs/TestingPractices/DataProviderOrderSniff.php new file mode 100644 index 0000000..b11a2d8 --- /dev/null +++ b/src/DrevOps/Sniffs/TestingPractices/DataProviderOrderSniff.php @@ -0,0 +1,421 @@ +getTokens(); + + // Skip if not a test class. + if (!$this->isTestClass($phpcsFile, $stackPtr)) { + return; + } + + // Get class boundaries. + $class_start = $tokens[$stackPtr]['scope_opener'] ?? NULL; + $class_end = $tokens[$stackPtr]['scope_closer'] ?? NULL; + + // @codeCoverageIgnoreStart + if ($class_start === NULL || $class_end === NULL) { + return; + } + // @codeCoverageIgnoreEnd + // Build map of tests and their providers with line numbers. + $tests = $this->findTestsAndProviders($phpcsFile, $class_start, $class_end); + + // Build map of provider methods with line numbers. + $providers = $this->findProviderMethods($phpcsFile, $class_start, $class_end); + + // Validate order and report violations. + $this->validateOrder($phpcsFile, $tests, $providers); + } + + /** + * Determines if the current class is a test class. + * + * @param \PHP_CodeSniffer\Files\File $phpcsFile + * The file being scanned. + * @param int $stackPtr + * The position of the class token. + * + * @return bool + * TRUE if the class is a test class, FALSE otherwise. + */ + private function isTestClass(File $phpcsFile, int $stackPtr): bool { + $tokens = $phpcsFile->getTokens(); + + // Get the class name. + $class_name_ptr = $phpcsFile->findNext(T_STRING, $stackPtr + 1, $stackPtr + 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. + $extends_ptr = $phpcsFile->findNext(T_EXTENDS, $stackPtr + 1, $tokens[$stackPtr]['scope_opener']); + if ($extends_ptr !== FALSE) { + $parent_class_ptr = $phpcsFile->findNext(T_STRING, $extends_ptr + 1, $tokens[$stackPtr]['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; + } + + /** + * Finds test methods and their data providers with line numbers. + * + * @param \PHP_CodeSniffer\Files\File $phpcsFile + * The file being scanned. + * @param int $classStart + * The position of the class opening brace. + * @param int $classEnd + * The position of the class closing brace. + * + * @return array> + * Map of test names to their provider info: + * ['testName' => ['provider' => 'providerName', 'line' => 123]]. + */ + private function findTestsAndProviders(File $phpcsFile, int $classStart, int $classEnd): array { + $tokens = $phpcsFile->getTokens(); + $tests = []; + + // Scan for all function tokens in the class. + $function_ptr = $classStart; + while (($function_ptr = $phpcsFile->findNext(T_FUNCTION, $function_ptr + 1, $classEnd)) !== FALSE) { + // Get method name. + $method_name_ptr = $phpcsFile->findNext(T_STRING, $function_ptr + 1, $function_ptr + 3); + // @codeCoverageIgnoreStart + if ($method_name_ptr === FALSE) { + continue; + } + // @codeCoverageIgnoreEnd + $method_name = $tokens[$method_name_ptr]['content']; + + // Skip if not a test method. + if (!preg_match('/^test[A-Z]/', $method_name)) { + continue; + } + + // Find data provider annotation or attribute. + $provider_name = $this->findDataProviderAnnotation($phpcsFile, $function_ptr); + if ($provider_name === NULL) { + $provider_name = $this->findDataProviderAttribute($phpcsFile, $function_ptr); + } + + // Skip if no provider or external provider. + if ($provider_name === NULL) { + continue; + } + + // Store test info. + $test_line = $tokens[$method_name_ptr]['line']; + $tests[$method_name] = [ + 'provider' => $provider_name, + 'line' => $test_line, + ]; + } + + return $tests; + } + + /** + * Finds all provider methods with line numbers. + * + * @param \PHP_CodeSniffer\Files\File $phpcsFile + * The file being scanned. + * @param int $classStart + * The position of the class opening brace. + * @param int $classEnd + * The position of the class closing brace. + * + * @return array + * Map of provider names to line numbers: + * ['providerName' => 456]. + */ + private function findProviderMethods(File $phpcsFile, int $classStart, int $classEnd): array { + $tokens = $phpcsFile->getTokens(); + $providers = []; + + // Scan for all function tokens in the class. + $function_ptr = $classStart; + while (($function_ptr = $phpcsFile->findNext(T_FUNCTION, $function_ptr + 1, $classEnd)) !== FALSE) { + // Get method name. + $method_name_ptr = $phpcsFile->findNext(T_STRING, $function_ptr + 1, $function_ptr + 3); + // @codeCoverageIgnoreStart + if ($method_name_ptr === FALSE) { + continue; + } + // @codeCoverageIgnoreEnd + $method_name = $tokens[$method_name_ptr]['content']; + + // Store method line number. + $providers[$method_name] = $tokens[$method_name_ptr]['line']; + } + + return $providers; + } + + /** + * Validates that providers appear after their tests. + * + * @param \PHP_CodeSniffer\Files\File $phpcsFile + * The file being scanned. + * @param array> $tests + * Map of tests to provider info. + * @param array $providers + * Map of provider names to line numbers. + */ + private function validateOrder(File $phpcsFile, array $tests, array $providers): void { + // Track which providers we've already checked. + $checked_providers = []; + + foreach ($tests as $test_name => $test_info) { + $provider_name = $test_info['provider']; + $test_line = $test_info['line']; + + // Ensure provider name is a string. + // @codeCoverageIgnoreStart + if (!is_string($provider_name)) { + continue; + } + // @codeCoverageIgnoreEnd + // Skip if provider already checked (shared provider). + if (isset($checked_providers[$provider_name])) { + continue; + } + + // Mark as checked. + $checked_providers[$provider_name] = TRUE; + + // Check if provider exists in this class. + // @codeCoverageIgnoreStart + if (!isset($providers[$provider_name])) { + continue; + } + // @codeCoverageIgnoreEnd + $provider_line = $providers[$provider_name]; + + // Validate ordering based on configuration. + $has_violation = FALSE; + $error_code = ''; + $error_message = ''; + + if ($this->providerPosition === 'after') { + // Provider should be after test. + if ($provider_line < $test_line) { + $has_violation = TRUE; + $error_code = self::CODE_PROVIDER_BEFORE_TEST; + $error_message = 'Data provider method "%s" (line %d) appears before test method "%s" (line %d). Providers should be defined after their test methods'; + } + } + // @codeCoverageIgnoreStart + elseif ($this->providerPosition === 'before') { + // Provider should be before test. + if ($provider_line > $test_line) { + $has_violation = TRUE; + $error_code = self::CODE_PROVIDER_AFTER_TEST; + $error_message = 'Data provider method "%s" (line %d) appears after test method "%s" (line %d). Providers should be defined before their test methods'; + } + } + // @codeCoverageIgnoreEnd + if ($has_violation) { + $data = [$provider_name, $provider_line, $test_name, $test_line]; + + // Find the provider function token to report error at. + $tokens = $phpcsFile->getTokens(); + $error_ptr = NULL; + foreach ($tokens as $ptr => $token) { + if ($token['line'] === $provider_line && $token['code'] === T_STRING) { + $error_ptr = (int) $ptr; + break; + } + } + + if ($error_ptr !== NULL) { + $phpcsFile->addError($error_message, $error_ptr, $error_code, $data); + } + } + } + } + + /** + * 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; + } + +} diff --git a/src/DrevOps/Sniffs/TestingPractices/DataProviderPrefixSniff.php b/src/DrevOps/Sniffs/TestingPractices/DataProviderPrefixSniff.php new file mode 100644 index 0000000..df79a4c --- /dev/null +++ b/src/DrevOps/Sniffs/TestingPractices/DataProviderPrefixSniff.php @@ -0,0 +1,292 @@ + + */ + private array $dataProviders = []; + + /** + * Whether the data providers have been cached for the current file. + */ + private bool $dataProvidersCached = FALSE; + + /** + * The file currently being processed. + */ + private ?File $currentFile = NULL; + + /** + * {@inheritdoc} + */ + public function register(): array { + return [T_FUNCTION]; + } + + /** + * {@inheritdoc} + */ + public function process(File $phpcsFile, $stackPtr): void { + // Reset cache if processing a new file. + if ($this->currentFile !== $phpcsFile) { + $this->currentFile = $phpcsFile; + $this->dataProvidersCached = FALSE; + $this->dataProviders = []; + } + + // Skip if not in a test class. + if (!$this->isTestClass($phpcsFile, $stackPtr)) { + return; + } + + // Build cache of data providers on first function. + if (!$this->dataProvidersCached) { + $this->dataProviders = $this->findDataProviders($phpcsFile); + $this->dataProvidersCached = TRUE; + } + + // Get the function name. + $function_name_ptr = $phpcsFile->findNext(T_STRING, $stackPtr + 1, $stackPtr + 3); + // @codeCoverageIgnoreStart + // Anonymous functions/closures don't have names. This is defensive code + // for such cases and malformed token streams. + if ($function_name_ptr === FALSE) { + return; + } + // @codeCoverageIgnoreEnd + $function_name = $phpcsFile->getTokens()[$function_name_ptr]['content']; + + // Check if this method is a data provider. + if (!isset($this->dataProviders[$function_name])) { + return; + } + + // Check if the name starts with the correct prefix. + if ($this->hasCorrectPrefix($function_name)) { + return; + } + + // Suggest a new name. + $suggested_name = $this->suggestName($function_name); + + $error = 'Data provider method "%s" should start with prefix "%s", suggested name: "%s"'; + $data = [$function_name, $this->prefix, $suggested_name]; + + $fix = $phpcsFile->addFixableError($error, $function_name_ptr, self::CODE_INVALID_PREFIX, $data); + + // @codeCoverageIgnoreStart + if ($fix === TRUE) { + $this->fixProviderName($phpcsFile, $function_name, $suggested_name); + } + // @codeCoverageIgnoreEnd + } + + /** + * 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 + // PHPCS always sets class names for valid class tokens. This check is + // defensive code for malformed token streams. + 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. + $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; + } + + /** + * Extracts all data provider method names from @dataProvider annotations. + * + * @param \PHP_CodeSniffer\Files\File $phpcsFile + * The file being scanned. + * + * @return array + * Array of data provider method names as keys. + */ + private function findDataProviders(File $phpcsFile): array { + $tokens = $phpcsFile->getTokens(); + $providers = []; + + // Search for @dataProvider annotations in doc comments. + for ($i = 0; $i < $phpcsFile->numTokens; $i++) { + if ($tokens[$i]['code'] !== T_DOC_COMMENT_TAG) { + continue; + } + + if ($tokens[$i]['content'] !== '@dataProvider') { + continue; + } + + // Find the method name after the tag. + $string_ptr = $phpcsFile->findNext(T_DOC_COMMENT_STRING, $i + 1, $i + 3); + if ($string_ptr === FALSE) { + continue; + } + + $method_name = trim($tokens[$string_ptr]['content']); + + // Handle method names that might include class references. + // E.g., "ClassName::methodName" - we only want "methodName". + if (strpos($method_name, '::') !== FALSE) { + continue; + } + + $providers[$method_name] = TRUE; + } + + return $providers; + } + + /** + * Checks if a method name has the correct prefix. + * + * @param string $methodName + * The method name to check. + * + * @return bool + * TRUE if the name starts with the correct prefix, FALSE otherwise. + */ + private function hasCorrectPrefix(string $methodName): bool { + return str_starts_with($methodName, $this->prefix); + } + + /** + * Suggests a new name for a data provider method. + * + * @param string $currentName + * The current method name. + * + * @return string + * The suggested method name with the correct prefix. + */ + private function suggestName(string $currentName): string { + // Remove common prefixes. + $name = $currentName; + $common_prefixes = ['provider', 'provide', 'data', 'get']; + + foreach ($common_prefixes as $common_prefix) { + if (str_starts_with(strtolower($name), $common_prefix)) { + $name = substr($name, strlen($common_prefix)); + // Lowercase the first character if it's now uppercase. + if ($name !== '' && ctype_upper($name[0])) { + $name = lcfirst($name); + } + break; + } + } + + // Ensure we have a name after the prefix. + if (empty($name)) { + $name = $currentName; + } + + // Add the configured prefix. + return $this->prefix . ucfirst($name); + } + + /** + * Fixes the data provider method name throughout the file. + * + * @param \PHP_CodeSniffer\Files\File $phpcsFile + * The file being scanned. + * @param string $oldName + * The old method name. + * @param string $newName + * The new method name. + * + * @codeCoverageIgnore + */ + private function fixProviderName(File $phpcsFile, string $oldName, string $newName): void { + $tokens = $phpcsFile->getTokens(); + + // Fix the method declaration. + for ($i = 0; $i < $phpcsFile->numTokens; $i++) { + // Fix method name in function declaration. + if ($tokens[$i]['code'] === T_STRING && $tokens[$i]['content'] === $oldName) { + // Check if this is a function declaration. + $prev_ptr = $phpcsFile->findPrevious(T_WHITESPACE, $i - 1, NULL, TRUE); + if ($prev_ptr !== FALSE && $tokens[$prev_ptr]['code'] === T_FUNCTION) { + $phpcsFile->fixer->replaceToken($i, $newName); + } + } + + // Fix @dataProvider annotations. + if ($tokens[$i]['code'] === T_DOC_COMMENT_STRING) { + $content = $tokens[$i]['content']; + // Check if this string is part of @dataProvider annotation. + $prev_tag = $phpcsFile->findPrevious(T_DOC_COMMENT_TAG, $i - 1, $i - 3); + if ($prev_tag !== FALSE && $tokens[$prev_tag]['content'] === '@dataProvider' && trim($content) === $oldName) { + $phpcsFile->fixer->replaceToken($i, $newName); + } + } + } + } + +} diff --git a/src/DrevOps/ruleset.xml b/src/DrevOps/ruleset.xml index ff5c197..016e97e 100644 --- a/src/DrevOps/ruleset.xml +++ b/src/DrevOps/ruleset.xml @@ -7,4 +7,21 @@ + + + + + + + + + + + + + + + + + diff --git a/tests/Fixtures/DataProviderInvalid.php b/tests/Fixtures/DataProviderInvalid.php new file mode 100644 index 0000000..67bc6be --- /dev/null +++ b/tests/Fixtures/DataProviderInvalid.php @@ -0,0 +1,128 @@ +assertEquals($expected, $input); + } + + /** + * Data provider with wrong prefix - should trigger violation. + * + * @return array> + */ + public function providerInvalidCases(): array { + return [ + ['input1', 'expected1'], + ['input2', 'expected2'], + ]; + } + + /** + * Tests another thing with no prefix. + * + * @dataProvider casesForAnotherTest + */ + public function testAnother($value): void { + $this->assertNotEmpty($value); + } + + /** + * Data provider with no prefix - should trigger violation. + * + * @return array> + */ + public function casesForAnotherTest(): array { + return [ + ['value1'], + ['value2'], + ]; + } + + /** + * Tests with data provider using "provide" prefix. + * + * @dataProvider provideDataForTest + */ + public function testWithProvide($data): void { + $this->assertIsString($data); + } + + /** + * Data provider with "provide" prefix - should trigger violation. + * + * @return array> + */ + public function provideDataForTest(): array { + return [ + ['data1'], + ['data2'], + ]; + } + + /** + * Tests with data provider using "get" prefix. + * + * @dataProvider getTestData + */ + public function testWithGet($data): void { + $this->assertIsString($data); + } + + /** + * Data provider with "get" prefix - should trigger violation. + * + * @return array> + */ + public function getTestData(): array { + return [ + ['data1'], + ['data2'], + ]; + } + + /** + * Tests with multiple references to the same wrong provider. + * + * @dataProvider sharedProvider + */ + public function testShared1($data): void { + $this->assertIsString($data); + } + + /** + * Another test using the same wrong provider. + * + * @dataProvider sharedProvider + */ + public function testShared2($data): void { + $this->assertIsString($data); + } + + /** + * Data provider referenced multiple times - should trigger violation. + * + * @return array> + */ + public function sharedProvider(): array { + return [ + ['data1'], + ['data2'], + ]; + } + +} diff --git a/tests/Fixtures/DataProviderMatchingInvalid.php b/tests/Fixtures/DataProviderMatchingInvalid.php new file mode 100644 index 0000000..6bc8acc --- /dev/null +++ b/tests/Fixtures/DataProviderMatchingInvalid.php @@ -0,0 +1,118 @@ +assertNotEmpty($user); + } + + /** + * Data provider with wrong name (partial match). + * + * @return array> + */ + public function dataProviderLogin(): array { + return [ + ['user1', 'pass1'], + ['user2', 'pass2'], + ]; + } + + /** + * Test with provider that has suffix. + * + * @dataProvider dataProviderEmailValidationCases + */ + public function testEmailValidation($email): void { + $this->assertNotEmpty($email); + } + + /** + * Data provider with suffix (not exact match). + * + * @return array> + */ + public function dataProviderEmailValidationCases(): array { + return [ + ['test@example.com'], + ['user@test.org'], + ]; + } + + /** + * Test with provider that doesn't match at all. + * + * @dataProvider providerAuth + */ + public function testAuthenticationScenarios($scenario): void { + $this->assertIsString($scenario); + } + + /** + * Data provider with completely different name. + * + * @return array> + */ + public function providerAuth(): array { + return [ + ['scenario1'], + ['scenario2'], + ]; + } + + /** + * Test using attribute with wrong name. + */ + #[DataProvider('dataProviderPass')] + public function testPasswordValidation($password): void { + $this->assertNotEmpty($password); + } + + /** + * Data provider with wrong name (attribute). + * + * @return array> + */ + public function dataProviderPass(): array { + return [ + ['password123'], + ['securePass!'], + ]; + } + + /** + * Test using attribute with suffix. + */ + #[DataProvider('TokenGenerationCases')] + public function testTokenGeneration($token): void { + $this->assertIsString($token); + } + + /** + * Data provider with suffix (attribute). + * + * @return array> + */ + public function TokenGenerationCases(): array { + return [ + ['token1'], + ['token2'], + ]; + } + +} diff --git a/tests/Fixtures/DataProviderMatchingValid.php b/tests/Fixtures/DataProviderMatchingValid.php new file mode 100644 index 0000000..d77939b --- /dev/null +++ b/tests/Fixtures/DataProviderMatchingValid.php @@ -0,0 +1,118 @@ +assertNotEmpty($user); + } + + /** + * Data provider matching testUserLogin. + * + * @return array> + */ + public function dataProviderUserLogin(): array { + return [ + ['user1', 'pass1'], + ['user2', 'pass2'], + ]; + } + + /** + * Test with different prefix. + * + * @dataProvider providerEmailValidation + */ + public function testEmailValidation($email): void { + $this->assertNotEmpty($email); + } + + /** + * Data provider with different prefix. + * + * @return array> + */ + public function providerEmailValidation(): array { + return [ + ['test@example.com'], + ['user@test.org'], + ]; + } + + /** + * Test with no prefix on provider. + * + * @dataProvider AuthenticationScenarios + */ + public function testAuthenticationScenarios($scenario): void { + $this->assertIsString($scenario); + } + + /** + * Data provider with no prefix. + * + * @return array> + */ + public function AuthenticationScenarios(): array { + return [ + ['scenario1'], + ['scenario2'], + ]; + } + + /** + * Test using PHP 8 attribute syntax. + */ + #[DataProvider('dataProviderPasswordValidation')] + public function testPasswordValidation($password): void { + $this->assertNotEmpty($password); + } + + /** + * Data provider using attribute. + * + * @return array> + */ + public function dataProviderPasswordValidation(): array { + return [ + ['password123'], + ['securePass!'], + ]; + } + + /** + * Test using attribute with no prefix. + */ + #[DataProvider('TokenGeneration')] + public function testTokenGeneration($token): void { + $this->assertIsString($token); + } + + /** + * Data provider for token generation. + * + * @return array> + */ + public function TokenGeneration(): array { + return [ + ['token1'], + ['token2'], + ]; + } + +} diff --git a/tests/Fixtures/DataProviderOrderInvalid.php b/tests/Fixtures/DataProviderOrderInvalid.php new file mode 100644 index 0000000..a0b8a05 --- /dev/null +++ b/tests/Fixtures/DataProviderOrderInvalid.php @@ -0,0 +1,75 @@ +> + */ + public function dataProviderUserLogin(): array { + return [ + ['user1', 'pass1'], + ['user2', 'pass2'], + ]; + } + + /** + * Test that comes after its provider - WRONG ORDER. + * + * @dataProvider dataProviderUserLogin + */ + public function testUserLogin($user, $pass): void { + $this->assertNotEmpty($user); + } + + /** + * Another provider before test - VIOLATION. + * + * @return array> + */ + public function dataProviderEmailValidation(): array { + return [ + ['test@example.com'], + ]; + } + + /** + * Test after provider - WRONG ORDER. + * + * @dataProvider dataProviderEmailValidation + */ + public function testEmailValidation($email): void { + $this->assertNotEmpty($email); + } + + /** + * Provider before test using attribute - VIOLATION. + * + * @return array> + */ + public function dataProviderAuthentication(): array { + return [ + ['scenario1'], + ]; + } + + /** + * Test using attribute after provider - WRONG ORDER. + */ + #[DataProvider('dataProviderAuthentication')] + public function testAuthentication($scenario): void { + $this->assertIsString($scenario); + } + +} diff --git a/tests/Fixtures/DataProviderOrderValid.php b/tests/Fixtures/DataProviderOrderValid.php new file mode 100644 index 0000000..89eca14 --- /dev/null +++ b/tests/Fixtures/DataProviderOrderValid.php @@ -0,0 +1,82 @@ +assertNotEmpty($user); + } + + /** + * Data provider that comes after test. + * + * @return array> + */ + public function dataProviderUserLogin(): array { + return [ + ['user1', 'pass1'], + ['user2', 'pass2'], + ]; + } + + /** + * Another test before its provider. + * + * @dataProvider dataProviderEmailValidation + */ + public function testEmailValidation($email): void { + $this->assertNotEmpty($email); + } + + /** + * Helper method can be between test and provider. + */ + private function helperValidate(): void { + // Helper logic. + } + + /** + * Provider after helper method is OK. + * + * @return array> + */ + public function dataProviderEmailValidation(): array { + return [ + ['test@example.com'], + ]; + } + + /** + * Test using attribute. + */ + #[DataProvider('dataProviderAuthentication')] + public function testAuthentication($scenario): void { + $this->assertIsString($scenario); + } + + /** + * Provider for attribute test. + * + * @return array> + */ + public function dataProviderAuthentication(): array { + return [ + ['scenario1'], + ]; + } + +} diff --git a/tests/Fixtures/DataProviderValid.php b/tests/Fixtures/DataProviderValid.php new file mode 100644 index 0000000..c4f91a9 --- /dev/null +++ b/tests/Fixtures/DataProviderValid.php @@ -0,0 +1,86 @@ +assertEquals($expected, $input); + } + + /** + * Provides valid test cases. + * + * @return array> + */ + public function dataProviderValidCases(): array { + return [ + ['input1', 'expected1'], + ['input2', 'expected2'], + ]; + } + + /** + * Tests another thing with valid data. + * + * @dataProvider dataProviderAnotherSet + */ + public function testAnother($value): void { + $this->assertNotEmpty($value); + } + + /** + * Provides another set of test cases. + * + * @return array> + */ + public function dataProviderAnotherSet(): array { + return [ + ['value1'], + ['value2'], + ]; + } + + /** + * Tests with multiple data providers referenced. + * + * @dataProvider dataProviderForMultiple + */ + public function testMultiple($data): void { + $this->assertIsString($data); + } + + /** + * Another test using the same provider. + * + * @dataProvider dataProviderForMultiple + */ + public function testMultipleAgain($data): void { + $this->assertIsString($data); + } + + /** + * Provides data for multiple tests. + * + * @return array> + */ + public function dataProviderForMultiple(): array { + return [ + ['data1'], + ['data2'], + ]; + } + +} diff --git a/tests/Functional/DataProviderMatchesTestNameSniffFunctionalTest.php b/tests/Functional/DataProviderMatchesTestNameSniffFunctionalTest.php new file mode 100644 index 0000000..6312578 --- /dev/null +++ b/tests/Functional/DataProviderMatchesTestNameSniffFunctionalTest.php @@ -0,0 +1,72 @@ +runPhpcs(static::$fixtures . DIRECTORY_SEPARATOR . 'DataProviderMatchingValid.php'); + } + + /** + * Tests that valid matching provider names pass. + */ + public function testValidMatchingProvidersPass(): void { + $this->runPhpcs( + static::$fixtures . DIRECTORY_SEPARATOR . 'DataProviderMatchingValid.php', + [] + ); + } + + /** + * Tests that invalid non-matching provider names are detected. + */ + public function testInvalidNonMatchingProvidersDetected(): void { + $this->runPhpcs( + static::$fixtures . DIRECTORY_SEPARATOR . 'DataProviderMatchingInvalid.php', + [ + [ + 'message' => 'Data provider method "dataProviderLogin" does not match test method "testUserLogin". Expected provider name to end with "UserLogin"', + 'source' => 'DrevOps.TestingPractices.DataProviderMatchesTestName.InvalidProviderName', + 'fixable' => FALSE, + ], + [ + 'message' => 'Data provider method "dataProviderEmailValidationCases" does not match test method "testEmailValidation". Expected provider name to end with "EmailValidation"', + 'source' => 'DrevOps.TestingPractices.DataProviderMatchesTestName.InvalidProviderName', + 'fixable' => FALSE, + ], + [ + 'message' => 'Data provider method "providerAuth" does not match test method "testAuthenticationScenarios". Expected provider name to end with "AuthenticationScenarios"', + 'source' => 'DrevOps.TestingPractices.DataProviderMatchesTestName.InvalidProviderName', + 'fixable' => FALSE, + ], + [ + 'message' => 'Data provider method "dataProviderPass" does not match test method "testPasswordValidation". Expected provider name to end with "PasswordValidation"', + 'source' => 'DrevOps.TestingPractices.DataProviderMatchesTestName.InvalidProviderName', + 'fixable' => FALSE, + ], + [ + 'message' => 'Data provider method "TokenGenerationCases" does not match test method "testTokenGeneration". Expected provider name to end with "TokenGeneration"', + 'source' => 'DrevOps.TestingPractices.DataProviderMatchesTestName.InvalidProviderName', + 'fixable' => FALSE, + ], + ] + ); + } + +} diff --git a/tests/Functional/DataProviderOrderSniffFunctionalTest.php b/tests/Functional/DataProviderOrderSniffFunctionalTest.php new file mode 100644 index 0000000..0e705cc --- /dev/null +++ b/tests/Functional/DataProviderOrderSniffFunctionalTest.php @@ -0,0 +1,62 @@ +runPhpcs(static::$fixtures . DIRECTORY_SEPARATOR . 'DataProviderOrderValid.php'); + } + + /** + * Tests that correct provider ordering passes. + */ + public function testCorrectOrderingPasses(): void { + $this->runPhpcs( + static::$fixtures . DIRECTORY_SEPARATOR . 'DataProviderOrderValid.php', + [] + ); + } + + /** + * Tests that incorrect provider ordering is detected. + */ + public function testIncorrectOrderingDetected(): void { + $this->runPhpcs( + static::$fixtures . DIRECTORY_SEPARATOR . 'DataProviderOrderInvalid.php', + [ + [ + 'message' => 'Data provider method "dataProviderUserLogin" (line 20) appears before test method "testUserLogin" (line 32). Providers should be defined after their test methods', + 'source' => 'DrevOps.TestingPractices.DataProviderOrder.ProviderBeforeTest', + 'fixable' => FALSE, + ], + [ + 'message' => 'Data provider method "dataProviderEmailValidation" (line 41) appears before test method "testEmailValidation" (line 52). Providers should be defined after their test methods', + 'source' => 'DrevOps.TestingPractices.DataProviderOrder.ProviderBeforeTest', + 'fixable' => FALSE, + ], + [ + 'message' => 'Data provider method "dataProviderAuthentication" (line 61) appears before test method "testAuthentication" (line 71). Providers should be defined after their test methods', + 'source' => 'DrevOps.TestingPractices.DataProviderOrder.ProviderBeforeTest', + 'fixable' => FALSE, + ], + ] + ); + } + +} diff --git a/tests/Functional/DataProviderPrefixSniffFunctionalTest.php b/tests/Functional/DataProviderPrefixSniffFunctionalTest.php new file mode 100644 index 0000000..d450885 --- /dev/null +++ b/tests/Functional/DataProviderPrefixSniffFunctionalTest.php @@ -0,0 +1,75 @@ +runPhpcs(static::$fixtures . DIRECTORY_SEPARATOR . 'DataProviderValid.php'); + } + + /** + * Tests that valid data provider names pass without errors. + */ + public function testValidDataProviderNamesPass(): void { + $this->runPhpcs( + static::$fixtures . DIRECTORY_SEPARATOR . 'DataProviderValid.php', + [] + ); + } + + /** + * Tests that invalid data provider names are detected. + */ + public function testInvalidDataProviderNamesAreDetected(): void { + $this->runPhpcs( + static::$fixtures . DIRECTORY_SEPARATOR . 'DataProviderInvalid.php', + [ + [ + 'message' => 'Data provider method "providerInvalidCases" should start with prefix "dataProvider", suggested name: "dataProviderInvalidCases"', + 'source' => 'DrevOps.TestingPractices.DataProviderPrefix.InvalidPrefix', + 'fixable' => TRUE, + ], + [ + 'message' => 'Data provider method "casesForAnotherTest" should start with prefix "dataProvider", suggested name: "dataProviderCasesForAnotherTest"', + 'source' => 'DrevOps.TestingPractices.DataProviderPrefix.InvalidPrefix', + 'fixable' => TRUE, + ], + [ + 'message' => 'Data provider method "provideDataForTest" should start with prefix "dataProvider", suggested name: "dataProviderDataForTest"', + 'source' => 'DrevOps.TestingPractices.DataProviderPrefix.InvalidPrefix', + 'fixable' => TRUE, + ], + [ + 'message' => 'Data provider method "getTestData" should start with prefix "dataProvider", suggested name: "dataProviderTestData"', + 'source' => 'DrevOps.TestingPractices.DataProviderPrefix.InvalidPrefix', + 'fixable' => TRUE, + ], + [ + 'message' => 'Data provider method "sharedProvider" should start with prefix "dataProvider", suggested name: "dataProviderSharedProvider"', + 'source' => 'DrevOps.TestingPractices.DataProviderPrefix.InvalidPrefix', + 'fixable' => TRUE, + ], + ] + ); + } + +} diff --git a/tests/Unit/DataProviderMatchesTestNameSniffTest.php b/tests/Unit/DataProviderMatchesTestNameSniffTest.php new file mode 100644 index 0000000..3a2511d --- /dev/null +++ b/tests/Unit/DataProviderMatchesTestNameSniffTest.php @@ -0,0 +1,649 @@ +config->sniffs = ['DrevOps.TestingPractices.DataProviderMatchesTestName']; + $this->ruleset = new Ruleset($this->config); + $this->sniff = new DataProviderMatchesTestNameSniff(); + } + + /** + * Tests the register method. + */ + public function testRegister(): void { + $result = $this->sniff->register(); + $this->assertEquals([T_FUNCTION], $result); + } + + /** + * Tests isTestMethod with valid test method. + */ + public function testIsTestMethodWithValidTest(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestMethod'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, 'testUserLogin'); + $this->assertTrue($result); + } + + /** + * Tests isTestMethod with invalid methods. + */ + public function testIsTestMethodWithInvalidMethods(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestMethod'); + $method->setAccessible(TRUE); + + // Not starting with test. + $this->assertFalse($method->invoke($this->sniff, 'userLogin')); + + // Test not followed by uppercase. + $this->assertFalse($method->invoke($this->sniff, 'test_user_login')); + + // Helper method. + $this->assertFalse($method->invoke($this->sniff, 'helperMethod')); + } + + /** + * Tests extractTestName method. + */ + public function testExtractTestName(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('extractTestName'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, 'testUserLogin'); + $this->assertEquals('UserLogin', $result); + + $result = $method->invoke($this->sniff, 'testEmailValidation'); + $this->assertEquals('EmailValidation', $result); + } + + /** + * Tests providerMatchesTest with exact matches. + */ + public function testProviderMatchesTestWithExactMatch(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('providerMatchesTest'); + $method->setAccessible(TRUE); + + // Exact match. + $this->assertTrue($method->invoke($this->sniff, 'dataProviderUserLogin', 'UserLogin')); + + // Different prefix. + $this->assertTrue($method->invoke($this->sniff, 'providerUserLogin', 'UserLogin')); + + // No prefix. + $this->assertTrue($method->invoke($this->sniff, 'UserLogin', 'UserLogin')); + } + + /** + * Tests providerMatchesTest with non-matches. + */ + public function testProviderMatchesTestWithNonMatch(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('providerMatchesTest'); + $method->setAccessible(TRUE); + + // Partial match. + $this->assertFalse($method->invoke($this->sniff, 'dataProviderLogin', 'UserLogin')); + + // With suffix. + $this->assertFalse($method->invoke($this->sniff, 'dataProviderUserLoginCases', 'UserLogin')); + + // Completely different. + $this->assertFalse($method->invoke($this->sniff, 'providerAuth', 'UserLogin')); + } + + /** + * Tests findDataProviderAnnotation with valid annotation. + */ + public function testFindDataProviderAnnotationWithValid(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAnnotation'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertEquals('dataProviderUserLogin', $result); + } + + /** + * Tests findDataProviderAnnotation with external provider. + */ + public function testFindDataProviderAnnotationWithExternal(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAnnotation'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAnnotation with no annotation. + */ + public function testFindDataProviderAnnotationWithNone(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAnnotation'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests process method with matching provider. + */ + public function testProcessWithMatchingProvider(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + $this->assertEmpty($errors); + } + + /** + * Tests process method with non-matching provider. + */ + public function testProcessWithNonMatchingProvider(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + $this->assertNotEmpty($errors); + $error_messages = []; + foreach ($errors as $line_errors) { + foreach ($line_errors as $column_errors) { + foreach ($column_errors as $error) { + $error_messages[] = $error['message']; + } + } + } + + $this->assertCount(1, $error_messages); + $this->assertStringContainsString('Data provider method "dataProviderLogin"', $error_messages[0]); + $this->assertStringContainsString('does not match test method "testUserLogin"', $error_messages[0]); + $this->assertStringContainsString('Expected provider name to end with "UserLogin"', $error_messages[0]); + } + + /** + * Tests process method skips non-test methods. + */ + public function testProcessSkipsNonTestMethods(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + $this->assertEmpty($errors); + } + + /** + * Tests process method with external provider. + */ + public function testProcessWithExternalProvider(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + $this->assertEmpty($errors); + } + + /** + * Tests isTestClass with function not in class. + */ + public function testIsTestClassWithFunctionNotInClass(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestClass'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertFalse($result); + } + + /** + * Tests isTestClass with class extending TestCase. + */ + public function testIsTestClassWithExtendsTestCase(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestClass'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertTrue($result); + } + + /** + * Tests isTestClass with class extending non-TestCase. + */ + public function testIsTestClassWithExtendsNonTestCase(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestClass'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertFalse($result); + } + + /** + * Tests findDataProviderAnnotation with no comment opener. + */ + public function testFindDataProviderAnnotationWithNoCommentOpener(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAnnotation'); + $method->setAccessible(TRUE); + + // This should still work with single-line doc comments. + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertEquals('dataProviderTest', $result); + } + + /** + * Tests findDataProviderAnnotation with different tag. + */ + public function testFindDataProviderAnnotationWithDifferentTag(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAnnotation'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAnnotation with empty tag. + */ + public function testFindDataProviderAnnotationWithEmptyTag(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAnnotation'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAttribute with valid attribute. + */ + public function testFindDataProviderAttributeWithValid(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAttribute'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertEquals('dataProviderUserLogin', $result); + } + + /** + * Tests findDataProviderAttribute with no attribute. + */ + public function testFindDataProviderAttributeWithNone(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAttribute'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAttribute with distant attribute. + */ + public function testFindDataProviderAttributeWithDistantAttribute(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAttribute'); + $method->setAccessible(TRUE); + + // Should return NULL if attribute is too far (>10 tokens). + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAttribute with wrong attribute name. + */ + public function testFindDataProviderAttributeWithWrongName(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAttribute'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAttribute with no parenthesis. + */ + public function testFindDataProviderAttributeWithNoParenthesis(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAttribute'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAttribute with no string argument. + */ + public function testFindDataProviderAttributeWithNoString(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAttribute'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAttribute with external provider. + */ + public function testFindDataProviderAttributeWithExternal(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAttribute'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests process method with attribute-based provider. + */ + public function testProcessWithAttributeProvider(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + $this->assertEmpty($errors); + } + + /** + * Tests process method with non-matching attribute provider. + */ + public function testProcessWithNonMatchingAttributeProvider(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + $this->assertNotEmpty($errors); + $error_messages = []; + foreach ($errors as $line_errors) { + foreach ($line_errors as $column_errors) { + foreach ($column_errors as $error) { + $error_messages[] = $error['message']; + } + } + } + + $this->assertCount(1, $error_messages); + $this->assertStringContainsString('Data provider method "dataProviderLogin"', $error_messages[0]); + $this->assertStringContainsString('does not match test method "testUserLogin"', $error_messages[0]); + } + +} diff --git a/tests/Unit/DataProviderOrderSniffTest.php b/tests/Unit/DataProviderOrderSniffTest.php new file mode 100644 index 0000000..cd4c687 --- /dev/null +++ b/tests/Unit/DataProviderOrderSniffTest.php @@ -0,0 +1,524 @@ +config->sniffs = ['DrevOps.TestingPractices.DataProviderOrder']; + $this->ruleset = new Ruleset($this->config); + $this->sniff = new DataProviderOrderSniff(); + } + + /** + * Tests the register method. + */ + public function testRegister(): void { + $result = $this->sniff->register(); + $this->assertEquals([T_CLASS], $result); + } + + /** + * Tests process with correct order. + */ + public function testProcessWithCorrectOrder(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + $this->assertEmpty($errors); + } + + /** + * Tests process with incorrect order. + */ + public function testProcessWithIncorrectOrder(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + $this->assertNotEmpty($errors); + + $error_messages = []; + foreach ($errors as $line_errors) { + foreach ($line_errors as $column_errors) { + foreach ($column_errors as $error) { + $error_messages[] = $error['message']; + } + } + } + + $this->assertCount(1, $error_messages); + $this->assertStringContainsString('Data provider method "dataProviderUserLogin"', $error_messages[0]); + $this->assertStringContainsString('appears before test method "testUserLogin"', $error_messages[0]); + } + + /** + * Tests process with helper method between test and provider. + */ + public function testProcessWithHelperBetweenTestAndProvider(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + // Helper between test and provider is OK. + $this->assertEmpty($errors); + } + + /** + * Tests process with shared provider used by multiple tests. + */ + public function testProcessWithSharedProvider(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + // Shared provider after first test is OK. + $this->assertEmpty($errors); + } + + /** + * Tests process with external provider. + */ + public function testProcessWithExternalProvider(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + // External providers are skipped. + $this->assertEmpty($errors); + } + + /** + * Tests process with PHP 8 attribute. + */ + public function testProcessWithAttribute(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + $this->assertNotEmpty($errors); + } + + /** + * Tests process skips non-test classes. + */ + public function testProcessSkipsNonTestClasses(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + $this->assertEmpty($errors); + } + + /** + * Tests isTestClass with test class. + */ + public function testIsTestClassWithTestClass(): void { + $code = <<<'PHP' +processCode($code); + $class_ptr = $this->findClassToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestClass'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $class_ptr); + $this->assertTrue($result); + } + + /** + * Tests isTestClass with non-test class. + */ + public function testIsTestClassWithNonTestClass(): void { + $code = <<<'PHP' +processCode($code); + $class_ptr = $this->findClassToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestClass'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $class_ptr); + $this->assertFalse($result); + } + + /** + * Tests isTestClass with class extending TestCase. + */ + public function testIsTestClassWithExtendsTestCase(): void { + $code = <<<'PHP' +processCode($code); + $class_ptr = $this->findClassToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestClass'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $class_ptr); + $this->assertTrue($result); + } + + /** + * Tests isTestClass with class extending non-TestCase. + */ + public function testIsTestClassWithExtendsNonTestCase(): void { + $code = <<<'PHP' +processCode($code); + $class_ptr = $this->findClassToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestClass'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $class_ptr); + $this->assertFalse($result); + } + + /** + * Tests providerPosition property default value. + */ + public function testProviderPositionDefaultValue(): void { + $this->assertEquals('after', $this->sniff->providerPosition); + } + + /** + * Tests providerPosition property can be changed. + */ + public function testProviderPositionCanBeChanged(): void { + $this->sniff->providerPosition = 'before'; + $this->assertEquals('before', $this->sniff->providerPosition); + } + + /** + * Tests findDataProviderAnnotation with no comment opener. + */ + public function testFindDataProviderAnnotationWithNoCommentOpener(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAnnotation'); + $method->setAccessible(TRUE); + + // This should still work with single-line doc comments. + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertEquals('dataProviderTest', $result); + } + + /** + * Tests findDataProviderAnnotation with different tag. + */ + public function testFindDataProviderAnnotationWithDifferentTag(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAnnotation'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAnnotation with empty tag. + */ + public function testFindDataProviderAnnotationWithEmptyTag(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAnnotation'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAttribute with distant attribute. + */ + public function testFindDataProviderAttributeWithDistantAttribute(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAttribute'); + $method->setAccessible(TRUE); + + // Should return NULL if attribute is too far (>10 tokens). + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAttribute with wrong attribute name. + */ + public function testFindDataProviderAttributeWithWrongName(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAttribute'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAttribute with no parenthesis. + */ + public function testFindDataProviderAttributeWithNoParenthesis(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAttribute'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAttribute with no string argument. + */ + public function testFindDataProviderAttributeWithNoString(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAttribute'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + + /** + * Tests findDataProviderAttribute with external provider. + */ + public function testFindDataProviderAttributeWithExternal(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviderAttribute'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertNull($result); + } + +} diff --git a/tests/Unit/DataProviderPrefixSniffTest.php b/tests/Unit/DataProviderPrefixSniffTest.php new file mode 100644 index 0000000..d2bbfd1 --- /dev/null +++ b/tests/Unit/DataProviderPrefixSniffTest.php @@ -0,0 +1,574 @@ +config->sniffs = ['DrevOps.TestingPractices.DataProviderPrefix']; + $this->ruleset = new Ruleset($this->config); + $this->sniff = new DataProviderPrefixSniff(); + } + + /** + * Tests the register method. + */ + public function testRegister(): void { + $result = $this->sniff->register(); + $this->assertEquals([T_FUNCTION], $result); + } + + /** + * Tests isTestClass method with valid test class names. + */ + public function testIsTestClassWithValidTestClassName(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestClass'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertTrue($result); + } + + /** + * Tests isTestClass method with TestCase suffix. + */ + public function testIsTestClassWithTestCaseSuffix(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestClass'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertTrue($result); + } + + /** + * Tests isTestClass method with class extending TestCase. + */ + public function testIsTestClassWithExtendsTestCase(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestClass'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertTrue($result); + } + + /** + * Tests isTestClass method with non-test class. + */ + public function testIsTestClassWithNonTestClass(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestClass'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertFalse($result); + } + + /** + * Tests findDataProviders method. + */ + public function testFindDataProviders(): void { + $code = <<<'PHP' +processCode($code); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviders'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file); + $this->assertIsArray($result); + + $this->assertArrayHasKey('providerTestData', $result); + $this->assertArrayHasKey('anotherProvider', $result); + $this->assertEquals(TRUE, $result['providerTestData']); + $this->assertEquals(TRUE, $result['anotherProvider']); + } + + /** + * Tests findDataProviders with external class references. + */ + public function testFindDataProvidersSkipsExternalReferences(): void { + $code = <<<'PHP' +processCode($code); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviders'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file); + $this->assertIsArray($result); + + // Should only find localProvider, not ExternalClass::providerData. + $this->assertArrayNotHasKey('ExternalClass::providerData', $result); + $this->assertArrayHasKey('localProvider', $result); + } + + /** + * Tests hasCorrectPrefix method with correct prefix. + */ + public function testHasCorrectPrefixWithCorrectPrefix(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('hasCorrectPrefix'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, 'dataProviderTest'); + $this->assertTrue($result); + } + + /** + * Tests hasCorrectPrefix method with wrong prefix. + */ + public function testHasCorrectPrefixWithWrongPrefix(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('hasCorrectPrefix'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, 'providerTest'); + $this->assertFalse($result); + } + + /** + * Tests hasCorrectPrefix with custom prefix. + */ + public function testHasCorrectPrefixWithCustomPrefix(): void { + $this->sniff->prefix = 'provider'; + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('hasCorrectPrefix'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, 'providerTest'); + $this->assertTrue($result); + } + + /** + * Tests suggestName method with "provider" prefix. + */ + public function testSuggestNameWithProviderPrefix(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('suggestName'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, 'providerTestData'); + $this->assertEquals('dataProviderTestData', $result); + } + + /** + * Tests suggestName method with "provide" prefix. + */ + public function testSuggestNameWithProvidePrefix(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('suggestName'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, 'provideTestData'); + $this->assertEquals('dataProviderTestData', $result); + } + + /** + * Tests suggestName method with "data" prefix. + */ + public function testSuggestNameWithDataPrefix(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('suggestName'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, 'dataForTest'); + $this->assertEquals('dataProviderForTest', $result); + } + + /** + * Tests suggestName method with "get" prefix. + */ + public function testSuggestNameWithGetPrefix(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('suggestName'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, 'getTestData'); + $this->assertEquals('dataProviderTestData', $result); + } + + /** + * Tests suggestName method with no known prefix. + */ + public function testSuggestNameWithNoKnownPrefix(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('suggestName'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, 'testCases'); + $this->assertEquals('dataProviderTestCases', $result); + } + + /** + * Tests process method with valid data provider name. + */ + public function testProcessWithValidDataProviderName(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + // Should not have any errors. + $this->assertEmpty($errors); + } + + /** + * Tests process method with invalid data provider name. + */ + public function testProcessWithInvalidDataProviderName(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + // Should have one error. + $this->assertNotEmpty($errors); + + // Check error message. + $error_messages = []; + foreach ($errors as $line_errors) { + foreach ($line_errors as $column_errors) { + foreach ($column_errors as $error) { + $error_messages[] = $error['message']; + } + } + } + + $this->assertCount(1, $error_messages); + $this->assertStringContainsString('Data provider method "providerTestData"', $error_messages[0]); + $this->assertStringContainsString('should start with prefix "dataProvider"', $error_messages[0]); + $this->assertStringContainsString('suggested name: "dataProviderTestData"', $error_messages[0]); + } + + /** + * Tests process method skips non-test classes. + */ + public function testProcessSkipsNonTestClasses(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + // Should not have any errors because it's not a test class. + $this->assertEmpty($errors); + } + + /** + * Tests process method with multiple invalid providers. + */ + public function testProcessWithMultipleInvalidProviders(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + // Should have two errors. + $this->assertNotEmpty($errors); + + $error_messages = []; + foreach ($errors as $line_errors) { + foreach ($line_errors as $column_errors) { + foreach ($column_errors as $error) { + $error_messages[] = $error['message']; + } + } + } + + $this->assertCount(2, $error_messages); + } + + /** + * Tests process method with method that is not a data provider. + */ + public function testProcessSkipsNonDataProviderMethods(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + // Should not have any errors because methods are not data providers. + $this->assertEmpty($errors); + } + + /** + * Tests isTestClass with function not in a class. + */ + public function testIsTestClassWithFunctionNotInClass(): void { + $code = <<<'PHP' +processCode($code); + $function_ptr = $this->findFunctionToken($file); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('isTestClass'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file, $function_ptr); + $this->assertFalse($result); + } + + /** + * Tests findDataProviders with other doc comment tags. + */ + public function testFindDataProvidersWithOtherDocTags(): void { + $code = <<<'PHP' +processCode($code); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviders'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file); + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Tests findDataProviders with dataProvider annotation without method name. + */ + public function testFindDataProvidersWithEmptyMethodName(): void { + $code = <<<'PHP' +processCode($code); + + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('findDataProviders'); + $method->setAccessible(TRUE); + + $result = $method->invoke($this->sniff, $file); + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Tests suggestName with method name that has only a common prefix. + */ + public function testSuggestNameWithOnlyPrefix(): void { + $reflection = new \ReflectionClass($this->sniff); + $method = $reflection->getMethod('suggestName'); + $method->setAccessible(TRUE); + + // Test with "provider" only - should keep it. + $result = $method->invoke($this->sniff, 'provider'); + $this->assertEquals('dataProviderProvider', $result); + + // Test with "data" only - should keep it. + $result = $method->invoke($this->sniff, 'data'); + $this->assertEquals('dataProviderData', $result); + } + + /** + * Tests process method with anonymous function/closure. + */ + public function testProcessWithAnonymousFunction(): void { + $code = <<<'PHP' +processCode($code); + $errors = $file->getErrors(); + + // Should not have errors for closures. + $this->assertEmpty($errors); + } + +}