Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New moodle.PHPUnit.TestCaseNames Sniff #161

Merged
merged 1 commit into from
Nov 29, 2021
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
271 changes: 271 additions & 0 deletions moodle/Sniffs/PHPUnit/TestCaseNamesSniff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.

/**
* Checks that a test file has a class name matching the file name.
*
* @package local_codechecker
* @copyright 2021 onwards Eloy Lafuente (stronk7) {@link https://stronk7.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/

namespace MoodleCodeSniffer\moodle\Sniffs\PHPUnit;

// phpcs:disable moodle.NamingConventions

use PHP_CodeSniffer\Sniffs\Sniff;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Util\Tokens;
use MoodleCodeSniffer\moodle\Util\MoodleUtil;

class TestCaseNamesSniff implements Sniff {

/**
* List of classes that have been found during checking.
*
* @var array
*/
protected $foundClasses = [];

/**
* List of classes that have been proposed during checking.
*
* @var array
*/
protected $proposedClasses = [];

/**
* Register for open tag (only process once per file).
*/
public function register() {
return array(T_OPEN_TAG);
}

/**
* Processes php files and perform various checks with file, namespace and class names.
* inclusion.
*
* @param File $file The file being scanned.
* @param int $pointer The position in the stack.
*/
public function process(File $file, $pointer) {

// Before starting any check, let's look for various things.

// Guess moodle component (from $file being processed).
$moodleComponent = MoodleUtil::getMoodleComponent($file);

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

// Get the file tokens, for ease of use.
$tokens = $file->getTokens();

// We only want to do this once per file.
$prevopentag = $file->findPrevious(T_OPEN_TAG, $pointer - 1);
if ($prevopentag !== false) {
return;
}

// If the file isn't under tests directory, nothing to check.
if (strpos($file->getFilename(), '/tests/') === false) {
return;
}

// If the file isn't called, _test.php, nothing to check.
$fileName = basename($file->getFilename());
if (substr($fileName, -9) !== '_test.php') {
// Make an exception for codechecker own phpunit fixtures here, allowing any name for them.
if (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST) {
return;
}
}

// In order to cover the duplicates detection, we need to set some
// properties (caches) here. It's extremely hard to do
// this via mocking / extending (at very least for this humble developer).
if (defined('PHPUNIT_TEST') && PHPUNIT_TEST) {
$this->prepareCachesForPHPUnit();
}

// Get the class namespace.
$namespace = '';
$nsStart = 0;
if ($nsStart = $file->findNext(T_NAMESPACE, ($pointer + 1))) {
$nsEnd = $file->findNext([T_NS_SEPARATOR, T_STRING, T_WHITESPACE], ($nsStart + 1), null, true);
$namespace = strtolower(trim($file->getTokensAsString(($nsStart + 1), ($nsEnd - $nsStart - 1))));
}
$pointer = $nsEnd ?? $pointer; // When possible, move the pointer to after the namespace name.

// Get the name of the 1st class in the file (this Sniff doesn't detects multiple),
// verify that it extends something and that has a test_ method.
$class = '';
$classFound = false;
while ($cStart = $file->findNext(T_CLASS, $pointer)) {
$pointer = $cStart + 1; // Move the pointer to the class start.

// Only if the class is extending something.
// TODO: We could add a list of valid classes once we have a class-map available.
if (!$file->findNext(T_EXTENDS, $cStart + 1, $tokens[$cStart]['scope_opener'])) {
continue;
}

// Verify that the class has some test_xxx method.
$method = '';
$methodFound = false;
while ($mStart = $file->findNext(T_FUNCTION, $pointer, $tokens[$cStart]['scope_closer'])) {
$pointer = $tokens[$mStart]['scope_closer']; // Next iteration look after the end of current method.
if (strpos($file->getDeclarationName($mStart), 'test_') === 0) {
$methodFound = true;
$method = $file->getDeclarationName($mStart);
break;
}
}

// If we have found a test_ method, this is our class (the 1st having one).
if ($methodFound) {
$classFound = true;
$class = $file->getDeclarationName($cStart);
$class = strtolower(trim($class));
break;
}
$pointer = $tokens[$cStart]['scope_closer']; // Move the pointer to the class end.
}

// No testcase class found, this is plain-wrong.
if (!$classFound) {
$file->addError('PHPUnit test file missing any valid testcase class declaration', 0, 'Missing');
return; // If arrived here we don't have a valid class, we are finished.
}

// All the following checks assume that a valid class has been found.

// Error if the found classname is "strange" (not "_test|_testcase" ended).
if (substr($class, -5) !== '_test' && substr($class, -9) != '_testcase') {
$file->addError('PHPUnit irregular testcase name found: %s (_test/_testcase ended expected)', $cStart,
'Irregular', [$class]);
}

// Check if the file name and the class name match, warn if not.
$baseName = pathinfo($fileName, PATHINFO_FILENAME);
if ($baseName !== $class) {
$file->addWarning('PHPUnit testcase name "%s" does not match file name "%s"', $cStart,
'NoMatch', [$class, $baseName]);
}

// Check if the class has been already found (this is useful when running against a lot of files).
$fdqnClass = $namespace ? $namespace . '\\' . $class : $class;
if (isset($this->foundClasses[$fdqnClass])) {
// Already found, this is a dupe class name, error!
foreach ($this->foundClasses[$fdqnClass] as $exists) {
$file->addError('PHPUnit testcase "%s" already exists at "%s" line %s', $cStart,
'DuplicateExists', [$fdqnClass, $exists['file'], $exists['line']]);
}
} else {
// Create the empty element.
$this->foundClasses[$fdqnClass] = [];
}

// Add the new element.
$this->foundClasses[$fdqnClass][] = [
'file' => $file->getFilename(),
'line' => $tokens[$cStart]['line'],
];

// Check if the class has been already proposed (this is useful when running against a lot of files).
if (isset($this->proposedClasses[$fdqnClass])) {
// Already found, this is a dupe class name, error!
foreach ($this->proposedClasses[$fdqnClass] as $exists) {
$file->addError('PHPUnit testcase "%s" already proposed for "%s" line %s. You ' .
'may want to change the testcase name (file and class)', $cStart,
'ProposedExists', [$fdqnClass, $exists['file'], $exists['line']]);
}
}

// Validate 1st level namespace.

if ($namespace && $moodleComponent) {
// Verify that the namespace declared in the class matches the namespace expected for the file.
if (strpos($namespace . '\\', $moodleComponent . '\\') !== 0) {
$file->addError('PHPUnit class namespace "%s" does not match expected file namespace "%s"', $nsStart,
'UnexpectedNS', [$namespace, $moodleComponent]);
}
}

if (!$namespace && $moodleComponent) {
$file->addWarning('PHUnit class "%s" does not have any namespace. It is recommended to add it to the "%s" ' .
'namespace, using more levels if needed, in order to match the code being tested', $cStart,
'MissingNS', [$fdqnClass, $moodleComponent]);

// Check if the proposed class has been already proposed (this is useful when running against a lot of files).
$fdqnProposed = $moodleComponent . '\\' . $fdqnClass;
if (isset($this->proposedClasses[$fdqnProposed])) {
// Already found, this is a dupe class name, error!
foreach ($this->proposedClasses[$fdqnProposed] as $exists) {
$file->addError('Proposed PHPUnit testcase "%s" already proposed for "%s" line %s. You ' .
'may want to change the testcase name (file and class)', $cStart,
'DuplicateProposed', [$fdqnProposed, $exists['file'], $exists['line']]);
}
} else {
// Create the empty element.
$this->proposedClasses[$fdqnProposed] = [];
}

// Add the new element.
$this->proposedClasses[$fdqnProposed][] = [
'file' => $file->getFilename(),
'line' => $tokens[$cStart]['line'],
];

// Check if the proposed class has been already found (this is useful when running against a lot of files).
if (isset($this->foundClasses[$fdqnProposed])) {
// Already found, this is a dupe class name, error!
foreach ($this->foundClasses[$fdqnProposed] as $exists) {
$file->addError('Proposed PHPUnit testcase "%s" already exists at "%s" line %s. You ' .
'may want to change the testcase name (file and class)', $cStart,
'ExistsProposed', [$fdqnProposed, $exists['file'], $exists['line']]);
}
}
}
}

/**
* Prepare found and proposed caches for PHPUnit.
*
* It's near impossible to extend or mock this class from PHPUnit in order
* to get the caches pre-filled with some values that will cover some
* of the logic of the sniff (at least for this developer).
*
* So we fill them here when it's detected that we are running PHPUnit.
*/
private function prepareCachesForPHPUnit() {
$this->foundClasses['local_codechecker\testcasenames_duplicate_exists'][] = [
'file' => 'phpunit_fake_exists',
'line' => -999,
];
$this->foundClasses['local_codechecker\testcasenames_exists_proposed'][] = [
'file' => 'phpunit_fake_exists',
'line' => -999,
];
$this->proposedClasses['local_codechecker\testcasenames_duplicate_proposed'][] = [
'file' => 'phpunit_fake_proposed',
'line' => -999,
];
$this->proposedClasses['local_codechecker\testcasenames_proposed_exists'][] = [
'file' => 'phpunit_fake_proposed',
'line' => -999,
];
}
}
11 changes: 11 additions & 0 deletions moodle/tests/fixtures/phpunit/testcasenames_duplicate_exists.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
namespace local_codechecker;
defined("MOODLE_INTERNAL") || die(); // Make this always the 1st line in all CS fixtures.

/**
* Correct class but with name not matching the file name.
*/
class testcasenames_duplicate_exists extends \local_codechecker_testcase {
public function test_something() {
}
}
10 changes: 10 additions & 0 deletions moodle/tests/fixtures/phpunit/testcasenames_duplicate_proposed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
defined("MOODLE_INTERNAL") || die(); // Make this always the 1st line in all CS fixtures.

/**
* Correct class but with name not matching the file name.
*/
class testcasenames_duplicate_proposed extends \local_codechecker_testcase {
public function test_something() {
}
}
10 changes: 10 additions & 0 deletions moodle/tests/fixtures/phpunit/testcasenames_exists_proposed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
defined("MOODLE_INTERNAL") || die(); // Make this always the 1st line in all CS fixtures.

/**
* Correct class but with name not matching the file name.
*/
class testcasenames_exists_proposed extends \local_codechecker_testcase {
public function test_something() {
}
}
21 changes: 21 additions & 0 deletions moodle/tests/fixtures/phpunit/testcasenames_missing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php
defined("MOODLE_INTERNAL") || die(); // Make this always the 1st line in all CS fixtures.

/**
* Class not extending anything
*/
class testcasenames_notextending {
// This class does not extend anything.
}

/**
* Class missing any test_ method
*/
class testcasenames_notestmethod extends local_codechecker_testcase {
public function notest_something() {
// This method is not a unit test.
}
public function notest_either() {
// This method is not a unit test.
}
}
10 changes: 10 additions & 0 deletions moodle/tests/fixtures/phpunit/testcasenames_missing_ns.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
defined("MOODLE_INTERNAL") || die(); // Make this always the 1st line in all CS fixtures.

/**
* Correct class but with missing namespace (and irregular test name).
*/
class testcasenames_missing_ns extends \local_codechecker_testcase {
public function test_something() {
}
}
11 changes: 11 additions & 0 deletions moodle/tests/fixtures/phpunit/testcasenames_nomatch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
namespace local_codechecker;
defined("MOODLE_INTERNAL") || die(); // Make this always the 1st line in all CS fixtures.

/**
* Correct class but with name not matching the file name.
*/
class testcasenames_nomatch_test extends local_codechecker_testcase {
public function test_something() {
}
}
11 changes: 11 additions & 0 deletions moodle/tests/fixtures/phpunit/testcasenames_proposed_exists.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
namespace local_codechecker;
defined("MOODLE_INTERNAL") || die(); // Make this always the 1st line in all CS fixtures.

/**
* Correct class but with name not matching the file name.
*/
class testcasenames_proposed_exists extends \local_codechecker_testcase {
public function test_something() {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
namespace local_codechecker;
defined("MOODLE_INTERNAL") || die(); // Make this always the 1st line in all CS fixtures.

/**
* Correct class but with incorrect name (not ended in _test or _testcase)
*/
class testcasenames_test_testcase_irregular extends local_codechecker_testcase {
public function test_something() {
}
}
11 changes: 11 additions & 0 deletions moodle/tests/fixtures/phpunit/testcasenames_unexpected_ns.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
namespace local_wrong;
defined("MOODLE_INTERNAL") || die(); // Make this always the 1st line in all CS fixtures.

/**
* Correct class but with name not matching the file name.
*/
class testcasenames_unexpected_ns extends \local_codechecker_testcase {
public function test_something() {
}
}
Loading