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 exception immediately when a log is generated, as this might be swallowed by the site under test. 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.

Issue fork drupal-2903456

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

joachim created an issue. See original summary.

Version: 8.5.x-dev » 8.6.x-dev

Drupal 8.5.0-alpha1 will be released the week of January 17, 2018, which means new developments and disruptive changes should now be targeted against the 8.6.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

larowlan’s picture

Just lost an hour (again) to this

larowlan’s picture

Status: Active » Needs review
StatusFileSize
new2.41 KB

This works well.

Probably a bit extreme to cause a watchdog_exception to fail a test. But maybe not.

Status: Needs review » Needs work

The last submitted patch, 4: 2903456-cron-exceptions.patch, failed testing. View results

larowlan’s picture

Ha some of those are legitimate issues with tests :)

lendude’s picture

Probably a bit extreme to cause a watchdog_exception to fail a test. But maybe not.

Well, I'd say that you would have to be able to differentiate between an expected and an unexpected watchdog_exception, like you can do with php exceptions. I think an unexpected watchdog_exception should fail a test. And having to provide explicit silencing/asserting/handling of expected watchdog_exceptions doesn't sound like a bad thing, right?

larowlan’s picture

Well, I'd say that you would have to be able to differentiate between an expected and an unexpected watchdog_exception, like you can do with php exceptions

Right, so making it a class property allows you to tune that up and down.

I think many of the fails in the test run above are valid issues in tests.

Many of them would just need to have their threshold tuned down, because we're expecting exceptions.

drunken monkey’s picture

I'm all for that! I've had this in some of my tests already, but I do think it makes sense for pretty much all of them. So, as long as this can be controlled or turned off (maybe we should have a special $this->logLevelThreshold === NULL check for that, or a second, boolean property?) easily, this would be great to have!

I assume this would break a lot of contrib modules' test suites when committed like this – but I don't think that's really a problem, is it? We don't have any BC guarantees for test bases, as far as I know. (My modules have their tests broken regularly, so I assume not.)

Finally: Wouldn't this also make equally much sense for functional tests? (Or even more there, as going through the UI will a) hide even more exceptions, and b) give even less helpful fail messages ("Expected status code 200, but was 500.").)

But, in any case: Great approach, thanks for the patch!

megachriz’s picture

+1

I would also like to have something like this for functional tests (actually especially functional tests). I guess though that it would be a little harder to implement as in that case a cron run would happen in a separate PHP process (if I'm right) and as a result then also the exception that is thrown during a cron run. I assume that the logger service set in the test in the way it is done in #4 won't have effect on operations performed by the 'browser' (for example via drupalGet()). Maybe for that reason support for functional tests should happen in a follow-up.

dawehner’s picture

+1 for the general idea. These try/catch statements hide actual problems.

fail: [Other] Line 0 of sites/default/files/simpletest/phpunit-132.xml:
PHPunit Test failed to complete; Error: PHPUnit 6.5.8 by Sebastian Bergmann and contributors.

Testing Drupal\Tests\block\Kernel\Migrate\d7\MigrateBlockTest
F                                                                   1 / 1 (100%)

Time: 3.73 seconds, Memory: 4.00MB

There was 1 failure:

1) Drupal\Tests\block\Kernel\Migrate\d7\MigrateBlockTest::testBlockMigration
Missing filter plugin: filter_null. (/var/www/html/vendor/phpunit/phpunit/src/Framework/Assert.php:2719)
Failed asserting that false is true.

/var/www/html/core/tests/Drupal/KernelTests/AssertLegacyTrait.php:23
/var/www/html/core/modules/migrate/tests/src/Kernel/MigrateTestBase.php:195
/var/www/html/core/modules/migrate/src/MigrateExecutable.php:437
/var/www/html/core/modules/migrate/src/MigrateExecutable.php:247
/var/www/html/core/modules/migrate/tests/src/Kernel/MigrateTestBase.php:169
/var/www/html/core/modules/migrate/tests/src/Kernel/MigrateTestBase.php:183
/var/www/html/core/modules/migrate/tests/src/Kernel/MigrateTestBase.php:184
/var/www/html/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php:50

Given the potential disruption for contrib tests I'd suggest to just add this to the next minor release.

Version: 8.6.x-dev » 8.7.x-dev

Drupal 8.6.0-alpha1 will be released the week of July 16, 2018, which means new developments and disruptive changes should now be targeted against the 8.7.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

Version: 8.7.x-dev » 8.8.x-dev

Drupal 8.7.0-alpha1 will be released the week of March 11, 2019, which means new developments and disruptive changes should now be targeted against the 8.8.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

jonathanshaw’s picture

1.

So, as long as this can be controlled or turned off (maybe we should have a special $this->logLevelThreshold === NULL check for that, or a second, boolean property?) easily, this would be great to have!

As I understand it, in the patch as it stands these will all work to suppress errors:

protected $logLevelThreshold = RfcLogLevel::EMERGENCY;
protected $logLevelThreshold = FALSE;
protected $logLevelThreshold = NULL;

because even an RfcLogLevel::EMERGENCY (===0) is not less than the above.

2. I do wonder if it would be better DX though for protected $logLevelThreshold = RfcLogLevel::ERROR; to mean "fail on logs of level ERROR and above", rather than "fail on logs of levels above ERROR".

3. The name may be worth bikeshedding. I'd expect logLevelThreshold to be about the logging of something, not failing on response to logs. Maybe $failFromLogLevel.

4. We've got a boatload of core test failures across many test classes. Maybe the best solution is to
a) commit this with the test failure threshold set to NOT cause any failures by default, so it's opt-in at first
b) file follow-ups for the test failures
c) file a follow-up to turn it on by default when the fails have been addressed. This could be for D9 because of the contrib disruption.

Might be good to ask for a release manager opinion on this last.

Version: 8.8.x-dev » 8.9.x-dev

Drupal 8.8.0-alpha1 will be released the week of October 14th, 2019, which means new developments and disruptive changes should now be targeted against the 8.9.x-dev branch. (Any changes to 8.9.x will also be committed to 9.0.x in preparation for Drupal 9’s release, but some changes like significant feature additions will be deferred to 9.1.x.). For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

Version: 8.9.x-dev » 9.1.x-dev

Drupal 8.9.0-beta1 was released on March 20, 2020. 8.9.x is the final, long-term support (LTS) minor release of Drupal 8, which means new developments and disruptive changes should now be targeted against the 9.1.x-dev branch. For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

jonathanshaw’s picture

Issue tags: +Needs tests

We're discussing something similar in DrupalTestTraits, and we have tests for the functionality that could provide inspiration here.

jonathanshaw’s picture

ravi.shankar’s picture

StatusFileSize
new2.36 KB

Here I have rerolled patch #4 on D-9.1.x.

Still needs work for tests.

joachim’s picture

I like this fix, as it covers lots more things other than cron and cron queues!

But the issue title and summary need updating to reflect what the new approach is doing.

joachim’s picture

Here's a backport of patch #19 for 8.9.

Just used it on a kernel test and it failed very nicely when a service logged an error which otherwise would have been a mysterious and silent problem!

A few things:

- setUpBeforeClass() is now missing its docblock
- could we make this work for Functional tests too?

jonathanshaw’s picture

We just committed a version of this on https://gitlab.com/weitzman/drupal-test-traits/-/merge_requests/94 that I think is slightly improved. It takes onboard @lendude's point in #7 about explicit expectation of exceptions, and also has test coverage.

It can be basically copied-pasted back into a patch here. Any thoughts about this @joachim?

joachim’s picture

> It takes onboard @lendude's point in #7 about explicit expectation of exceptions

Funnily enough, I came here just now to comment exactly about this!

Having written one test for my project's custom code where I don't want an error to be logged, I now want to test the circumstance where an error SHOULD be logged. And obviously, I don't want this to fail the test! I want a test that PASSES when an error *IS* logged.

So rather than this:

+++ b/core/tests/Drupal/KernelTests/KernelTestBase.php
@@ -1097,4 +1109,13 @@ public function __sleep() {
+    if ($level < $this->logLevelThreshold) {
+      $this->fail(strtr($message, $context));
+    }

what's needed is something like what's in @weitzman's patch over on Drupalforks:

    public function log($level, $message, array $context = [])
    {
        if ($level <= $this->failOnLogsLevel) {
            throw new LoggedMessageException(strtr($message, $context), $level);
        }
    }

That way, the error fails the test because it throws an exception, BUT you can tell PHPUnit to expect the exception, so the test can be designed to pass when there's a logging error.

joachim’s picture

More work on #19, based on code from https://gitlab.com/weitzman/drupal-test-traits/-/merge_requests/94 as suggested in #22.

I've changed it so that instead of failing the test, a log message throws an exception.

Tests that cause a logged error will either fail because of the exception, or can be written to catch the exception so that they can test error conditions.

jonathanshaw’s picture

  1. +++ b/core/tests/Drupal/KernelTests/KernelTestBase.php
    @@ -216,6 +221,16 @@ abstract class KernelTestBase extends TestCase implements ServiceProviderInterfa
    +   * Log level threshold.
    +   *
    +   * Log messages below this will trigger a failing test. Defaults to 'Warning',
    +   * meaning emergency, alert, critical and error will cause the test to fail.
    +   *
    +   * @var int
    +   */
    +  protected $logLevelThreshold = RfcLogLevel::WARNING;
    

    I think it's more intuitive if message of this level OR below trigger exceptions. What do you reckon?

  2. +++ b/core/tests/Drupal/KernelTests/KernelTestBase.php
    @@ -239,6 +254,7 @@ protected function setUp() {
    +    $this->container->get('logger.factory')->addLogger($this);
    

    This is not opt in. So it will break half of contrib overnight. From a release management perspective, it'd seem necessary to make this opt-in at first, then have another issue to enable it by default, which would be blocked by all the child issue to cleanup the fails this will expose.

joachim’s picture

Here's a backport of #24 for 8.9.x.

> I think it's more intuitive if message of this level OR below trigger exceptions. What do you reckon?

Yeah, I think that makes sense. Also, there is precedent now for a less-than-or-equals on the level in drupal-test-traits, and being inconsistent with that would be a pain:

    public function log($level, $message, array $context = [])
    {
        if ($level <= $this->failOnLogsLevel) {

> This is not opt in. So it will break half of contrib overnight

It's break contrib *tests*, which is not the same thing as breaking contrib. What's the policy on BC for tests?

jonathanshaw’s picture

break contrib *tests*, which is not the same thing as breaking contrib. What's the policy on BC for tests?

I've come across #2629286: Use getDisplayName() for user names consistently 188-193 where @alexpott is reluctant to break contrib tests en masse

joachim’s picture

Title: re-throw exceptions caught for invoking hook_cron() and running cron queue workers during tests » Allow tests to fail or expect logged errors
Issue summary: View changes

Updated the IS with current plan for this issue.

joachim’s picture

I've maybe found a problem with our approach...

When there's an error, we throw an exception immediately:

  public function log($level, $message, array $context = []) {
    // Throw an exception if there is a log message below the set severity
    // threshold. This will cause a test to fail if an error logged, for
    // example. To test that something is correctly logged, use
    // expectException().
    if ($level < $this->logLevelThreshold) {
      throw new LoggedMessageException(strtr($message, $context), $level);
    }
  }

That means that the logged error stops the SUT.

In my custom code that I was testing, I had something like this pseudocode:

function get_thing(): ?Thing {
  $thing = do_something();

  if (empty($thing)) {
   \Drupal::logger('my_log')->error("ERROR!");
  }

  return $thing;
}

With this patch, I can test that when get_thing() fails to get a Thing, it logs an error. But the test isn't covering the behaviour after that failure. (In my case, I forgot the '?' before the return type declaration... DUH.)

So I'm wondering whether we should change the handling in log() to keep a record of seen errors instead of throwing the exception immediately, and then examine the log in tearDown(). But then I'm not sure if we can still use PHPUnit's built-in expectException/expectExceptionMessage/expectExceptionCode -- because my cursory look inside PHPUnit suggests that tearDown() is run after an expected exception is checked for. So we'd need to define something like an expectLogWillContain() method.... and it's all starting to get rather complicated!

Am I overthinking this? Should it simply be the case that to test both an error is logged AND how the SUT behaves after the error, you need to write two test cases, one to catch the logged error and one to ignore the logged error and check what happens after?

Also:

> From a release management perspective, it'd seem necessary to make this opt-in at first

Release management aside, this definitely makes it clear to me that this needs to be opt-in, and opt-in per test *method*, not test class.

mondrake’s picture

Component: cron system » phpunit

Looks like this is an issue for the PHPUnit component queue, at this stage

jonathanshaw’s picture

the logged error stops the SUT

Uggh. You're totally right. The current approach is good - better than simply ignoring logged errors silently - but not great.

I'm wondering whether we should change the handling in log() to keep a record of seen errors instead of throwing the exception immediately, and then examine the log in tearDown().

As you suspected, tearDown() is not the right place for this.

What we need to do is:
1. We need our own expectLog() methods that parallel PhpUnit's expectException() etc.
2. When a log is thrown, we immediately thrown an expection if is more severe than the acceptable level AND it is not expected.
3. When a log is thrown, we record it for checking at the end.
4. We override runTest() and append our own log handling, so that when a test ends we can check that we got all the logs we expected to get.

opt-in per test *method*, not test class.

That's why the DTT implemetation has methods to vary acceptable severity level on the fly during a test.

joachim’s picture

@jonathanshaw that sounds good!

> 1. We need our own expectLog() methods that parallel PhpUnit's expectException() etc.

Given that our logger is PSR-compliant, I'm wondering whether this is something that could be implemented in PHPUnit. I've posted a feature request over there: https://github.com/sebastianbergmann/phpunit/issues/4437

That shouldn't block this issue though, as we are so many major versions behind on PHPUnit!

joachim’s picture

The feature request I filed on PHPUnit has been rejected.

I've since had a brainwave:

We want a class that implements LoggerInterface to do one of the following, depending on test configuration:

- pass the test if it receives an expected log event, and fail if it doesn't
- fail the test if it receives any log event
- fail the test if it receives a log event above a certain threshold

That suddenly seems to me to be what Prophecy is there for!

What do we need in Kernel tests to make it easy to register a mocked logger?

What do we need in Functional tests for a logger to feed back to the test?

jonathanshaw’s picture

Hmm. That seems like a good idea. Using phpunit mocks it would look something like this:

trait MockLoggerTrait {

  protected $failOnLogsLevel = RfcLogLevel::ERROR;

  protected function createMockLogger($failOnLogsLevel = NULL) {
   $logger = $this->createMock(LoggerInterface::class);

    if (is_null($failOnLogsLevel)) {
      $failOnLogsLevel = $this->failOnLogsLevel;
    }

    $levels = array_keys(RfcLogLevel::getLevels());
    sort($levels);

    foreach ($levels as $level) {
      if ($level >= $failOnLogsLevel) {
        $logger->expects($this->never())
           ->method('log')
           ->with($level);
      }
    }
    
     return $logger;
  }

  protected function addMockLogger() {
    if (is_null($this->mockLogger)) {
      $this->mockLogger = $this->createMockLogger();
    }
    $this->container->get('logger.factory')->addLogger($this->mockLogger);
  }

}

class KernelTestBase extends whatever {

  use MockLoggerTrait;

  protected function setUp() {
    $this->addMockLogger();
    ...
  }

}

class MyTest extends KernelTestBase {

  // WORKS: Make this test fail if any warning is logged.
  protected $failOnLogsLevel = RfcLogLevel::WARNING;  

  protected function myTest() {
   // WORKS: make this test fail if a particular notice is not logged.
    $this->mockLogger->expects($this->once())
      ->method('log')
      ->with(RfcLogLevel::NOTICE, 'my_module');
    ...
    // DOES NOT WORK: allow a single error to happen after a particular point in the test
    $this->mockLogger->expects($this->once())
      ->method('log')
      ->with(RfcLogLevel::ERROR, 'my_module');
   ...
  }

}

We hit the problem that once a phpunit mock has been told that by default something should never happen, we cannot override that in a more specific place.

I've never used prophecy but I think it might be more flexible about this. It's docs say:

Every argument token type has a different score level, which wildcard then uses to calculate the final arguments match score and use the method prophecy promise that has the highest score.... The simple rule of thumb - more precise token always wins.

So it might be possible to make a more specific error message be allowed despite having disallowed errors in general. Anyone familiar enough with prophecy to suggest the correct syntax?

joachim’s picture

I would definitely use Prophecy over PHPUnit mocks, as for one thing the syntax is clearer to read.

Furthermore, Prophecy allows callbacks to verify method arguments and promises:

> CallbackToken or Argument::that(callback) - checks that the argument matches a custom callback

> CallbackPromise or ->will($callback) - gives you a quick way to define your own custom logic

So inside the CallbackToken we can do what we want to check the log levels.

I've done a fair bit with Prophey, so I'll try to have a play with this later.

I'm vaguely thinking that for Kernel tests, we might end up providing some helper methods to make it easier for developers to set up the logging expectations.

For Functional tests I'm not sure yet. I've definitely thought about the problem of mocking a service in a Functional test, but I don't remember if I came up with a plan for doing that. I have parenting brain at the moment!

joachim’s picture

Issue summary: View changes
jonathanshaw’s picture

I've done a fair bit with Prophecy, so I'll try to have a play with this later.

Great. I'm happy to keep reviewing and staying with it all the way to RTBC.

I'm vaguely thinking that for Kernel tests, we might end up providing some helper methods to make it easier for developers to set up the logging expectations.

That would be good.

For Functional tests I'm not sure yet. I've definitely thought about the problem of mocking a service in a Functional test, but I don't remember if I came up with a plan for doing that.

I've been trying to wrap my head around https://gitlab.com/weitzman/drupal-test-traits/-/merge_requests/96

What's it's taught me is that since #2664150: Expand BrowserTestBase with error handling support we attempt to transport exceptions from the SUT into PhpUnit, by using Guzzle middleware https://github.com/drupal/drupal/blob/9.0.x/core/lib/Drupal/Core/Test/HttpClientMiddleware/TestHttpClientMiddleware.php that intercepts X-Drupal-Assertion headers added by the SUT.

Searching the codebase for those headers leads me to core/includes/errors.inc which includes

function _drupal_error_handler_real($error_level, $message, $filename, $line, $context) {
  if ($error_level & error_reporting()) {
    ...
    _drupal_log_error(...)
    ...
}

function _drupal_log_error($error, $fatal = FALSE) {
 ...
  // When running inside the testing framework, we relay the errors
  // to the tested site by the way of HTTP headers.
  if (DRUPAL_TEST_IN_CHILD_SITE && !headers_sent() && (!defined('SIMPLETEST_COLLECT_ERRORS') || SIMPLETEST_COLLECT_ERRORS)) {
    _drupal_error_header($error['@message'], $error['%type'], $error['%function'], $error['%file'], $error['%line']);
  }

This appears to get invoked every time anything, even a notice is logged.

So it seems we should have access to all SUT logs in browser test base. What will be different about this is that we won't be able to fail immediately when something is logged, we might have to wait for PhpUnit to process the complete response from the SUT.

We might therefore need 2 traits, one for Kernel tests and a slightly different one for browser tests. Traits are good, they will help us reuse this in Drupal Test Traits which doesn't directly extend BrowserTestBase, and the logic will likely grow to be non trivial by the time we're done with the helper methods.

longwave’s picture

This seems closely related to #652394: Aggressive watchdog message assertion, unsure if it's an exact duplicate or they just complement each other.

jonathanshaw’s picture

Good spot. I closed #652394: Aggressive watchdog message assertion as a duplicate of this. While it is older, it is so outdated that it's better to work here.

joachim’s picture

My thinking for Functional tests was more:

- test module
- which provider a logger
- the test setup puts some data about the expected log level into site state (because that persists in the DB, so the SUT has it too).
- the logger in the test module reads the state, and behaves accordingly. In particular, it puts things back in the state about what it's seen.
- the test tearDown() reads the state report from the logger and can fail the test

joachim’s picture

For Kernel tests, here's a quick proof of concept with Prophecy:

    $mininum_allowed_log_level = \Drupal\Core\Logger\RfcLogLevel::NOTICE;

    $mock_logger = $this->prophesize(\Psr\Log\LoggerInterface::class);
    $mock_logger->log(
      \Prophecy\Argument::that(function($level) use ($mininum_allowed_log_level) {
        return $level >= $mininum_allowed_log_level;
      }),
      \Prophecy\Argument::any(),
      \Prophecy\Argument::any()
    )->shouldBeCalled();
    $this->container->get('logger.factory')->addLogger($mock_logger->reveal());

I put that in KernelTestBase::setUp().

The failure message isn't good at all though:


There was 1 error:

1) Drupal\KernelTests\Core\Queue\QueueTest::testQueueFailureLog
Prophecy\Exception\Call\UnexpectedCallException: Unexpected method call on Double\LoggerInterface\P1:
  - log(
        4,
        "foo",
        ["channel" => "pants", "link" => "", "uid" => 0, "request_uri" => "http://localhost/", "referer" => "", "ip" => "127.0.0.1", "timestamp" => 1599239806]
    )
expected calls were:
  - log(
        callback(),
        *,
        *
    )

That's not good DX at all -- I can't see what went wrong!

So I think we need to implement a custom Prophecy\TokenInterface.

longwave’s picture

Status: Needs work » Needs review
StatusFileSize
new2.87 KB

Going back to #30/#32, I think we can do this with a custom logger for tests, a trait (so it is opt-in per class) and assertPostConditions() which conveniently runs after the test but before tearDown().

Proof of concept attached with two test cases, expecting a message and expecting no message. We can add other expectations around log levels, matching messages with regex, etc.

If we want to explicitly say "expect no log messages" we could add a separate method for that instead of expecting it by default.

I also think the functional test version will be quite different so wonder if it's worth spinning off a new issue for that?

jonathanshaw’s picture

Status: Needs review » Needs work

(Sorry, cross-posted with @longwave, didn't mean to hide file and not responding to him)

I had a moment of doubtabout the mock logger approach. Before we go too far down it we need to make sure that it will allow us to adjust expectations party way through as test as nimbly as we might want to.

For example:
public function myTest() {

$this->expectNoLogs(Error);
// Some code here. Warnings OK, errors not.

$this->expectNoLogs(Warning);
//Some code here. Warnings now not OK.

$this->expectNoLogs(Error);
//Some code here. Warnings now OK.

//$this->expectLog(Error, my_module)
// Should still be failing on warnings, but this specific error is allowed.

}

The hardest thing is to de-escalate negative expectations, the transition from disallowing warnings to no longer disallowing warnings.

I think we're OK, as long as our mock logger callback can have a reference to a property of the test class that tracks the currently allowed log level.

jonathanshaw’s picture

@longwave thanks for introducing me to both assertPostConditions() and expectNotToPerformAssertions(), nice.

I'm a bit uncomfortable with overriding assertPostConditions() in a trait, that could have consequences that people using the trait don't expect. e.g. if they also define assertPostConditions() in the test class. But we could have an assertLogExpectationsMet() in the trait, and then call that from assertPostConditions() in the base class(es) when we add the trait to the base classes.

I think we can move forward with defining the trait and the API we're trying to add here, even before resolving this question of how exactly we trigger failure.

jonathanshaw’s picture

How's this for our API:

function expectLog($severity = 'UNSPECIFIED', $channel = 'UNSPECIFIED', $message = 'UNSPECIFIED') {
  $this->expectLogs($severity, $channel, $message, 1);
}

function expectLogs($severity = 'UNSPECIFIED', $channel = 'UNSPECIFIED', $message = 'UNSPECIFIED', $count = NULL) {
  $this->expectedLogs[$severity][$channel][$message] = $count;
}

function expectNoLogs($severity = 'UNSPECIFIED', $channel = 'UNSPECIFIED', $message = 'UNSPECIFIED') {
  $this->unwantedLogs[$channel][$message] = $severity;
}

// Call when a log is received.
function handleLog($severity, $channel, $message) {
  if (isset($this->expectedLogs[$severity][$channel][$message])) {
     if (!is_null($this->expectedLogs[$severity][$channel][$message])) {
        $this->expectedLogs[$severity][$channel][$message] = $this->expectedLogs[$severity][$channel][$message] - 1;
     }
     if ($this->expectedLogs[$severity][$channel][$message] === 0) {
        unset($this->expectedLogs[$severity][$channel][$message]);
     }
  }
  else {
    $this->assertNoUnwantedLogsMatching($severity, $channel, $message);
  }
}

function assertNoUnwantedLogsMatching($severity, $channel, $message) {
  $unwantedMessages = $this->unwantedLogs[$channel];
  foreach($unwantedMessages as $unwantedMessage => $unwantedSeverity) {
    if ($unwantedMessage == $message && $unwantedSeverity >= $severity) {
      $this->fail("Log message received: $severity $channel $message");
    }
  }
}

// Call at end of test.
function assertLogExpectationsMet() {
  $this->assertEmpty($this->expectedLogs);
}
jonathanshaw’s picture

I got @drumm to enable issue forks for this issue, so we have that to play with.

Version: 9.1.x-dev » 9.2.x-dev

Drupal 9.1.0-alpha1 will be released the week of October 19, 2020, which means new developments and disruptive changes should now be targeted for the 9.2.x-dev branch. For more information see the Drupal 9 minor version schedule and the Allowed changes during the Drupal 9 release cycle.

joachim’s picture

I've got something working for functional tests!

I can't see where to make a new fork for this issue to work on it though :/

I reckon we should keep the work on both Kernel and Functional tests in the same issue, as they should have the same API so developers use the same methods for setting up expectations in both types of test.

Here's what I've got though:

1. in a test module called state_log, I have this logger service:

services:
  state_log.state_logger:
    class: Drupal\state_log\Service\StateLogger
    arguments:
      - '@state'
    tags:
      - { name: logger, priority: '0' }
<?php

namespace Drupal\state_log\Service;

use Drupal\Core\State\StateInterface;
use Drupal\Core\Logger\RfcLoggerTrait;
use Psr\Log\LoggerInterface;

/**
 * Stores logging messages in the site state, so tests can retrieve them.
 */
class StateLogger implements LoggerInterface {

  /**
   * The state storage service.
   *
   * @var \Drupal\Core\State\StateInterface
   */
  protected $state;

  /**
   * Creates a StateLogger instance.
   *
   * @param \Drupal\Core\State\StateInterface $state
   *   The state storage service.
   */
  public function __construct(
    StateInterface $state
  ) {
    $this->state = $state;
  }

  /**
   * {@inheritdoc}
   */
  public function log($level, $message, array $context = array()) {
    $log = $this->state->get('state_log', []);

    $log[$level][] = $message;

    $this->state->set('state_log', $log);
  }

}

In my test class, just as a proof of concept:

  protected function assertPostConditions() {
    $log = $this->container->get('state')->get('functional_test_log', []);
    // dump($log);

    if (!empty($log[3])) {
      $this->fail('log!');
    }
  }

It works! I can see the whole of the log being dump()ed, and if a page load in the test logs an error, then the test fails!

morganlyndel’s picture

The issue summary appears to be up-to-date, removed the "Needs issue summary update" tag.

jonathanshaw’s picture

I can't see where to make a new fork for this issue to work on it though :/

Use the existing fork and create a new branch and MR.

Version: 9.2.x-dev » 9.3.x-dev

Drupal 9.2.0-alpha1 will be released the week of May 3, 2021, which means new developments and disruptive changes should now be targeted for the 9.3.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.3.x-dev » 9.4.x-dev

Drupal 9.3.0-rc1 was released on November 26, 2021, which means new developments and disruptive changes should now be targeted for the 9.4.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

joachim’s picture

Title: Allow tests to fail or expect logged errors » Allow kernel tests to fail or expect logged errors

> I reckon we should keep the work on both Kernel and Functional tests in the same issue, as they should have the same API so developers use the same methods for setting up expectations in both types of test.

I think I was wrong about that! This issue has been dormant for over a year, let's keep it simple.

I'm renaming this to be about kernel tests, and I'll file a new issue for functional tests.

I've rebased this on 9.4.x, but it needs more work.

In particular, the trait needs docs to explain how to use it, and I don't remember how it's meant to work.

@jonathanshaw can you shed light on this?

> // Call when a log is received.
> function handleLog($severity, $channel, $message) {

Who calls this? Patch #43 has a test logger class, but that's not in the MR.

The docs also need to say that tests using this should call assertLogExpectationsMet() and where.

joachim’s picture

Assigned: Unassigned » joachim

I'm doing some work on this.

jonathanshaw’s picture

Sorry, I collided with you on this.

I have improved the docs. More importantly, I have added the trait to KernelTestBase.

The implementation as it stands is completely opt-in and should not break any core or contrib tests (unless they have a method name collision with the trait).

In follow-up issues we can:
- throw a deprecation error in KernelTestBase if tests generate logged errors without explicitly expecting them
- add expectations to any core tests that generate logged errors
- one day remove the deprecation error and simply add expectNoLog(RfcLogLebel::ERROR) ins KernelTestBase::setUp() so that that tests extending from KernelTestBase will fail if they generate logged errors that they have not more specifically expected.

joachim’s picture

I don't know what gitlab is playing at with my comments, so:

- The technique of unsetting from the array of expected logs doesn't allow a log message to occur more than once.

> protected function expectNoLog($level = '', $channel = '', $message = '') {

In the code I was working on today, I think I called this expectNoLogMoreSevereThan() for clarity.
I also split this into two methods:

expectNoLogMoreSevereThan()
expectNoLogOnChannelMoreSevereThan()

I think it's easier to read calls that way, and the docs are simpler to write too.

Also, I don't think it makes sense for the level param to be optional.

joachim’s picture

Some higher-level questions:

- If we're putting this into KernelTestBase immediately -- and I'm fine with this happening :) -- then why do we need a trait for this? All kernel tests inherit from KernelTestBase. Unit tests can't use this. Functional tests will need to access the log in a totally different way.

- What are the pros and cons of using the test class as the logger vs. using a separate logger class?

- Should we be concerned about running out of memory by storing all the log messages in a class property? One thing that occurs to me is that we could have the logger throw away any log message that we know is not interesting -- e.g. if all the thresholds are WARNING, and there's no expectation that's any less severe than that, we can discard anything less severe than WARNING.

- I think threshold expectations should be checked at the end, rather than fail the test as soon as a single bad log is received. I know this is following the pattern of expectException(), but with exceptions you have no choice but to stop when one is thrown. With logs, we can keep going. This is better DX, because it means a developer can fix ALL the bad log problems before running the tests again. It's also better for d.org's infrastructure, because it means all the bad log problems are found in one CI run, rather than repeated CI runs to fix each log problem one by one. Obviously, for that to work, the test output needs to list all bad logs.

joachim’s picture

Here's my code for asserting the global expectation:

  protected function assertLogExpectations(): void {
    $messages = $this->testLogger->getMessages();
    dump($messages);

    // Check the threshold for all channels if it is set.
    if (isset($this->globalLogThreshold)) {
      // Filter all messages to those that fail the threshold.
      $threshold = $this->globalLogThreshold;
      $failing_messages = array_map(function ($channel_messages) use ($threshold) {
        return array_filter($channel_messages, function ($key) use ($threshold) {
          return $key < $threshold;
        }, \ARRAY_FILTER_USE_KEY);
      }, $messages);
      $failing_messages = array_filter($failing_messages);

      $this->assertEmpty($failing_messages, sprintf("Failed expectation that no log channel has messages more severe than %s, failing log messages are:\n%s.",
        (string) RfcLogLevel::getLevels()[$threshold],
        $this->implodeMultipleChannelsMessages($failing_messages)
      ));

   // per-channel TODO
}


  protected function implodeMultipleChannelsMessages($messages) {
    // $string_by_channel = array_map([$this, 'implodeMessages'], $failing_messages))
    $string = '';
    foreach ($messages as $channel => $channel_messages) {
      $string .= "Channel $channel:\n";
      $string .= $this->implodeMessages($channel_messages) . "\n";
    }
    return $string;
  }

  // make a string for a single channel array of messages.
  protected function implodeMessages(array $channel_messages) {
    $messages_string_by_level = [];
    foreach ($channel_messages as $level => $messages) {
      $messages_string_by_level[] = (string) RfcLogLevel::getLevels()[$level] . "\n" . implode("\n", $messages);
    }
    return implode("\n", $messages_string_by_level);
  }

jonathanshaw’s picture

why do we need a trait for this

A lot (most) of this trait is probably still relevant to functional tests. And it could be used in ExistingSite tests, which don't extend KernelTestBase. Probably most importantly, doesn't it make things easier to understand if the log is organised into a trait rahter than making the base class even longer?

I think threshold expectations should be checked at the end, rather than fail the test as soon as a single bad log is received. I know this is following the pattern of expectException(), but with exceptions you have no choice but to stop when one is thrown. With logs, we can keep going. This is better DX, because it means a developer can fix ALL the bad log problems before running the tests again.

If we fail immediately if a unexpected error is logged, then the stacktrace pinpoints the cause. I think this probably outweights the sensible reasons for deferring you give.

What are the pros and cons of using the test class as the logger vs. using a separate logger class

If it's good to fail immediately if a unexpected error is logged, this requires the logger to be able to have access to the allowed and expected logs defined in the test.

I suppose we could pass these off to the logger from the test, instead of storing them on the test.

I do kind of like the idea of having as much as possible outside the test class, shall we give this a try?

Should we be concerned about running out of memory by storing all the log messages in a class property?

I can't see how, assuming things are properly cleaned pu between tests. But anyway, we're not storing the log messages, the current implementation only stores the rules about the expected/allowed/disallowed logs, not the actual logs received.

joachim’s picture

> A lot (most) of this trait is probably still relevant to functional tests. And it could be used in ExistingSite tests, which don't extend KernelTestBase

Fair enough.

> If it's good to fail immediately if a unexpected error is logged, this requires the logger to be able to have access to the allowed and expected logs defined in the test.

Good point. I've been mostly working with log messages from cron, where knowing it's from cron isn't that useful.

But it also means we can pick up an exception backtrace that's in the log context and output that.

> I suppose we could pass these off to the logger from the test, instead of storing them on the test.

In light of the above, I think this maybe isn't worth the extra complexity.

jonathanshaw’s picture

I've realised that expectNoLogMoreSevereThan() is the wrong name to use.

This is because the current implementation is designed so that ::expectNoLogMoreSevereThan(RfcLogLevel::WARNING) will fail on warnings and errors, not just errors.

We could change the implementation but I think it's more natural and direct to pass as parameter the severity one does not want to have logged, rather than passing the level immediately below it.

::expectNoLogOfSeverityGreaterThanOrEqualTo() seems just silly. We could have ::expectNoLogOfSeverity() but I don't see how that is really better than ::expectNoLog().

I suggest we revert back to ::expectNoLog().

joachim’s picture

Fair point.

I'll have a think about how to call the method, because I think expectNoLog is misleading.

jonathanshaw’s picture

I've realised that expectNoLogMoreSevereThan() is the wrong name to use.

I think expectNoLog is misleading.

I've implemented expectNoLogsAsSevereAs() which I think might satisfy your concern but is tolerably concise.

I've had to completely rework the implementation to check for generated but disallowed logs at the end of the test, not immediately when they're thrown. This is the approach we previously rejected in #61 / 63. The reason I've done it is that I've realised that it's not OK for the test to throw ExpectationFailedExceptions when acting as a logger and processing logs during test execution. This is because log handling is part of the System-Under-Test, and we can't predict how the SUT will handle these exceptions our test logger is generating. It may well catch and suppress them, so they never bubble up into the original test execution context.

This does leave us with the problem I raised in #63, that we would not have the stacktrace available to indicate which code in the test triggered the log. However, there are workarounds for this. We could capture the stacktrace when the logs are generated, and store it with the record of logs generated during the test. I haven't implemented this yet, but it will be easy to do if people are happy with the rest of the approach here.

joachim’s picture

> I've implemented expectNoLogsAsSevereAs() which I think might satisfy your concern but is tolerably concise.

Yup, that seems fine.

> The reason I've done it is that I've realised that it's not OK for the test to throw ExpectationFailedExceptions when acting as a logger and processing logs during test execution. This is because log handling is part of the System-Under-Test, and we can't predict how the SUT will handle these exceptions our test logger is generating. It may well catch and suppress them, so they never bubble up into the original test execution context

Ohhhh yeah :/

jonathanshaw’s picture

Status: Needs work » Needs review

The fail is random, this is now complete and green.

joachim’s picture

Status: Needs review » Needs work

Reviewed the MR.

jonathanshaw’s picture

Status: Needs work » Needs review

The fail is random

joachim’s picture

Status: Needs review » Reviewed & tested by the community

Looks good!

jonathanshaw’s picture

Issue summary: View changes
jonathanshaw’s picture

jonathanshaw’s picture

Issue tags: +Needs reroll

@alexpott asked for a reroll onto 10.0, but that's beyond my gitfu.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs change record

This looks super useful. Nice one. I've added a load of comments to the MR. All quite minor things to fix. I think we should add a CR to inform developers about this and the fact they can use this in Kernel tests.

jonathanshaw’s picture

jonathanshaw’s picture

The MR 2903456-for-9.4 should be good as is.

The MR 2903456-1 needs someone to reroll it onto 10.x

joachim’s picture

Version: 9.4.x-dev » 10.0.x-dev

As discussed in slack, I've rebased branch 2903456-1 against 10.0.x, and there's now a 2nd branch 2903456-for-9.4.

jonathanshaw’s picture

jonathanshaw’s picture

Status: Needs work » Needs review

Added primitive type hints. The changes since #72 are basically docs, typehints and coding stamdards, so this is RTBC once the bot is happy.

jonathanshaw’s picture

Issue tags: +Needs reroll

MR!14 for 10.0 is unhappy has become unhappy and is claiming it's not mergeable, but nothing has changed to make that happen. Maybe it needs a reroll.

joachim’s picture

Issue tags: -Needs reroll

I've rebased the branch for the 10.0 MR.

jonathanshaw’s picture

The test fail is random.

joachim’s picture

Status: Needs review » Reviewed & tested by the community

Back to RTBC.

joachim’s picture

I've just added the patch for this to my current project and tried running kernel tests with a expectNoLogsAsSevereAs() and it's working great!

joachim’s picture

I've been using this patch for the kernel tests on my project, and today I found something that didn't quite give me the DX I was hoping from this :/

I had a test which did this:

    $this->cron->run();
    $this->assertResultOfCron()

A change elsewhere in the codebase meant that a cron hook was throwing an exception which was getting swallowed up by cron.

Because of this exception, some of the work the cron hook did wasn't happening, and so assertResultOfCron() was failing.

However, because the test was failing on assertResultOfCron(), the logged exception wasn't failing the test, so the test failure was hard to debug.

If the test failure had been the logged exception, I've had seen exactly where it went wrong.

Is this something we can address? Or is it just a case of learning how to work with this new system -- if I'd temporarily changed my code to:

    $this->cron->run();
    return;
    $this->assertResultOfCron()

then I *would* have seen the test fail on the logged exception.

@jonathanshaw any thoughts?

jonathanshaw’s picture

Hmmm. I think this matters

Approach A

#89 revisits the debate from #30 / 32 about whether our logger should throw exceptions so test execution can be halted immediately. We decided not to do this, because the SUT can catch exceptions and so throwing them in the logger is not a reliable way to cause a test to fail.

However we could combine this approach with approach of inspecting the logs in assertPostConditions(). We could store a record of the log, throw an exception if it was unexpected, and then inspect the logs in assertPostconditions() and trigger failure if appropriate in case the thrown exception was caught and didn't stop the test earlier.

One concern I have here is sequencing. If a test generates 2 logs errors, and the exception from the first is caught but that from the second is not, then test execution would stop with the second log and assertPostConditions() would be skipped and the first log (more likely to be the root cause) would be unreported. We might be able to make this sort of OK with better reporting, but it's not trivial.

The other concern I have is that SUT code that catches the exception thrown by our logger could itself then try to log that exception. Which would lead to extra logs being reported in a slightly messy way. More generally, this approach is more invasive of the SUT and hence liable to odd side-effects like this.

This approach would also not help with cron either, because that is an example of a SUT that catches exceptions.

Balance against all of that, in the 95% use case this would give more timely and useful reporting of the root cause of a test failing.

Approach B

If we could find a way to report the disallowed logs whenever a test ended early due to assertion failure, that would seem like a robust solution.

We could do this:

protected function runTest() {
  try {
    parent::runTest();
  }
  catch (AssertFailedException($e)) {
    // Add some output here, maybe by adding to the exception message.
    throw $e;
  }
}

or maybe even better just this:

    /**
     * Returns the status message of this test.
     *
     * @return string
     */
    public function getStatusMessage()
    {
      if ($this->hasFailed()) {
        $logs = $this->getAssertableLogger()->getDisallowedLogs();
        if ($logs) {
        }
        return $this->statusMessage . print_r($this->getAssertableLogger()->getDisallowedLogs(), TRUE));
      }
        return $this->statusMessage;
    }

Approach C

If we don't want to do any of this, we could add a method like this to LoggingTrait:

  /**
   * Assert that no logs were received that should not have been.
   */
  protected function assertNoDisallowedLogs(): void {
    if ($this->getAssertableLogger()) {
      $this->assertEmpty($this->getAssertableLogger()->getDisallowedLogs(), "Logs were generated during the test that were explicitly expected not to be generated. " . print_r($this->getAssertableLogger()->getDisallowedLogs(), TRUE));
    }
  }

This would allow test code to do more frequent checking for bad logs to assist debugging.

joachim’s picture

> We decided not to do this, because the SUT can catch exceptions and so throwing them in the logger is not a reliable way to cause a test to fail.

I don't have time right now to dig into how this works, but I can report that doing this fails the test where the log was caused by cron:

  protected function handleLog(int $level, string $channel, string $message): void {
    $is_expected = $this->handleLogExpectations($level, $channel, $message);
    if ($is_expected) {
      return;
    }
    $is_disallowed = !$this->isLogAllowed($level, $channel, $message) && $this->isLogDisallowed($level, $channel, $message);
    if ($is_disallowed) {
      Assert::fail("FAIL!");
// etc

No idea how, since Drupal\Core\Cron catches all exceptions.

joachim’s picture

Status: Reviewed & tested by the community » Needs work

Something else that's missing from the current MR is handling of variables in the message.

E.g. I just got this failure in a test:

            [level] => 3
            [channel] => wsd_test_commons
            [message] => %type: @message in %function (line %line of %file).
            [trace] => Array

which is not very informative!

Also, the backtrace we give should perhaps trim off the calls which are to do with the logging system itself.

joachim’s picture

Assigned: joachim » Unassigned

Unassigning myself so other people feel free to jump in -- not had time to consider options in #90 yet, but still on my radar.

joachim’s picture

I'm doing a bit of experimenting around #90.

This is my test code:

  public function testUnexpectedLogBeforeTestFailure() {
    $this->expectNoLogsAsSevereAs(RfcLogLevel::ERROR);

    \Drupal::logger('test')->error('a test error');

    $this->fail('Normal failure');
  }

We'd like this to fail the test on the log error, and not the failure. I haven't yet figured how to make the test actually test this!

Approach A: I don't think this is going to work:

However we could combine this approach with approach of inspecting the logs in assertPostConditions(). We could store a record of the log, throw an exception if it was unexpected, and then inspect the logs in assertPostconditions() and trigger failure if appropriate in case the thrown exception was caught and didn't stop the test earlier.

When I run my test, assertPostConditions() is not called, and so assertLogExpectationsMet() isn't either!

> This approach would also not help with cron either, because that is an example of a SUT that catches exceptions.

Also, I think that cron and queue runners are one of our major use cases.

Going to look at approach B next.

jonathanshaw’s picture

Going to look at approach B next.

Yeah, I quite like the version of #90 B that overrides getStatusMessage.

joachim’s picture

I've been trying to get it to work, and struggling.

For one thing, my debugging seems to show that PHPUnit's TestBase class gets copied into the sites/simpletest folder?!?!

joachim’s picture

In passing -- I've just discovered Psr\Log\Test\TestLogger, in the psr/log package.

One of our tests is using this already (LegacyBootstrapTest). Could we do the same here?

jonathanshaw’s picture

Status: Needs work » Postponed

FWIW a cruder version of this kind of functionality was implemented in Drupal Test Traits:
https://gitlab.com/weitzman/drupal-test-traits/-/merge_requests/110/diffs

I've just discovered Psr\Log\Test\TestLogger, in the psr/log package.

Yes! See #3321905: Add colinodell/psr-test-logger to core's dev dependencies. Previously this was very crude, but it's now matured to do exactly the kind of thing we want here, so yay!

Let's postpone on #3321905: Add colinodell/psr-test-logger to core's dev dependencies.

wim leers’s picture

jonathanshaw’s picture

Status: Postponed » Needs work
liam morland’s picture

Version: 10.0.x-dev » 11.x-dev
adamfranco’s picture

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.

joachim’s picture

Priority: Normal » Major

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 #3041700: [META] Convert some tests into Kernel or Unit tests, 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");
  }
jonathanshaw’s picture

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.

joachim’s picture

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.

jonathanshaw’s picture

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.

joachim’s picture

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.

jonathanshaw’s picture

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

guypaddock’s picture

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?
jonathanshaw’s picture

@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()

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

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

alexpott’s picture

We can convert \Drupal\KernelTests\Core\Extension\ModuleInstallerTest, \Drupal\Tests\system\Kernel\SecurityAdvisories\SecurityAdvisoriesFetcherTest and \Drupal\KernelTests\Core\Config\ConfigImporterMissingContentTest are converted to use this.

alexpott’s picture

At the moment this trait has an architectural flaw - the logging expectations do not survive a container rebuild which they need to.

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

alexpott’s picture

#116 is resolved and tested.

nikolay shapovalov’s picture

Next time I will use search better.
I tried to do pretty similar thing but with different approach at #3496184: Replace usage of dblog module at tests with alternative.
And I am very happy about approach used at this issue. Having this at Kernel test by default will improve DX a lot, because now there are tons of different ways doing this.

I would suggest not to replace tokens at message, but keep original message and provide tokens value. Because at FormattableMarkup::placeholderFormat token values can be escaped and wrapped with <em class="placeholder"></em>.
And we are not checking message display but provided values.
And it much easy to search message source.

You can check example at MR 10863, but here is some code for short example.

     $expected_message = [
      'message' => "View display '@id': Comment field formatter '@name' was disabled because it is using the comment view display '@display' (@mode) that was just disabled.",
      'context' => [
        '@id' => $host_display_id,
        '@name' => $field_name,
        '@display' => EntityViewMode::load("comment.$mode")->label(),
        '@mode' => $mode,
      ],
    ];
    $this->assertTrue($logger->hasRecord($expected_message, RfcLogLevel::WARNING));
joachim’s picture

I might have a better approach for #108.

It's possible to store an exception and throw it later:

<?php

function sut() {
  throw new \Exception();
}

class Catcher {

  protected $e;

  function catching() {
    try {
      sut();
    }
    catch (\Exception $e) {
      $this->e = $e;
    }
  }

  function check() {
    throw $this->e;
  }
}

$c = new Catcher();

$c->catching();
$c->check();

This fails on the thrown exception in check(), but the error reported makes it look like it happened in catching().

This means that our logger could throw an exception at the point it wants to say things have gone wrong, but catch and store it, and then when asked by the test whether everything is OK, throw that exception.

You'd then get the backtrace of what actually caused the problem, which otherwise could have been swallowed up by something like the batch system or cron.

mondrake’s picture

Issue tags: +Needs reroll

The MR is 1280+ commits behind head, needs a rebase.

jonathanshaw’s picture

I think #120 is interesting, possibly simpler than some of the alternatives suggested in #90, because it makes use of phpunit's exception reporting.

However, it seems like it would do a bad job at handling multiple logged errors, because we could only rethrow one.

So while capturing the backtrace in the logger is necessary, I'm not sure rethrowing is what's needed.

joachim’s picture

> However, it seems like it would do a bad job at handling multiple logged errors, because we could only rethrow one.

I think that's a good thing! The first one fails the test, you fix it, then you get another one, and you fix that one. This is the same way that test failures work -- you only see the first one.

jonathanshaw’s picture

#123 makes sense to me. In which case, yes, #120 seems good.

We need to identify the right point to trigger the rethrowing, before the test begins to output information about assertion failures, as these may be subsequent to the original exception and so confusing.

joachim’s picture

The DX I was thinking of was this:

// I know this might log some errors, but they are not logged inside a try{} block.
$this->expectNoLogErrors();
$sut->doSomething();
// I know this might log some errors, but the SUT is wrapped in a try{} block.
$sut->doSomething();
$this->assertNoCaughtLogErrors();

Having two different ways to check for this is less good that the approach we were working on previously, but the advantage is that the reporting from PHPUnit is consistent and correct.

jonathanshaw’s picture

I find that even as someone who's spent hours working on solutions for this problem in the past, when I read the suggested DX, my mind is completely blank as to what the difference is, until I read the comments. That seems like a bad sign! Are you convinced this is necessary?

Version: 11.x-dev » main

Drupal core is now using the main branch as the primary development branch. New developments and disruptive changes should now be targeted to the main branch.

Read more in the announcement.

donquixote’s picture

One thing I like to see in a test log system is the concept of marking log messages as "read".
I do this whenever I implement my own custom test logging helper for a custom or contrib project.

This is especially useful in a test that goes through multiple subsequent operations or steps, which is common in Drupal tests.
It helps to not get confused with messages from previous operations.
It also helps to not get confused with log messages from Drupal install.

Like this:

  1. Create a node type "page".
  2. Mark all non-error messages as read.
  3. Visit node/add/page.
  4. Assert unread log message "no access", and mark as read.
  5. Login as admin.
  6. Assert unread log message "logged in", and mark as read.
  7. Visit node/add/page, again.
  8. (assert no further unread log messages)
  9. Fill form and submit.
  10. Assert unread log message "node was created", and mark as read.
  11. Edit the node. Submit.
  12. Assert unread log message "node was updated", and mark as read.
  13. In tearDown(), assert that no further unread log messages with failure exist, OR that not any unread log messages exist.

The "assert next unread and mark as read" can be implemented as an atomic operation.
Whenever this is called, we can also assert that no failure log messages exist (warning, error etc).
I wrote a PoC of this in #3274834: Allow functional tests to fail or expect logged errors, using dblog as backend. Still WIP.
The "mark as read" does not need any additional storage, because the test itself is the only thing that needs to know about it.

donquixote’s picture

Issue summary: View changes

One thing I like to see in a test log system is the concept of marking log messages as "read".
I do this whenever I implement my own custom test logging helper for a custom or contrib project.

Changing my mind from previous post:

  • We should call ->expectLogMessage() before it occurs, rather than calling "->assertLogMessageExists()" after it occurs.
  • We queue up expectations for log messages, and then "consume" them when we find matching messages in the log.
  • A queued log expectation has a match condition and then an assert condition.
    If the expectation match condition does not match, it is kept in the queue.
    If the match condition matches but the assert condition does not, it produces a failure.
  • Conditions for match and assert can be callbacks, to allow for dynamic conditions for match and assert, e.g. if some parts of the log message are not hard-predictable or it would make the test too rigid.
  • We store an expected count with each expectation.
    On a match, we count down.
    On zero, we remove the expectation.
    Negative numbers allow for infinite matches.
    We can use -1 for "at least one match", and lower negative numbers for "any number or zero matches".
  • We match the logs to expectations automatically at strategic moments, e.g. in functional tests we can do it after we receive a page response. In those places we fail on unexpected log messages, but not on unmatched expectations.
  • At the end of a test we fail for unmatched expectations.
joachim’s picture

> If the match condition matches but the assert condition does not, it produces a failure.

Do you mean that we store this for a subsequent check, or the end of the test, or that we fail immediately?

Failing immediately is problematic - we can't reliably cause a test failure to happen inside the logger, because the call to log() could be done by a system that catches exceptions, such as a batch, queue, or cron.

donquixote’s picture

If the match condition matches but the assert condition does not, it produces a failure.

Do you mean that we store this for a subsequent check, or the end of the test, or that we fail immediately?

We fail at the moment when we compare actual logged messages to the queued expectations.
This would mean:

  • In a kernel test, most of the time we can do it immediately when ->log() is called.
    For a failure, we can just Assert::fail() or a regular Assert::assertSame() etc, the original ->log() call will naturally be in the stack trace.
  • In a functional test, we have to store the log somewhere persistent at first.
    Like dblog/watchdog, but it could be a separate solution without a module. And it should store a backtrace every time, and additional metadata similar to dblog.
    Then in the test, we can load the stored messages at strategic moments, and compare them against expectations.
    E.g. it could trigger automatically after a server request, and at the end of a test.
    On failure, we need to add metadata to the failure message:
    - This log happened in a web request to (url).
    - The original stack trace was this: (trace)
    - Show metadata stored with the log record.

The expectations queue only needs to exist in the test process. It does not need to be persisted. Therefore it is safe for it to contain callbacks.

EDIT: I realize we need some kind of buffer even in kernel tests, because, as you say, exceptions (and test failures) may be caught.