DrupalStandardsListenerTrait.php

Same filename and directory in other branches
  1. 8.9.x core/tests/Drupal/Tests/Listeners/DrupalStandardsListenerTrait.php

Namespace

Drupal\Tests\Listeners

File

core/tests/Drupal/Tests/Listeners/DrupalStandardsListenerTrait.php

View source
<?php

namespace Drupal\Tests\Listeners;

use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Util\ErrorHandler;
use PHPUnit\Util\Test;

/**
 * Listens for PHPUnit tests and fails those with invalid coverage annotations.
 *
 * Enforces various coding standards within test runs.
 *
 * @internal
 */
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);
    }

}

Traits

Title Deprecated Summary
DrupalStandardsListenerTrait Listens for PHPUnit tests and fails those with invalid coverage annotations.

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