How to verify ILogger<T>.Log extension method has been called using Moq?
Asked Answered
E

6

74

I created a xUnit project to test this sample code

public class ClassToTest
{
    private readonly ILogger<ClassToTest> _logger;

    public ClassToTest(ILogger<ClassToTest> logger)
    {
        _logger = logger;
    }
    
    public void Foo() => _logger.LogError(string.Empty);
}

I installed Moq to create a mock instance for the logger

public class ClassToTestTests
{
    private readonly ClassToTest _classToTest;
    private readonly Mock<ILogger<ClassToTest>> _loggerMock;
    
    public ClassToTestTests()
    {
        _loggerMock = new Mock<ILogger<ClassToTest>>();
        _classToTest = new ClassToTest(_loggerMock.Object);
    }

    [Fact]
    public void TestFoo()
    {
        _classToTest.Foo();
        
        _loggerMock.Verify(logger => logger.LogError(It.IsAny<string>()), Times.Once);
    }
}

When running the tests I get this error message

System.NotSupportedException: Unsupported expression: logger => logger.LogError(It.IsAny(), new[] { })

System.NotSupportedException Unsupported expression: logger => logger.LogError(It.IsAny(), new[] { }) Extension methods (here: LoggerExtensions.LogError) may not be used in setup / verification expressions.

After some research I know that all the log methods are just extension methods. Moq is not able to setup extension methods.

I would like to avoid installing additional third party packages for this problem. Are there any solutions to make the test pass?

Enyedy answered 21/2, 2021 at 21:46 Comment(4)
The extensions themselves call on some other method belonging to the ILogger interface. You could check if that method is called instead.Intuitionism
@Intuitionism except the extension methods use an internal class as the state. So if you want to know/assert what actually was logged, you can't.. And It.IsAny<> won't work since you can't use the internal class for the generic parameter. If you really have such a need I suggest writing a custom ILogger implementation to use in your tests. You can have all messages redirected to a list and then assert their contents directlyTalishatalisman
There are heaps of answers on this e.g., https://mcmap.net/q/275592/-moq-and-microsoft-extensions-logging-ilogger-unit-tests-failing-after-microsoft-extensions-logging-abstractions-updateAstraphobia
Voting to reopen. The dup is about mocking the logger not verifying the logger was called.Glennaglennie
T
119

You can't mock extension methods.

Instead of mocking

logger.LogError(...)

You need to mock the interface method

void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter);

LogError actually calls that interface method like this

logger.Log(LogLevel.Error, 0, new FormattedLogValues(message, args), null, (state, ex) => state.ToString());

So you need to mock

 _loggerMock.Verify(logger => logger.Log(It.Is(LogLevel.Error), It.Is(0), It.IsAny<FormattedLogValues>(), It.IsAny<Exception>(), It.IsAny<Func<TState, Exception, string>>()), Times.Once);

Disclaimer I didn't verify the code

Edit after the comment from pinkfloydx33, I set up a test example in .net50 and came to the following answer

With the most recent framework the FormattedLogValues class has been made internal. So you can't use this with the generic Moq.It members. But Moq has an alternate way to do this (this answer also mentioned the solution)

For a call to the logger like this

_logger.LogError("myMessage");

You need to verify like this

_loggerMock.Verify(logger => logger.Log(
        It.Is<LogLevel>(logLevel => logLevel == LogLevel.Error),
        It.Is<EventId>(eventId => eventId.Id == 0),
        It.Is<It.IsAnyType>((@object, @type) => @object.ToString() == "myMessage" && @type.Name == "FormattedLogValues"),
        It.IsAny<Exception>(),
        It.IsAny<Func<It.IsAnyType, Exception, string>>()),
    Times.Once);

You use the It.IsAnyType for types where you don't have access to. And if you need to restrict the verification you can add a func<object, type> to check if the type is what you expect or cast it to the public interface and validate any public members it has.

When you work with a string message and some parameters you need to cast the object of type FormattedLogValues to interface IReadOnlyList<KeyValuePair<string, object?>> and verify the string/values of the different parameters.

Please read the remark from pcdev in the comments about the FormattedLogValues and its behaviour to format the message!

Truc answered 21/2, 2021 at 22:12 Comment(12)
FormattedLogValues is an internal class. This won't workTalishatalisman
@Talishatalisman thanks for pointing out. I guess this has been changed in the more recent framework because I based my first answer on an archived github repo.Truc
It.IsAnyType is a new one to me. Cool. That'd been useful to know a while agoTalishatalisman
thanks for your help. when replacing my Verify call with the last one of yours I get the following exception Expected invocation on the mock once, but was 0 times: logger => logger.Log<It.IsAnyType>(It.Is<LogLevel>(logLevel => (int)logLevel == 4), It.Is<EventId>(eventId => eventId.Id == 0), It.Is<It.IsAnyType>((object, type) => object.ToString() == "myMessage" && type.Name == "FormattedLogValues"), It.IsAny<Exception>(), It.IsAny<Func<It.IsAnyType, Exception, string>>())Enyedy
I also tried to replace "myMessage" with It.IsAny<string>()Enyedy
Hi @Enyedy you can just drop the check on the object.ToString() == "myMessage". This was just to point out that you can verify the message that is logged. In your OP it would be string.Empty but it depends on the actual log that is written. The It class can only be used with the verify function.Truc
ah that worked, thanks! but it seems I have to keep @type.Name == "FormattedLogValues" right? And this is fix too eventId.Id == 0Enyedy
It's not needed if you return true it will be fine. The additional checks are only their if this is part of your unit test. E.g. the test requires that that type of message is written. If you only want to know if a message was written but you are not bothered what is written you can just return true like this (@object, @type) => true or eventId => true.Truc
@Enyedy adamstorr.azurewebsites.net/blog/mocking-ilogger-with-moqPilpul
Despite being in C# development for few years, never knew that calls to extension methods can't be verified. Thank you for your solution, I had issues verifying the call to the main method.Rolling
It.Is<LogLevel>(logLevel => logLevel == LogLevel.Error) can be replaced with LogLevel.ErrorMaidamaidan
If you want to match the exception message as part of your test, be aware that the FormattedLogValues object will format the string so it no longer matches what you passed in. I had success with It.Is<It.IsAnyType>((@object, @type) => @object.ToString()!.Contains(expectedMessage) && @type.Name == "FormattedLogValues"). I also found that the nullability of the last parameter didn't match and used this instead: It.IsAny<Func<It.IsAnyType, Exception?, string>>()Hashim
K
17

I found the answer Sergio Moreno posted in Git here worked for me:

mock.Verify(
    x => x.Log(
        It.IsAny<LogLevel>(),
        It.IsAny<EventId>(),
        It.Is<It.IsAnyType>((v,t) => true),
        It.IsAny<Exception>(),
        It.Is<Func<It.IsAnyType, Exception, string>>((v,t) => true))
Krishnakrishnah answered 21/2, 2021 at 21:46 Comment(0)
M
9

The answer by verbedr worked fine, with one change for Net6, I had to make Exception nullable. Here's the setup and verify code for clarity:

_mockLogger.Setup(logger => logger.Log(
    It.Is<LogLevel>(logLevel => logLevel == LogLevel.Error),
    It.IsAny<EventId>(),
    It.Is<It.IsAnyType>((@object, @type) => true),
    It.IsAny<Exception>(),
    It.IsAny<Func<It.IsAnyType, Exception?, string>>()
));

_mockLogger.Verify(
    logger => logger.Log(
        It.Is<LogLevel>(logLevel => logLevel == LogLevel.Error),
        It.IsAny<EventId>(),
        It.Is<It.IsAnyType>((@object, @type) => true),
        It.IsAny<Exception>(),
        It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
    Times.Once);
Mathers answered 6/3, 2023 at 18:27 Comment(0)
K
2

C# code looks like below using Nuint & Mock framework latest version and its working for me.

Declare:

private Mock<ILogger> _logger;

Inside setup method:

_logger = new Mock<ILogger>();

Inside Test Method:

_logger.Setup(x => x.Log(LogLevel.Information, 0, It.IsAny<object>(), It.IsAny<Exception>(), It.IsAny<Func<object, Exception, string>>())).Verifiable();
_logger.Verify(logger => logger.Log(
    It.Is<LogLevel>(logLevel => logLevel == LogLevel.Information),
    0,
    It.Is<It.IsAnyType>((@o, @t) => @o.ToString().StartsWith("C# Timer trigger function executed at: ") && @t.Name == "FormattedLogValues"),
    It.IsAny<Exception>(),
    It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);

Inside Teardown method:

_logger.VerifyNoOtherCalls();

Karyn answered 17/5, 2023 at 4:38 Comment(0)
A
0

I landed on this page for the same reason that I could not test my logger outputs. And one of my collegue suggested a nuget library called MELT and it really solves this problem. of course you can create a custom solution on your own ( as the question owner said he doesnt want a 3rd party ) but there is a shortcut and enjoy it. This is the library : Melt If you want to test that logger has outputted "myMessage" like _logger.LogError("myMessage"); see the following ;

 var testFactory = TestLoggerFactory.Create();
 var logger = testFactory.CreateLogger<YourLoggerImplementation>();
 SystemUnderTest sut = new SystemUnderTest(options, logger);
 sut.Action(); // that logger works and logs "myMessage"
 testFactory.Sink.LogEntries.Should().ContainSingle(t => t.Message!.Contains("myMessage"));
Aramen answered 5/9, 2023 at 13:26 Comment(0)
C
-1

I recently used this solution with Moq version 4.18.2. To answer this specific question, I would instead call:

_loggerMock.VerifyLogging(string.Empty, LogLevel.Error, Times.Once());
Ciliolate answered 25/3, 2023 at 0:53 Comment(1)
Are you referring to github.com/adrianiftode/Moq.ILogger ?Genteelism

© 2022 - 2024 — McMap. All rights reserved.