Unit Testing

Unit Testing ILogger in ASP.NET Core

I've been creating a new template solution for our ASP.NET Core projects. As I was writing some tests for an API controller, I hit a problem with mocking the ILogger<T> interface. So I thought I would write a quick blog post about what I found, mainly so I won't forget in the future!

I had a setup similar to the following code.

public class CatalogueController : Controller
{
    private readonly ILogger<CatalogueController> _logger;
    private readonly ICatalogueService _catalogueService;

    public CatalogueController(ILogger<CatalogueController> logger, ICatalogueService catalogueService)
    {
        _logger = logger;
        _catalogueService = catalogueService;
    }

    [HttpGet("api/catalogue")]
    public async Task<IActionResult> GetActiveStockItemsAsync()
    {
        try
        {
            var stockItems = await _catalogueService.GetActiveStockItemsAsync();

            return Ok(stockItems);
        }
        catch (Exception exception)
        {
            _logger.LogError("Error returning active stock catalogue items", exception);

            return new StatusCodeResult((int)HttpStatusCode.InternalServerError);
        }
  }
public class CatalogueControllerTests
{
    private readonly IFixture _fixture;
    private readonly Mock<ILogger<CatalogueController>> _mockLogger;
    private readonly Mock<ICatalogueService> _mockCatalogueService;
    private readonly CatalogueController _catalogueController; 

    public CatalogueControllerTests()
    {
        _fixture = new Fixture();

        _mockLogger = new Mock<ILogger<CatalogueController>>();
        _mockCatalogueService = new Mock<ICatalogueService>();
        _catalogueController = new CatalogueController(_mockLogger.Object, _mockCatalogueService.Object);
    }

    [Fact]
    public async Task GetActiveStockItems_LogsErrorAndReturnsInternalServerError_When_ErroOnServer()
    {
        // Arrange
        _mockCatalogueService.Setup(x => x.GetActiveStockItemsAsync()).Throws<Exception>();

        // Act
        var result = await _catalogueController.GetActiveStockItemsAsync();

        // Assert
        _mockLogger.Verify(x => x.LogError("Error returning active stock catalogue items", It.IsAny<Exception>()), Times.Once);
        var errorResult = Assert.IsType<StatusCodeResult>(result);
        Assert.Equal(500, errorResult.StatusCode);
    }
}

When I ran my test expecting it to pass I got the following error.

Message: System.NotSupportedException : Invalid verify on an extension method: x => x.LogError("Error returning active stock catalogue items", new[] { It.IsAny<Exception>() })

It turns out LogInformation, LogDebug, LogError, LogCritial and LogTrace are all extension methods. After a quick Google I came across this issue on GitHub with an explanation from Brennan Conroy as to why the ILogger interface is so limited.

Right now the ILogger interface is very small and neat, if you want to make a logger you only need to implement 3 methods, why would we want to force someone to implement the 24 extra extension methods everytime they inherit from ILogger?

Solution 1

There is a method on the ILogger interface which you can verify against, ILogger.Log. Ultimately all the extension methods call this log method. So a quick change to the verify code in my unit test and I had a working test.

Unit Test - Solution 1

public class CatalogueControllerTests
{
    private readonly IFixture _fixture;
    private readonly Mock<ILogger<CatalogueController>> _mockLogger;
    private readonly Mock<ICatalogueService> _mockCatalogueService;
    private readonly CatalogueController _catalogueController; 

    public CatalogueControllerTests()
    {
        _fixture = new Fixture();

        _mockLogger = new Mock<ILogger<CatalogueController>>();
        _mockCatalogueService = new Mock<ICatalogueService>();
        _catalogueController = new CatalogueController(_mockLogger.Object, _mockCatalogueService.Object);
    }

    [Fact]
    public async Task GetActiveStockItems_LogsErrorAndReturnsInternalServerError_When_ErroOnServer()
    {
        // Arrange
        _mockCatalogueService.Setup(x => x.GetActiveStockItemsAsync()).Throws<Exception>();

        // Act
        var result = await _catalogueController.GetActiveStockItemsAsync();

        // Assert
        _mockLogger.Verify(x => x.Log(LogLevel.Error, It.IsAny<EventId>(), It.IsAny<FormattedLogValues>(), It.IsAny<Exception>(), It.IsAny<Func<object, Exception, string>>()), Times.Once);
        var errorResult = Assert.IsType<StatusCodeResult>(result);
        Assert.Equal(500, errorResult.StatusCode);
    }
}

Solution 2

Another way to solve the problem I found on the same GitHub issue from Steve Smith. He's written a blog post about the issues of unit testing the ILogger and suggests creating an adapter for the default ILogger.

public interface ILoggerAdapter<T>
{
    void LogInformation(string message);
    void LogError(Exception ex, string message, params object[] args);
    ...
}
public class LoggerAdapter<T> : ILoggerAdapter<T>
{
    private readonly ILogger<T> _logger;
 
    public LoggerAdapter(ILogger<T> logger)
    {
        _logger = logger;
    }
 
    public void LogError(Exception ex, string message, params object[] args)
    {
        _logger.LogError(ex, message, args);
    }
 
    public void LogInformation(string message)
    {
        _logger.LogInformation(message);
    }
    
    ...
}

The LoggerAdapter will need to be added to DI as well.

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();

    services.AddSingleton(typeof(ILoggerAdapter<>), typeof(LoggerAdapter<>));
}

With this in place I could then change my code as follows.

public class CatalogueController : Controller
{
    private readonly ILoggerAdapter<CatalogueController> _logger;
    private readonly ICatalogueService _catalogueService;

    public CatalogueController(ILoggerAdapter<CatalogueController> logger, ICatalogueService catalogueService)
    {
        _logger = logger;
        _catalogueService = catalogueService;
    }

    [HttpGet("api/catalogue")]
    public async Task<IActionResult> GetActiveStockItemsAsync()
    {
        try
        {
            var stockItems = await _catalogueService.GetActiveStockItemsAsync();

            return Ok(stockItems);
        }
        catch (Exception exception)
        {
            _logger.LogError("Error returning active stock catalogue items", exception);

            return new StatusCodeResult((int)HttpStatusCode.InternalServerError);
        }
  }
public class CatalogueControllerTests
{
    private readonly IFixture _fixture;
    private readonly Mock<ILoggerAdapter<CatalogueController>> _mockLogger;
    private readonly Mock<ICatalogueService> _mockCatalogueService;
    private readonly CatalogueController _catalogueController; 

    public CatalogueControllerTests()
    {
        _fixture = new Fixture();

        _mockLogger = new Mock<ILoggerAdapter<CatalogueController>>();
        _mockCatalogueService = new Mock<ICatalogueService>();
        _catalogueController = new CatalogueController(_mockLogger.Object, _mockCatalogueService.Object);
    }

    [Fact]
    public async Task GetActiveStockItems_LogsErrorAndReturnsInternalServerError_When_ErroOnServer()
    {
        // Arrange
        _mockCatalogueService.Setup(x => x.GetActiveStockItemsAsync()).Throws<Exception>();

        // Act
        var result = await _catalogueController.GetActiveStockItemsAsync();

        // Assert
        _mockLogger.Verify(x => x.LogError("Error returning active stock catalogue items", It.IsAny<Exception>()), Times.Once);
        var errorResult = Assert.IsType<StatusCodeResult>(result);
        Assert.Equal(500, errorResult.StatusCode);
    }
}

Wrapping up

To summarise, there are a couple of ways you can go about unit testing when ILogger is involved.

The first is to verify against the Log method, the downside here is that it may not seem very obvious why you are doing it this way. The second option is to wrap the logger with your own implementation. This allows you to mock and verify methods as normal. But the downside is having to write the extra code to achieve it.

Do you know of any other ways to test the ILogger? If so let me know in the comments.