-
Notifications
You must be signed in to change notification settings - Fork 267
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Mock Microsoft.Extensions.Logging and test logging in .Net core 3 #597
Comments
Before the assert I added this code which shows the nature of the issue: // Assert
foreach (var c in _logger.ReceivedCalls()) {
System.Console.WriteLine(c.GetMethodInfo());
}
/* Output:
Void Log[FormattedLogValues](Microsoft.Extensions.Logging.LogLevel,
Microsoft.Extensions.Logging.EventId,
Microsoft.Extensions.Logging.FormattedLogValues,
System.Exception,
System.Func`3[Microsoft.Extensions.Logging.FormattedLogValues,System.Exception,System.String])
*/ The exception thrown from the assertion is:
So it looks like there is a mismatch between the generic type Looks like the same issue has reported here devlooped/moq#918, but NSubstitute does not have an equivalent for Moq's One option here is to cheat and use:
Even though Another option is to use a wrapper that forces logging to be done with
We can wrap the substitute logger with this type:
And then your original assertion passes as the generic types match. Another thing to consider, especially if you are doing lots of logging tests, is to implement a custom Hope this helps. |
@dtchepak your ext. method trick might work for |
Thanks for pointing this out @ehouarn-perret ! In cases like this where an API design is not easily testable with an automatically-generated substitute, then I think we're better off manually creating an If you need any assistance with this please let me know. |
Here is a mini version to verify the basics that can be extended further if anyone is interested: public class LoggerMock<T> : ILogger<T>
{
readonly Stack<ReceivedLogEvent> _events = new Stack<ReceivedLogEvent>();
public IDisposable BeginScope<TState>(TState state)
{
return null;
}
public bool IsEnabled(LogLevel logLevel)
{
return false;
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
{
_events.Push(new ReceivedLogEvent {Level = logLevel, Message = state.ToString()});
}
public void ReceivedOnce(LogLevel level, string message)
{
var matchedEventsCount = _events.Count(e => e.Level == level && e.Message == message);
if (matchedEventsCount != 1)
{
throw new Exception($"Expected one call to Log with the following arguments: {level}, \"{message}\". Actual received count: {matchedEventsCount}");
}
}
}
class ReceivedLogEvent
{
public LogLevel Level { get; set; }
public string Message { get; set; }
} P.S. It's so frustrating Microsoft has made this interface virtually untestable though it's not that hard I think to allow it... P.P.S. Don't know if it covers all the use cases, but it works fine for my simple uses and can easily be extended, just a reference point really. |
Thanks @ilya-spv! 👍 |
I solved this in two ways: I'd be happy to contribute either or both of these solutions. |
Hi @SeanFarrow, this sounds interesting! If it's not too much work could you please push the code somewhere or just copy/paste some snippets here for us to take a look at? It would be good to see a bit more info to make sure it is something we should include before you spend time putting this into a PR. (As an aside, I think for this particular issue the best approach is not to mock |
I have found out that actually testing on extension method does work out of the box: _logger
.Received(1)
.LogError(myException, "Exception happened"); I am actually not sure why. It did not work before, that was the reason everyone had to do something to mock the call to Log() with multiple parameters. I have checked that it's not the update to .netcore 3.1, but the Is there some new feature from .NetCore 3 that makes it possible @dtchepak? I am really wondering if we can "trust" this behaviour or is it something that can just be damaged since it did not work before... |
@ilya-spv I definitely wouldn't trust/rely on this behaviour. NSubstitute cannot mock static members; the options described here rely on a particular implementation detail that Microsoft is quite within their rights to change whenever they see fit. I personally try to never mock types I don't own, but attempting to mock static methods is even more dangerous as it should be fine to change the static method implementation without it being a breaking change/requiring a major version update. I think the cost of implementing a small, app/library-specific logging interface wrapper is fairly small, and may provide options to simplify client code (e.g. API with strongly typed logging events) in addition to being reliably testable. |
Thanks for the answer, I guess it's wise to follow your advise. I am curious though on why it suddenly started working? Since methods are still static and Nubstitute cannot mock these methods, yet it just works somehow... |
@ilya-spv it's working in 3.1 probably because FormattedLogValues was changed from being a class to a struct, and now it is possible to assert some calls if parameters match precisely. NSubstitute is not mocking statics, it's just your call to the extension method gets propagated to the mocked interface if you see what I mean. @dtchepak I can't believe that we need to implement some wrappers over ILogger to make our code testable. Doesn't it diminish the whole idea of "Microsoft.Extensions.Logging.Abstractions" library? |
@moikot Yes, thanks for the explanation, I guess I understand how it works. Maybe Microsoft will switch from using extension methods to the interface with default implementation in C#8, that would fix at least these problems nicely |
It seems it does diminish it a bit. But if we are using a library that is not easily testable, then I guess we only have the choice of wrapping it or switching to a different library. AFAICT it will still allow us to change which underlying logger implementation is used, so maybe that is still useful? I haven't used it so I'm not sure, but if it allows us to use a in-memory logger that we can then query in our tests then I think that is better than mocking it. |
I recently found Melt:
https://github.com/alefranz/MELT.git
This makes writing integration tests that check what is logged really easy with the TestSink.
From: David Tchepak [mailto:[email protected]]
Sent: 14 January 2020 21:44
To: nsubstitute/NSubstitute <[email protected]>
Cc: Sean Farrow <[email protected]>; Mention <[email protected]>
Subject: Re: [nsubstitute/NSubstitute] Mock Microsoft.Extensions.Logging and test logging in .Net core 3 (#597)
@moikot<https://github.com/moikot>
I can't believe that we need to implement some wrappers over ILogger to make our code testable. Doesn't it diminish the whole idea of "Microsoft.Extensions.Logging.Abstractions" library?
It seems it does diminish it a bit. But if we are using a library that is not easily testable, then I guess we only have the choice of wrapping it or switching to a different library.
AFAICT it will still allow us to change which underlying logger implementation is used, so maybe that is still useful? I haven't used it so I'm not sure, but if it allows us to use a in-memory logger that we can then query in our tests then I think that is better than mocking it.
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub<#597?email_source=notifications&email_token=AALDK7RQOZ72IN55ZZ4ZRZDQ5YW2BA5CNFSM4JNVCU2KYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEI6HQWI#issuecomment-574388313>, or unsubscribe<https://github.com/notifications/unsubscribe-auth/AALDK7WAAUBOUK3MGK4L6NDQ5YW2BANCNFSM4JNVCU2A>.
|
Thanks @SeanFarrow , that looks perfect! I strongly advise everyone looking at this issue to try a library like MELT rather than mocking |
Hello. I also just had a fun time researching this topic. I used similar test code to the one in the first post. When comparing the parameter types for actual and expected calls in CallSpecification.cs in @dtchepak, it seems like when comparing Func types for equivalency, .NET doesn't check each generic type parameter? Would it be correct or appropriate to update that method to check the generic parameters if the type is something like a Func? |
@pmaytak Thanks for the detective work! I'm not confident that this won't cause other problems (for example, if we're trying to resolve overloads with different @zvirja , @alexandrnikitin : do you have any thoughts about the safety of supporting co- and contravariance for NSubstitute/src/NSubstitute/Core/CallSpecification.cs Lines 72 to 84 in 5b182fc
|
@dtchepak that's a good catch. I would expect that |
@dtchepak Yes, I would also say that NSub should support Func co-and contravariance in accordance with the language rules. The only question is how to implement it in a way that it doesn't affect performance, as for vast majority of cases isn't not required. |
Best approach I have found so far is to do a minimal abstract logger: public abstract class MockLogger : ILogger
{
void ILogger.Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
=> Log(logLevel, formatter(state, exception));
public abstract void Log(LogLevel logLevel, string message);
public virtual bool IsEnabled(LogLevel logLevel) => true;
public abstract IDisposable BeginScope<TState>(TState state);
} Then use NSubstitute to do its thing on that, taking advantage of all the existing Received functionality, eg: var logger = Substitute.For<MockLogger>();
new UnderTest(logger).DoSomething();
logger.Received().Log(Arg.Any<LogLevel>(), Arg.Is<string>(s => s.Contains("some log message"))); |
@AlexVallat I just had to change first line in order to have a typed logger, but all works as advertised. Thanks! :) public abstract class MockLogger<T> : ILogger<T> |
Thanks for the insight @AlexVallat public abstract class MockLogger<T> : ILogger<T>
{
void ILogger.Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
=> Log(logLevel, formatter(state, exception), exception);
public abstract void Log(LogLevel logLevel, object state, Exception exception = null);
public virtual bool IsEnabled(LogLevel logLevel) => true;
public abstract IDisposable BeginScope<TState>(TState state);
} Then I can use logger.Received().Log(LogLevel.Error, "Message", exception);
// or
logger.Received().Log(LogLevel.Error, "Message", Arg.Is<ExceptionType>(ex => ex.Message == "")); |
What I’ve taken to doing is splitting this up:
In the unit tests, I test that the logger was call (or not depending on the test) the number of times I expect.
Then, in a separate integration test using Melt:
https://github.com/alefranz/MELT
I test that the output is what I expect.
This gives me the best of both worlds imo.
Sean.
From: murven ***@***.***>
Sent: 02 March 2022 19:56
To: nsubstitute/NSubstitute ***@***.***>
Cc: Sean Farrow ***@***.***>; Mention ***@***.***>
Subject: Re: [nsubstitute/NSubstitute] Mock Microsoft.Extensions.Logging and test logging in .Net core 3 (#597)
I was able to work around the issue this way:
var receivedCalls = logger.ReceivedCalls(); receivedCalls .ShouldContain( c => c.GetArguments() .Any(a => a is object && a.ToString() == "The string after the formatting is applied"));
Can anyone think of any objections to this approach?
I know there are more sophisticated workarounds in this thread, but this one also gets the work done, so I would like to know if this is bad practice and why.
—
Reply to this email directly, view it on GitHub<#597 (comment)>, or unsubscribe<https://github.com/notifications/unsubscribe-auth/AALDK7XXADS7ZFVZ4TJ2HL3U57BTNANCNFSM4JNVCU2A>.
You are receiving this because you were mentioned.Message ID: ***@***.***>
|
A bit further with mocking logger: using Microsoft.Extensions.Logging;
public abstract class MockLogger<T> : ILogger<T>
{
void ILogger.Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
var unboxed = (IReadOnlyList<KeyValuePair<string, object>>)state!;
string message = formatter(state, exception);
this.Log();
this.Log(logLevel, message, exception);
this.Log(logLevel, unboxed.ToDictionary(k => k.Key, v => v.Value), exception);
}
public abstract void Log();
public abstract void Log(LogLevel logLevel, string message, Exception? exception = null);
public abstract void Log(LogLevel logLevel, IDictionary<string, object> state, Exception? exception = null);
public virtual bool IsEnabled(LogLevel logLevel)
{
return true;
}
public abstract IDisposable BeginScope<TState>(TState state);
} And the usage with specific params: MockLogger<Service> logger = Substitute.For<MockLogger<Service>>();
logger.IsEnabled(LogLevel.Error).Returns(true);
string originalFormat = "Service returned error with code: '{statusCode}' and reason: '{reason}'";
var statusCode = HttpStatusCode.BadRequest;
string reason = "The reason...";
string message = $"Service returned error with code: '{(int)statusCode}' and reason: '{reason}'";
// Assert
logger.Received(1).Log(
LogLevel.Error,
Arg.Is<IDictionary<string, object>>(
dict =>
dict.Any(kv => kv.Key == "{OriginalFormat}" && (string)kv.Value == originalFormat) &&
dict.Any(kv => kv.Key == "statusCode" && (int)kv.Value == (int)statusCode) &&
dict.Any(kv => kv.Key == "reason" && (string)kv.Value == reason)));
logger.Received(1).Log(LogLevel.Error, message);
logger.Received(1).Log(); |
@SeanFarrow, |
.NET 8 seems to have a new |
I don't really see something we should do here (correct me if wrong!), so closing this issue. |
Mock Microsoft.Extensions.Logging in .Net core 3
Following code is working in .Net core 2.2 but .Net Core 3
Environments
Domain Class
Test Class
Not sure whether this is a bug. Any support would be appreciated.
The text was updated successfully, but these errors were encountered: