Allow kernel tests to fail or expect logged errors

Created on 20 August 2017, about 7 years ago
Updated 18 January 2024, 9 months ago

Problem/Motivation

Sometimes, a system will log an error and carry on functioning, because that's the best thing to do in normal operation.

But in a test, the logged error is invisible, and we might want a test to either a) fail on an error, because something's gone wrong in the test, or b) expect the error, because we want to actually test the conditions that cause an error.

Examples of this include:

- The cron service catches exceptions thrown by hook_cron() implementations, logs them, and then continues as normal
- The queue runner catches exceptions thrown by queue workers, logs them, and then continues as normal

Proposed resolution

Add a way for tests to react to logged errors. It should be up to individual tests:

- whether to opt in to being aware of logs
- to fail a test if a log of specified type, of specified or greater severity, is generated
- to fail a test if a log of specified type is not generated

1. To keep the scope tight, this issue is focused on kernel tests as exceptions in the SUT in functional tests are significantly more complex to handle.
2. We create a new AssertableLogger class and register this as a logger, intead of registering the test itself as a logger, to reduce the surface area of KernelTestBase.
3. We created a LoggingTrait to use in KernelTestBase to facilitate future use in other kinds of tests, like functional tests or contrib ExistingSite tests.
4. We don't throw an expection immediately when a log is generated, as this might be swallowed by th SUT. Instead we store logs and evaluate them at the end of a test's execution.
5. To keep the scope tight, we do not opt in to expecting no logged errors in KernelTestBase, this will be explored in a follow-up issue.

User interface changes

None.

API changes

None.

Data model changes

None.

Feature request
Status

Needs work

Version

11.0 🔥

Component
PHPUnit 

Last updated 1 day ago

Created by

🇬🇧United Kingdom joachim

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Merge Requests

Comments & Activities

Not all content is available!

It's likely this issue predates Contrib.social: some issue and comment data are missing.

  • 🇬🇧United Kingdom jonathanshaw Stroud, UK

    📌 Add colinodell/psr-test-logger to core's dev dependencies Fixed got committed, so we're unblocked.

  • 🇨🇦Canada Liam Morland Ontario, CA 🇨🇦
  • 🇺🇸United States adamfranco

    For anyone looking for a workaround while waiting for this to be fixed, here's the setup I'm using based on the BufferingLogger and tying it to my logger channel.

    mymodule/tests/Kernel/MyModuleTestBase.php

    namespace Drupal\Tests\mymodule\Kernel;
    
    use Drupal\Core\DependencyInjection\ContainerBuilder;
    use Drupal\Core\Logger\RfcLogLevel;
    use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
    use Symfony\Component\ErrorHandler\BufferingLogger;
    
    /**
     * Base class for setting up MyModule tests.
     *
     * @group mymodule
     */
    abstract class MyModuleTestBase extends EntityKernelTestBase {
    
      /**
       * The entity type manager service.
       *
       * @var \Psr\Log\LoggerInterface
       */
      protected $logger;
    
      /**
       * {@inheritdoc}
       */
      public function register(ContainerBuilder $container) {
        parent::register($container);
        $container
          ->register('logger.channel.mymodule', BufferingLogger::class)
          ->addTag('logger');
      }
    
      /**
       * {@inheritdoc}
       */
      protected function setUp(): void {
        parent::setUp();
    
        $this->logger = $this->container->get('logger.channel.mymodule');
    
        // Clean any existing logs.
        $this->logger->cleanLogs();
      }
      
      /**
       * Verify that no logs were written worse than a certain level.
       *
       * @param string $minLogLevel
       *   The RfcLogLevel name that is the minimum allowed. One of:
       *   EMERGENCY, ALERT, CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG
       * @param string $message
       *   An optional message to print.
       *
       * @return array
       *   The logs in our logger for further examination.
       */
      protected function assertNoLogsWorseThan(string $minLogLevel, string $message = NULL): array {
        $logs = $this->logger->cleanLogs();
        $rfcLogLevels = new \ReflectionClass(RfcLogLevel::class);
        $minLogLevelText = strtoupper(trim($minLogLevel));
        $this->assertContains($minLogLevelText, array_keys($rfcLogLevels->getConstants()), "\$this->assertLogsNoWorseThan(\$minLogLevel): \$minLogLevel=$minLogLevel is not one of ".implode(", ", array_keys($rfcLogLevels->getConstants())));
        $minLogLevelInt = $rfcLogLevels->getConstant($minLogLevelText);
        foreach ($logs as [$level, $message, $context]) {
          $level = trim($level);
          $logLevelText = strtoupper($level);
          $logLevelInt = $rfcLogLevels->getConstant($logLevelText);
          $this->assertGreaterThanOrEqual($minLogLevelInt, $logLevelInt, "Log worse than $minLogLevel recorded: [$level] ".$this->getLogMessage($level, $message, $context));
        }
        return $logs;
      }
    
      /**
       * Assert that a log matching the level and regular expression exists.
       *
       * @param string $level
       *   The RfcLogLevel name that is the minimum allowed. One of:
       *   EMERGENCY, ALERT, CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG
       * @param string $regex
       *   A regular expression to log.
       * @param array $logs
       *   An optional array of logs to examine for multiple checks. If not passed
       *   logs will be cleared from the logger.
       * @param string $message
       *   An optional message to print.
       *
       * @return array
       *   The logs in our logger for further examination.
       */
      protected function assertLogsMatchingRegex(string $level, string $regex, $logs = NULL, string $message = NULL): array {
        if (!is_array($logs)) {
          $logs = $this->logger->cleanLogs();
        }
        $found = FALSE;
        $messages = [];
        foreach ($logs as [$logLevel, $logMessage, $logContext]) {
          $messages[] = $logLevel . "\t" . $logMessage;
          if (strtolower($logLevel) == strtolower($level) && preg_match($regex, strval($logMessage))) {
            $found = TRUE;
            break;
          }
        }
        if ($message) {
          $this->assertTrue($found, $message);
        }
        else {
          $this->assertTrue($found, "Expected a message of level $level matching $regex but didn't find one in:\n\t" . implode("\n\t", $messages));
        }
        return $logs;
      }
      /**
       * Answer message string from a BufferingLogger entry.
       *
       * Copied from BufferingLogger::__destruct().
       *
       * @param string $level
       *   The log level string.
       * @param string $message
       *   The message with placeholders.
       * @param array $context
       *   An array of placeholders to place in the message.
       *
       * @return string
       *  The formatted output.
       */
      public function getLogMessage($level, $message, $context) {
        if (str_contains($message, '{')) {
          foreach ($context as $key => $val) {
            if (null === $val || \is_scalar($val) || (\is_object($val) && \is_callable([$val, '__toString']))) {
              $message = str_replace("{{$key}}", $val, $message);
            } elseif ($val instanceof \DateTimeInterface) {
              $message = str_replace("{{$key}}", $val->format(\DateTimeInterface::RFC3339), $message);
            } elseif (\is_object($val)) {
              $message = str_replace("{{$key}}", '[object '.get_debug_type($val).']', $message);
            } else {
              $message = str_replace("{{$key}}", '['.\gettype($val).']', $message);
            }
          }
        }
        return $message;
      }
    }
    

    Then in my kernel tests I can execute some code and then something like:

        $this->assertNoLogsWorseThan('debug');
    

    Which will result in an error like:

    Log worse than debug recorded: [info] person: Created jdoe@middlebury.edu 00000001. User 1
    Failed asserting that 6 is equal to 7 or is greater than 7.

    If I need to examine logs more closely to look for an expected warning message, I can use something like:

        // Ensure that a warning about our conflict was logged.
        $logs = $this->assertNoLogsWorseThan('warning');
        $this->assertLogsMatchingRegex('warning', '/^Error syncing person\. ID: 00000009/', $logs);
    

    Which will result in an error message like the following:

    Expected a message of level warning matching /^Error syncing person\. PIDM: 00000009/ but didn't find one in:
            info    person: Created jdoe@middlebury.edu 00000001. User 1
            warning Error syncing person. PIDM: 00000002 Error: Found an existing account matching name or mail=jdoe@middlebury.edu [1] that was created during the past year. Skipping auto-rename of old account.
            info    person: Created rjones@middlebury.edu 00000003. User 2
            info    person: Created ldoe@middlebury.edu 00000004. User 3
            info    person: Synced all people. 4 total, 3 changed, 1 failed.
    

    This isn't completely generalized and has the ugly need to pass logs to do multiple checks, but the DX isn't terrible in my actual tests.

  • Merge request !5695Resolve #2903456 "For 10.0" → (Open) created by joachim
  • 🇬🇧United Kingdom joachim

    I've revisited this recently, and I think it's becoming more important in light of the drive to have kernel tests that make HTTP requests. I was sure there was a core issue for this, but I can't find it -- only the more general 🌱 [META] Convert some tests into Kernel or Unit tests Active , and this docs page: https://www.drupal.org/docs/automated-testing/phpunit-in-drupal/making-h...

    If you make an HTTP request in a kernel test, you should obviously be checking the response status is a 200, but if it's not, the problem is obscured, as the HTTP kernel catches exceptions to return the 500 response.

    Therefore, if you're not expecting a 500 error, you want the test to fail when the error is logged, rather than when you check the status of the HTTP response.

    Regarding the open question of when and how to fail tests for log errors, I am starting to think if we need *both* flavours -- fail immediately on log, AND fail if log errors are found in tearDown().

    Failing immediately on a log error can be done by registering the test class itself as a logger -- here's some quick and unsubtle code that does it; obviously it needs refining to allow for expected errors:

        $this->container->get('logger.factory')->addLogger($this);
    
    SNIP
    
      public function log($level, string|\Stringable $message, array $context = []): void {
        $message = strtr($message, $context);
    
        $level_label = \Drupal\Core\Logger\RfcLogLevel::getLevels()[$level];
        $this->fail("Log $level_label: $message");
      }
    
  • 🇬🇧United Kingdom jonathanshaw Stroud, UK

    Regarding the open question of when and how to fail tests for log errors, I am starting to think if we need *both* flavours -- fail immediately on log, AND fail if log errors are found in tearDown().

    I agree that failing on both is an acceptable solution to the concern about immediate failing raised in #67.

    But I remain nervous about immediate failing because it runs the risk of changing the code flow in the SUT. If the log throws an exception, and the exception is caught in the SUT, the code will flow down pathways it may not otherwise have done. So the test could fail with a different exception error from the SUT or an assertion failure that would not have been shown if we had not thrown an exception on the log. This would be unexpected behavior for developers and could be confusing for people trying to debug test failures.

  • 🇬🇧United Kingdom joachim

    I think I see what you mean - simplified, something like this:

        // Some SUT code that is catching exceptions.
        try {
          // Somewhere inside this block we log an error, and that causes the 
          // test class's log handling to fail the test like this:
          $this->fail();
        }
        catch (\Exception $e) {
          // Our SUT code caught PHPUnit's failure exception (AssertionFailedError extends Exception) and can continue! YAY :(
        }
    

    I wonder whether our logger can keep track of the fact that it failed the test on a log error, and then check for that in tearDown. If there's a discrepancy, it can THEN fail the test and say 'log test failures are being caught somewhere'. That would then allow a developer to investigate in the right place.

  • 🇬🇧United Kingdom jonathanshaw Stroud, UK

    I hadn't imagined our logger would call this->fail() instead of just throwing some exception, but you make a good case why even that wouldn't help. So yes, we're on the same page.

    I wonder whether our logger can keep track of the fact that it failed the test on a log error, and then check for that in tearDown.

    That sounds like what I called "failing on both".

    Maybe if the log exception was caught and we didn't catch it until the end of the test we could completely suppress phpunits normal error/assertion failure messaging and replace it with our unexpected log message, so that the developer was never exposed to the potentially misleading messages.

  • 🇬🇧United Kingdom joachim

    I had a go at using tearDown() to handle cases where the logger triggering a test failure gets swallowed by a catch() in the SUT. Here's a sandbox I made: https://github.com/joachim-n/test-2903456-test-logging

    Do composer install and then `vendor/bin/phpunit`. All tests should fail because of the logged error!

    It works, but for the case where the test goes on to fail for another reason, PHPUnit registers two failures.

  • 🇬🇧United Kingdom jonathanshaw Stroud, UK

    tearDown() isn't quite the right place maybe. See #90, especially #90.B that suggests to tinker with getStatusMessage() to append logs to it.

  • 🇺🇸United States GuyPaddock

    I was just trying to write a functional test that asserts that a log message emitted by my SUT is correct and ran smack into this issue. I gave the patch a try and have not had much success. I might be in the minority here, but I found the description and instructions on the trait to be unclear:

    /**
     * Sets test expectations for generated log messages.
     *
     * A test class using this trait should:
     * - ensure that AssertableLogger::log() is called when logs are generated,
     * - provide a getAssertableLogger() method that returns that logger, and
     * - call LoggingTrait::assertLogExpectationsMet(), typically in its
     * assertPostConditions() method.
     *
     * In order to assert that a test does or does not generate logs, the test
     * must call LoggingTrait::expectLog() or
     * LoggingTrait::expectNoLogsAsSevereAs(); it may also call
     * LoggingTrait::allowLogsAsSevereAs().
     */
    

    Here's what is unclear:

    • "ensure that AssertableLogger::log() is called when logs are generated" -- wouldn't the SUT be the one logging errors, not the test? Or is this a method I am supposed to call before or after the code I am testing? I do not understand what this means.
    • "provide a getAssertableLogger() method that returns that logger" -- by that logger, what logger are we talking about?
    • "In order to assert that a test does or does not generate logs, the test must call LoggingTrait::expectLog() or LoggingTrait::expectNoLogsAsSevereAs(); it may also call LoggingTrait::allowLogsAsSevereAs()." -- when must these be called? Before the code being tested? After it?
  • 🇬🇧United Kingdom jonathanshaw Stroud, UK

    @GuyPaddock see the proposed changes to KernelTestBase, and the usage in KernelTestBaseTest.
    Answers:
    1. Yes, the SUT logs. But you have to add this new logger to the SUT somehow to make the logs from the SUT available outside it in the test.
    2. The assertable one added in the SUT
    3. As per the phpunit methods with similar names, like expectException()

  • 🇬🇧United Kingdom alexpott 🇪🇺🌍

    alexpott changed the visibility of the branch 2903456-1 to hidden.

  • 🇬🇧United Kingdom alexpott 🇪🇺🌍

    alexpott changed the visibility of the branch 2903456-for-9.4 to hidden.

Production build 0.71.5 2024