How to unit test with ILogger in ASP.NET Core

后端 未结 14 1189
南旧
南旧 2020-12-04 11:47

This is my controller:

public class BlogController : Controller
{
    private IDAO _blogDAO;
    private readonly ILogger _         


        
相关标签:
14条回答
  • 2020-12-04 12:06

    Just mock it as well as any other dependency:

    var mock = new Mock<ILogger<BlogController>>();
    ILogger<BlogController> logger = mock.Object;
    
    //or use this short equivalent 
    logger = Mock.Of<ILogger<BlogController>>()
    
    var controller = new BlogController(logger);
    

    You probably will need to install Microsoft.Extensions.Logging.Abstractions package to use ILogger<T>.

    Moreover you can create a real logger:

    var serviceProvider = new ServiceCollection()
        .AddLogging()
        .BuildServiceProvider();
    
    var factory = serviceProvider.GetService<ILoggerFactory>();
    
    var logger = factory.CreateLogger<BlogController>();
    
    0 讨论(0)
  • 2020-12-04 12:09

    I have created a package, Moq.ILogger, to make testing ILogger extensions much easier.

    You can actually use something like the following which is more close to your actual code.

    loggerMock.VerifyLog(c => c.LogInformation(
                     "Index page say hello", 
                     It.IsAny<object[]>());
    

    Not only it is easier to write new tests, but also the maintenance is with no costs.

    The repo can be found here and there is a nuget package too (Install-Package ILogger.Moq).

    I explained it also with a real-life example on my blog.

    In short, let's say if you have the following code:

    public class PaymentsProcessor
    {
        private readonly IOrdersRepository _ordersRepository;
        private readonly IPaymentService _paymentService;
        private readonly ILogger<PaymentsProcessor> _logger;
    
        public PaymentsProcessor(IOrdersRepository ordersRepository, 
            IPaymentService paymentService, 
            ILogger<PaymentsProcessor> logger)
        {
            _ordersRepository = ordersRepository;
            _paymentService = paymentService;
            _logger = logger;
        }
    
        public async Task ProcessOutstandingOrders()
        {
            var outstandingOrders = await _ordersRepository.GetOutstandingOrders();
            
            foreach (var order in outstandingOrders)
            {
                try
                {
                    var paymentTransaction = await _paymentService.CompletePayment(order);
                    _logger.LogInformation("Order with {orderReference} was paid {at} by {customerEmail}, having {transactionId}", 
                                           order.OrderReference, 
                                           paymentTransaction.CreateOn, 
                                           order.CustomerEmail, 
                                           paymentTransaction.TransactionId);
                }
                catch (Exception e)
                {
                    _logger.LogWarning(e, "An exception occurred while completing the payment for {orderReference}", 
                                       order.OrderReference);
                }
            }
            _logger.LogInformation("A batch of {0} outstanding orders was completed", outstandingOrders.Count);
        }
    }
    

    You could then write some tests like

    [Fact]
    public async Task Processing_outstanding_orders_logs_batch_size()
    {
        // Arrange
        var ordersRepositoryMock = new Mock<IOrdersRepository>();
        ordersRepositoryMock.Setup(c => c.GetOutstandingOrders())
            .ReturnsAsync(GenerateOutstandingOrders(100));
    
        var paymentServiceMock = new Mock<IPaymentService>();
        paymentServiceMock
            .Setup(c => c.CompletePayment(It.IsAny<Order>()))
            .ReturnsAsync((Order order) => new PaymentTransaction
            {
                TransactionId = $"TRX-{order.OrderReference}"
            });
    
        var loggerMock = new Mock<ILogger<PaymentsProcessor>>();
    
        var sut = new PaymentsProcessor(ordersRepositoryMock.Object, paymentServiceMock.Object, loggerMock.Object);
    
        // Act
        await sut.ProcessOutstandingOrders();
    
        // Assert
        loggerMock.VerifyLog(c => c.LogInformation("A batch of {0} outstanding orders was completed", 100));
    }
    
    [Fact]
    public async Task Processing_outstanding_orders_logs_order_and_transaction_data_for_each_completed_payment()
    {
        // Arrange
        var ordersRepositoryMock = new Mock<IOrdersRepository>();
        ordersRepositoryMock.Setup(c => c.GetOutstandingOrders())
            .ReturnsAsync(GenerateOutstandingOrders(100));
    
        var paymentServiceMock = new Mock<IPaymentService>();
        paymentServiceMock
            .Setup(c => c.CompletePayment(It.IsAny<Order>()))
            .ReturnsAsync((Order order) => new PaymentTransaction
            {
                TransactionId = $"TRX-{order.OrderReference}"
            });
    
        var loggerMock = new Mock<ILogger<PaymentsProcessor>>();
    
        var sut = new PaymentsProcessor(ordersRepositoryMock.Object, paymentServiceMock.Object, loggerMock.Object);
    
        // Act
        await sut.ProcessOutstandingOrders();
    
        // Assert
        loggerMock.VerifyLog(logger => logger.LogInformation("Order with {orderReference} was paid {at} by {customerEmail}, having {transactionId}",
            It.Is<string>(orderReference => orderReference.StartsWith("Reference")),
            It.IsAny<DateTime>(),
            It.Is<string>(customerEmail => customerEmail.Contains("@")),
            It.Is<string>(transactionId => transactionId.StartsWith("TRX"))),
          Times.Exactly(100));
    }
    
    [Fact]
    public async Task Processing_outstanding_orders_logs_a_warning_when_payment_fails()
    {
        // Arrange
        var ordersRepositoryMock = new Mock<IOrdersRepository>();
        ordersRepositoryMock.Setup(c => c.GetOutstandingOrders())
            .ReturnsAsync(GenerateOutstandingOrders(2));
    
        var paymentServiceMock = new Mock<IPaymentService>();
        paymentServiceMock
            .SetupSequence(c => c.CompletePayment(It.IsAny<Order>()))
            .ReturnsAsync(new PaymentTransaction
            {
                TransactionId = "TRX-1",
                CreateOn = DateTime.Now.AddMinutes(-new Random().Next(100)),
            })
            .Throws(new Exception("Payment exception"));
    
        var loggerMock = new Mock<ILogger<PaymentsProcessor>>();
    
        var sut = new PaymentsProcessor(ordersRepositoryMock.Object, paymentServiceMock.Object, loggerMock.Object);
    
        // Act
        await sut.ProcessOutstandingOrders();
    
        // Assert
        loggerMock.VerifyLog(c => c.LogWarning(
                     It.Is<Exception>(paymentException => paymentException.Message.Contains("Payment exception")), 
                     "*exception*Reference 2"));
    }
    
    0 讨论(0)
  • 2020-12-04 12:10

    Merely creating a dummy ILogger is not very valuable for unit testing. You should also verify that the logging calls were made. You can inject a mock ILogger with Moq but verifying the call can be a little tricky. This article goes into depth about verifying with Moq.

    Here is a very simple example from the article:

    _loggerMock.Verify(l => l.Log(
    LogLevel.Information,
    It.IsAny<EventId>(),
    It.IsAny<It.IsAnyType>(),
    It.IsAny<Exception>(),
    (Func<It.IsAnyType, Exception, string>)It.IsAny<object>()), Times.Exactly(1));
    

    It verifies that an information message was logged. But, if we want to verify more complex information about the message like the message template and the named properties, it gets more tricky:

    _loggerMock.Verify
    (
        l => l.Log
        (
            //Check the severity level
            LogLevel.Error,
            //This may or may not be relevant to your scenario
            It.IsAny<EventId>(),
            //This is the magical Moq code that exposes internal log processing from the extension methods
            It.Is<It.IsAnyType>((state, t) =>
                //This confirms that the correct log message was sent to the logger. {OriginalFormat} should match the value passed to the logger
                //Note: messages should be retrieved from a service that will probably store the strings in a resource file
                CheckValue(state, LogTest.ErrorMessage, "{OriginalFormat}") &&
                //This confirms that an argument with a key of "recordId" was sent with the correct value
                //In Application Insights, this will turn up in Custom Dimensions
                CheckValue(state, recordId, nameof(recordId))
        ),
        //Confirm the exception type
        It.IsAny<NotImplementedException>(),
        //Accept any valid Func here. The Func is specified by the extension methods
        (Func<It.IsAnyType, Exception, string>)It.IsAny<object>()),
        //Make sure the message was logged the correct number of times
        Times.Exactly(1)
    );
    

    I'm sure that you could do the same with other mocking frameworks, but the ILogger interface ensures that it's difficult.

    0 讨论(0)
  • 2020-12-04 12:13

    Actually, I've found Microsoft.Extensions.Logging.Abstractions.NullLogger<> which looks like a perfect solution. Install the package Microsoft.Extensions.Logging.Abstractions, then follow the example to configure and use it:

    using Microsoft.Extensions.Logging;
    
    public void ConfigureServices(IServiceCollection services)
    {
        ...
    
        services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
    
        ...
    }
    
    using Microsoft.Extensions.Logging;
    
    public class MyClass : IMyClass
    {
        public const string ErrorMessageILoggerFactoryIsNull = "ILoggerFactory is null";
    
        private readonly ILogger<MyClass> logger;
    
        public MyClass(ILoggerFactory loggerFactory)
        {
            if (null == loggerFactory)
            {
                throw new ArgumentNullException(ErrorMessageILoggerFactoryIsNull, (Exception)null);
            }
    
            this.logger = loggerFactory.CreateLogger<MyClass>();
        }
    }
    

    and unit test

    //using Microsoft.VisualStudio.TestTools.UnitTesting;
    //using Microsoft.Extensions.Logging;
    
    [TestMethod]
    public void SampleTest()
    {
        ILoggerFactory doesntDoMuch = new Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory();
        IMyClass testItem = new MyClass(doesntDoMuch);
        Assert.IsNotNull(testItem);
    }   
    
    0 讨论(0)
  • 2020-12-04 12:13

    It is easy as other answers suggest to pass mock ILogger, but it suddenly becomes much more problematic to verify that calls actually were made to logger. The reason is that most calls do not actually belong to the ILogger interface itself.

    So the most calls are extension methods that call the only Log method of the interface. The reason it seems is that it's way easier to make implementation of the interface if you have just one and not many overloads that boils down to same method.

    The drawback is of course that it is suddenly much harder to verify that a call has been made since the call you should verify is very different from the call that you made. There are some different approaches to work around this, and I have found that custom extension methods for mocking framework will make it easiest to write.

    Here is an example of a method that I have made to work with NSubstitute:

    public static class LoggerTestingExtensions
    {
        public static void LogError(this ILogger logger, string message)
        {
            logger.Log(
                LogLevel.Error,
                0,
                Arg.Is<FormattedLogValues>(v => v.ToString() == message),
                Arg.Any<Exception>(),
                Arg.Any<Func<object, Exception, string>>());
        }
    
    }
    

    And this is how it can be used:

    _logger.Received(1).LogError("Something bad happened");   
    

    It looks exactly as if you used the method directly, the trick here is that our extension method gets priority because it's "closer" in namespaces than the original one, so it will be used instead.

    It does not give unfortunately 100% what we want, namely error messages will not be as good, since we don't check directly on a string but rather on a lambda that involves the string, but 95% is better than nothing :) Additionally this approach will make the test code

    P.S. For Moq one can use the approach of writing an extension method for the Mock<ILogger<T>> that does Verify to achieve similar results.

    P.P.S. This does not work in .Net Core 3 anymore, check this thread for more details: https://github.com/nsubstitute/NSubstitute/issues/597#issuecomment-573742574

    0 讨论(0)
  • 2020-12-04 12:13

    Building even further on the work of @ivan-samygin and @stakx, here are extension methods that can also match on the Exception and all log values (KeyValuePairs).

    These work (on my machine ;)) with .Net Core 3, Moq 4.13.0 and Microsoft.Extensions.Logging.Abstractions 3.1.0.

    /// <summary>
    /// Verifies that a Log call has been made, with the given LogLevel, Message and optional KeyValuePairs.
    /// </summary>
    /// <typeparam name="T">Type of the class for the logger.</typeparam>
    /// <param name="loggerMock">The mocked logger class.</param>
    /// <param name="expectedLogLevel">The LogLevel to verify.</param>
    /// <param name="expectedMessage">The Message to verify.</param>
    /// <param name="expectedValues">Zero or more KeyValuePairs to verify.</param>
    public static void VerifyLog<T>(this Mock<ILogger<T>> loggerMock, LogLevel expectedLogLevel, string expectedMessage, params KeyValuePair<string, object>[] expectedValues)
    {
        loggerMock.Verify(mock => mock.Log(
            expectedLogLevel,
            It.IsAny<EventId>(),
            It.Is<It.IsAnyType>((o, t) => MatchesLogValues(o, expectedMessage, expectedValues)),
            It.IsAny<Exception>(),
            It.IsAny<Func<object, Exception, string>>()
            )
        );
    }
    
    /// <summary>
    /// Verifies that a Log call has been made, with LogLevel.Error, Message, given Exception and optional KeyValuePairs.
    /// </summary>
    /// <typeparam name="T">Type of the class for the logger.</typeparam>
    /// <param name="loggerMock">The mocked logger class.</param>
    /// <param name="expectedMessage">The Message to verify.</param>
    /// <param name="expectedException">The Exception to verify.</param>
    /// <param name="expectedValues">Zero or more KeyValuePairs to verify.</param>
    public static void VerifyLog<T>(this Mock<ILogger<T>> loggerMock, string expectedMessage, Exception expectedException, params KeyValuePair<string, object>[] expectedValues)
    {
        loggerMock.Verify(logger => logger.Log(
            LogLevel.Error,
            It.IsAny<EventId>(),
            It.Is<It.IsAnyType>((o, t) => MatchesLogValues(o, expectedMessage, expectedValues)),
            It.Is<Exception>(e => e == expectedException),
            It.Is<Func<It.IsAnyType, Exception, string>>((o, t) => true)
        ));
    }
    
    private static bool MatchesLogValues(object state, string expectedMessage, params KeyValuePair<string, object>[] expectedValues)
    {
        const string messageKeyName = "{OriginalFormat}";
    
        var loggedValues = (IReadOnlyList<KeyValuePair<string, object>>)state;
    
        return loggedValues.Any(loggedValue => loggedValue.Key == messageKeyName && loggedValue.Value.ToString() == expectedMessage) &&
               expectedValues.All(expectedValue => loggedValues.Any(loggedValue => loggedValue.Key == expectedValue.Key && loggedValue.Value == expectedValue.Value));
    }
    
    0 讨论(0)
提交回复
热议问题