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
- 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.
- 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.
- We created a LoggingTrait to use in KernelTestBase to facilitate future use in other kinds of tests, like functional tests or contrib ExistingSite tests.
- 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.
- 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.
Comments
Comment #3
larowlanJust lost an hour (again) to this
Comment #4
larowlanThis works well.
Probably a bit extreme to cause a watchdog_exception to fail a test. But maybe not.
Comment #6
larowlanHa some of those are legitimate issues with tests :)
Comment #7
lendudeWell, 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?
Comment #8
larowlanRight, 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.
Comment #9
drunken monkeyI'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 === NULLcheck 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!
Comment #10
megachriz+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.Comment #11
dawehner+1 for the general idea. These try/catch statements hide actual problems.
Given the potential disruption for contrib tests I'd suggest to just add this to the next minor release.
Comment #14
jonathanshaw1.
As I understand it, in the patch as it stands these will all work to suppress errors:
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.
Comment #17
jonathanshawWe're discussing something similar in DrupalTestTraits, and we have tests for the functionality that could provide inspiration here.
Comment #18
jonathanshawhttps://gitlab.com/weitzman/drupal-test-traits/-/merge_requests/94
Comment #19
ravi.shankar commentedHere I have rerolled patch #4 on D-9.1.x.
Still needs work for tests.
Comment #20
joachim commentedI 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.
Comment #21
joachim commentedHere'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?
Comment #22
jonathanshawWe 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?
Comment #23
joachim commented> 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:
what's needed is something like what's in @weitzman's patch over on Drupalforks:
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.
Comment #24
joachim commentedMore 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.
Comment #25
jonathanshawI think it's more intuitive if message of this level OR below trigger exceptions. What do you reckon?
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.
Comment #26
joachim commentedHere'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:
> 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?
Comment #28
jonathanshawI've come across #2629286: Use getDisplayName() for user names consistently 188-193 where @alexpott is reluctant to break contrib tests en masse
Comment #29
joachim commentedUpdated the IS with current plan for this issue.
Comment #30
joachim commentedI've maybe found a problem with our approach...
When there's an error, we throw an exception immediately:
That means that the logged error stops the SUT.
In my custom code that I was testing, I had something like this pseudocode:
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.
Comment #31
mondrakeLooks like this is an issue for the PHPUnit component queue, at this stage
Comment #32
jonathanshawUggh. You're totally right. The current approach is good - better than simply ignoring logged errors silently - but not great.
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.
That's why the DTT implemetation has methods to vary acceptable severity level on the fly during a test.
Comment #33
joachim commented@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!
Comment #34
joachim commentedThe 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?
Comment #35
jonathanshawHmm. That seems like a good idea. Using phpunit mocks it would look something like this:
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:
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?
Comment #36
joachim commentedI 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!
Comment #37
joachim commentedComment #38
jonathanshawGreat. I'm happy to keep reviewing and staying with it all the way to RTBC.
That would be good.
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-Assertionheaders added by the SUT.Searching the codebase for those headers leads me to core/includes/errors.inc which includes
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.
Comment #39
longwaveThis seems closely related to #652394: Aggressive watchdog message assertion, unsure if it's an exact duplicate or they just complement each other.
Comment #40
jonathanshawGood 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.
Comment #41
joachim commentedMy 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
Comment #42
joachim commentedFor Kernel tests, here's a quick proof of concept with Prophecy:
I put that in KernelTestBase::setUp().
The failure message isn't good at all though:
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.
Comment #43
longwaveGoing 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 beforetearDown().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?
Comment #44
jonathanshaw(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.
Comment #45
jonathanshaw@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.
Comment #46
jonathanshawHow's this for our API:
Comment #47
jonathanshawI got @drumm to enable issue forks for this issue, so we have that to play with.
Comment #52
joachim commentedI'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:
In my test class, just as a proof of concept:
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!
Comment #53
morganlyndel commentedThe issue summary appears to be up-to-date, removed the "Needs issue summary update" tag.
Comment #54
jonathanshawUse the existing fork and create a new branch and MR.
Comment #57
joachim commented> 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.
Comment #58
joachim commentedI'm doing some work on this.
Comment #59
jonathanshawSorry, 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.
Comment #60
joachim commentedI 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.
Comment #61
joachim commentedSome 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.
Comment #62
joachim commentedHere's my code for asserting the global expectation:
Comment #63
jonathanshawA 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?
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.
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?
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.
Comment #64
joachim commented> 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.
Comment #65
jonathanshawI'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().
Comment #66
joachim commentedFair point.
I'll have a think about how to call the method, because I think expectNoLog is misleading.
Comment #67
jonathanshawI'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.
Comment #68
joachim commented> 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 :/
Comment #69
jonathanshawThe fail is random, this is now complete and green.
Comment #70
joachim commentedReviewed the MR.
Comment #71
jonathanshawThe fail is random
Comment #72
joachim commentedLooks good!
Comment #73
jonathanshawComment #74
jonathanshawComment #75
jonathanshawComment #76
jonathanshaw@alexpott asked for a reroll onto 10.0, but that's beyond my gitfu.
Comment #77
alexpottThis 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.
Comment #78
jonathanshawCR done: https://www.drupal.org/node/3280722
Comment #80
jonathanshawThe MR 2903456-for-9.4 should be good as is.
The MR 2903456-1 needs someone to reroll it onto 10.x
Comment #81
joachim commentedAs discussed in slack, I've rebased branch 2903456-1 against 10.0.x, and there's now a 2nd branch 2903456-for-9.4.
Comment #82
jonathanshawComment #83
jonathanshawAdded primitive type hints. The changes since #72 are basically docs, typehints and coding stamdards, so this is RTBC once the bot is happy.
Comment #84
jonathanshawMR!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.
Comment #85
joachim commentedI've rebased the branch for the 10.0 MR.
Comment #86
jonathanshawThe test fail is random.
Comment #87
joachim commentedBack to RTBC.
Comment #88
joachim commentedI've just added the patch for this to my current project and tried running kernel tests with a expectNoLogsAsSevereAs() and it's working great!
Comment #89
joachim commentedI'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:
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:
then I *would* have seen the test fail on the logged exception.
@jonathanshaw any thoughts?
Comment #90
jonathanshawHmmm. 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:
or maybe even better just this:
Approach C
If we don't want to do any of this, we could add a method like this to LoggingTrait:
This would allow test code to do more frequent checking for bad logs to assist debugging.
Comment #91
joachim commented> 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:
No idea how, since Drupal\Core\Cron catches all exceptions.
Comment #92
joachim commentedSomething 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:
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.
Comment #93
joachim commentedUnassigning myself so other people feel free to jump in -- not had time to consider options in #90 yet, but still on my radar.
Comment #94
joachim commentedI'm doing a bit of experimenting around #90.
This is my test code:
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:
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.
Comment #95
jonathanshawYeah, I quite like the version of #90 B that overrides getStatusMessage.
Comment #96
joachim commentedI'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?!?!
Comment #97
joachim commentedIn 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?
Comment #98
jonathanshawFWIW 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
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.
Comment #99
wim leersSee #3321905-9: Add colinodell/psr-test-logger to core's dev dependencies for news there.
Comment #100
jonathanshaw#3321905: Add colinodell/psr-test-logger to core's dev dependencies got committed, so we're unblocked.
Comment #101
liam morlandComment #102
adamfranco commentedFor 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.phpThen in my kernel tests I can execute some code and then something like:
Which will result in an error like:
If I need to examine logs more closely to look for an expected warning message, I can use something like:
Which will result in an error message like the following:
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.
Comment #104
joachim commentedI'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:
Comment #105
jonathanshawI 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.
Comment #106
joachim commentedI think I see what you mean - simplified, something like this:
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.
Comment #107
jonathanshawI 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.
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.
Comment #108
joachim commentedI 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.
Comment #109
jonathanshawtearDown() isn't quite the right place maybe. See #90, especially #90.B that suggests to tinker with getStatusMessage() to append logs to it.
Comment #110
guypaddock commentedI 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:
Here's what is unclear:
Comment #111
jonathanshaw@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()
Comment #114
alexpottWe can convert \Drupal\KernelTests\Core\Extension\ModuleInstallerTest, \Drupal\Tests\system\Kernel\SecurityAdvisories\SecurityAdvisoriesFetcherTest and \Drupal\KernelTests\Core\Config\ConfigImporterMissingContentTest are converted to use this.
Comment #116
alexpottAt the moment this trait has an architectural flaw - the logging expectations do not survive a container rebuild which they need to.
Comment #118
alexpott#116 is resolved and tested.
Comment #119
nikolay shapovalov commentedNext 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::placeholderFormattoken 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.
Comment #120
joachim commentedI might have a better approach for #108.
It's possible to store an exception and throw it later:
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.
Comment #121
mondrakeThe MR is 1280+ commits behind head, needs a rebase.
Comment #122
jonathanshawI 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.
Comment #123
joachim commented> 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.
Comment #124
jonathanshaw#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.
Comment #125
joachim commentedThe DX I was thinking of was this:
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.
Comment #126
jonathanshawI 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?
Comment #128
donquixote commentedOne 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:
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.
Comment #129
donquixote commentedChanging my mind from previous post:
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.
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".
Comment #130
joachim commented> 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.
Comment #131
donquixote commentedWe fail at the moment when we compare actual logged messages to the queued expectations.
This would mean:
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.
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.