trait DrupalStandardsListenerTrait

Same name and namespace in other branches
  1. 8.9.x core/tests/Drupal/Tests/Listeners/DrupalStandardsListenerTrait.php \Drupal\Tests\Listeners\DrupalStandardsListenerTrait

Listens for PHPUnit tests and fails those with invalid coverage annotations.

Enforces various coding standards within test runs.

@internal

Hierarchy

File

core/tests/Drupal/Tests/Listeners/DrupalStandardsListenerTrait.php, line 18

Namespace

Drupal\Tests\Listeners
View source
trait DrupalStandardsListenerTrait {
    
    /**
     * Signals a coding standards failure to the user.
     *
     * @param \PHPUnit\Framework\TestCase $test
     *   The test where we should insert our test failure.
     * @param string $message
     *   The message to add to the failure notice. The test class name and test
     *   name will be appended to this message automatically.
     */
    private function fail(TestCase $test, $message) {
        // Add the report to the test's results.
        $message .= ': ' . get_class($test) . '::' . $test->getName();
        $fail = new AssertionFailedError($message);
        $result = $test->getTestResultObject();
        $result->addFailure($test, $fail, 0);
    }
    
    /**
     * Helper method to check if a string names a valid class or trait.
     *
     * @param string $class
     *   Name of the class to check.
     *
     * @return bool
     *   TRUE if the class exists, FALSE otherwise.
     */
    private function classExists($class) {
        return class_exists($class, TRUE) || trait_exists($class, TRUE);
    }
    
    /**
     * Check an individual test run for valid @covers annotation.
     *
     * This method is called from $this::endTest().
     *
     * @param \PHPUnit\Framework\TestCase $test
     *   The test to examine.
     */
    private function checkValidCoversForTest(TestCase $test) {
        // If we're generating a coverage report already, don't do anything here.
        if ($test->getTestResultObject() && $test->getTestResultObject()
            ->getCollectCodeCoverageInformation()) {
            return;
        }
        // Gather our annotations.
        $annotations = Test::parseTestMethodAnnotations(static::class, $test->getName());
        // Glean the @coversDefaultClass annotation.
        $default_class = '';
        $valid_default_class = FALSE;
        if (isset($annotations['class']['coversDefaultClass'])) {
            if (count($annotations['class']['coversDefaultClass']) > 1) {
                $this->fail($test, '@coversDefaultClass has too many values');
            }
            // Grab the first one.
            $default_class = reset($annotations['class']['coversDefaultClass']);
            // Check whether the default class exists.
            $valid_default_class = $this->classExists($default_class);
            if (!$valid_default_class && interface_exists($default_class)) {
                $this->fail($test, "@coversDefaultClass refers to an interface '{$default_class}' and those can not be tested.");
            }
            elseif (!$valid_default_class) {
                $this->fail($test, "@coversDefaultClass does not exist '{$default_class}'");
            }
        }
        // Glean @covers annotation.
        if (isset($annotations['method']['covers'])) {
            // Drupal allows multiple @covers per test method, so we have to check
            // them all.
            foreach ($annotations['method']['covers'] as $covers) {
                // Ensure the annotation isn't empty.
                if (trim($covers) === '') {
                    $this->fail($test, '@covers should not be empty');
                    // If @covers is empty, we can't proceed.
                    return;
                }
                // Ensure we don't have ().
                if (strpos($covers, '()') !== FALSE) {
                    $this->fail($test, "@covers invalid syntax: Do not use '()'");
                }
                // Glean the class and method from @covers.
                $class = $covers;
                $method = '';
                if (strpos($covers, '::') !== FALSE) {
                    [
                        $class,
                        $method,
                    ] = explode('::', $covers);
                }
                // Check for the existence of the class if it's specified by @covers.
                if (!empty($class)) {
                    // If the class doesn't exist we have either a bad classname or
                    // are missing the :: for a method. Either way we can't proceed.
                    if (!$this->classExists($class)) {
                        if (empty($method)) {
                            $this->fail($test, "@covers invalid syntax: Needs '::' or class does not exist in {$covers}");
                            return;
                        }
                        elseif (interface_exists($class)) {
                            $this->fail($test, "@covers refers to an interface '{$class}' and those can not be tested.");
                        }
                        else {
                            $this->fail($test, '@covers class does not exist ' . $class);
                            return;
                        }
                    }
                }
                else {
                    // The class isn't specified and we have the ::, so therefore this
                    // test either covers a function, or relies on a default class.
                    if (empty($default_class)) {
                        // If there's no default class, then we need to check if the global
                        // function exists. Since this listener should always be listening
                        // for endTest(), the function should have already been loaded from
                        // its .module or .inc file.
                        if (!function_exists($method)) {
                            $this->fail($test, '@covers global method does not exist ' . $method);
                        }
                    }
                    else {
                        // We have a default class and this annotation doesn't act like a
                        // global function, so we should use the default class if it's
                        // valid.
                        if ($valid_default_class) {
                            $class = $default_class;
                        }
                    }
                }
                // Finally, after all that, let's see if the method exists.
                if (!empty($class) && !empty($method)) {
                    $ref_class = new \ReflectionClass($class);
                    if (!$ref_class->hasMethod($method)) {
                        $this->fail($test, '@covers method does not exist ' . $class . '::' . $method);
                    }
                }
            }
        }
    }
    
    /**
     * Handles errors to ensure deprecation messages are not triggered.
     *
     * @param int $type
     *   The severity level of the error.
     * @param string $msg
     *   The error message.
     * @param $file
     *   The file that caused the error.
     * @param $line
     *   The line number that caused the error.
     * @param array $context
     *   The error context.
     */
    public static function errorHandler($type, $msg, $file, $line, $context = []) {
        if ($type === E_USER_DEPRECATED) {
            return;
        }
        return ErrorHandler::handleError($type, $msg, $file, $line, $context);
    }
    
    /**
     * Reacts to the end of a test.
     *
     * We must mark this method as belonging to the special legacy group because
     * it might trigger an E_USER_DEPRECATED error during coverage annotation
     * validation. The legacy group allows symfony/phpunit-bridge to keep the
     * deprecation notice as a warning instead of an error, which would fail the
     * test.
     *
     * @group legacy
     *
     * @param \PHPUnit\Framework\Test $test
     *   The test object that has ended its test run.
     * @param float $time
     *   The time the test took.
     *
     * @see http://symfony.com/doc/current/components/phpunit_bridge.html#mark-tests-as-legacy
     */
    private function doEndTest($test, $time) {
        // \PHPUnit\Framework\Test does not have any useful methods of its own for
        // our purpose, so we have to distinguish between the different known
        // subclasses.
        if ($test instanceof TestCase) {
            // Change the error handler to ensure deprecation messages are not
            // triggered.
            set_error_handler([
                $this,
                'errorHandler',
            ]);
            $this->checkValidCoversForTest($test);
            restore_error_handler();
        }
        elseif ($this->isTestSuite($test)) {
            foreach ($test->getGroupDetails() as $tests) {
                foreach ($tests as $test) {
                    $this->doEndTest($test, $time);
                }
            }
        }
    }
    
    /**
     * Determine if a test object is a test suite regardless of PHPUnit version.
     *
     * @param \PHPUnit\Framework\Test $test
     *   The test object to test if it is a test suite.
     *
     * @return bool
     *   TRUE if it is a test suite, FALSE if not.
     */
    private function isTestSuite($test) {
        if (class_exists('PHPUnit\\Framework\\TestSuite') && $test instanceof TestSuite) {
            return TRUE;
        }
        return FALSE;
    }
    
    /**
     * Reacts to the end of a test.
     *
     * @param \PHPUnit\Framework\Test $test
     *   The test object that has ended its test run.
     * @param float $time
     *   The time the test took.
     */
    protected function standardsEndTest($test, $time) {
        $this->doEndTest($test, $time);
    }

}

Members

Title Sort descending Modifiers Object type Summary
DrupalStandardsListenerTrait::checkValidCoversForTest private function Check an individual test run for valid @covers annotation.
DrupalStandardsListenerTrait::classExists private function Helper method to check if a string names a valid class or trait.
DrupalStandardsListenerTrait::doEndTest private function Reacts to the end of a test.
DrupalStandardsListenerTrait::errorHandler public static function Handles errors to ensure deprecation messages are not triggered.
DrupalStandardsListenerTrait::fail private function Signals a coding standards failure to the user.
DrupalStandardsListenerTrait::isTestSuite private function Determine if a test object is a test suite regardless of PHPUnit version.
DrupalStandardsListenerTrait::standardsEndTest protected function Reacts to the end of a test.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.