We commonly write unit tests to test the business logic of our application. But what about the times we also want to make sure our logging works as expected? In this article, we will cover both cases and learn how to unit test ILogger in ASP.NET Core applications.
Let’s start.
Why Test ILogger?
In general, logging allows us to track and record important events and data during the execution of our application. This can help us troubleshoot, debug, and monitor our application’s health and performance. Also, it can help us collect useful metrics and properties depending on our needs.
Given the variety of functions that logging can perform in our applications, and all the helpful insights it can provide us with, let’s set up a project and see how to test it!
Testing ILogger
In general, there are a couple of ways to test ILogger in ASP.NET Core applications. Some of the most common ones are to use mocked ILogger, to use a real instance of ILogger, or test it with NullLogger.
Project Setup
We will start by creating an ASP.NET Core API project. This project template comes with a WeatherForecastController
class by default.
To have more variety in our logging, let’s extend the default controller with a few methods of our own:
[HttpGet("[action]")] public IEnumerable<WeatherForecast> GetForecastInfo() { _logger.LogInformation("WeatherForecastController: Severity - Information"); return Enumerable .Range(1, 5) .Select(index => new WeatherForecast { Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index).Date), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] } ).ToArray(); } [HttpGet("[action]")] public IEnumerable<WeatherForecast> GetForecastError() { _logger.LogError("WeatherForecastController: Severity - Error"); return Enumerable .Range(1, 5) .Select(index => new WeatherForecast { Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] } ).ToArray(); }
We create two methods that return weather forecast data. Each method returns an IEnumberable
of WeatherForecast
and writes a log message with a different severity level. In our case, we have two severity levels – information and error.
In our methods, we are calling LogInformation
and LogError
, which are extension methods of ILogger
. Under the hood, these methods call the Log
method which can accept multiple parameters:
/// <summary> /// Writes a log entry. /// </summary> /// ... void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter);
To keep things simple we provide only the log level and message to be logged. Because the message can be found in the state parameter, we verify both logLevel
and state
in the assert section of our tests.
Now that we have our methods and know what we want to test, we are ready to jump into testing ILogger
. Before we begin writing tests, we’ll add a new XUnit
project to our solution.
Unit Test ILogger Using Mocked Logger
The first approach we take to test ILogger is to mock an instance of the logger. To set everything up, we need to install the Moq package from NuGet. We do this using the following command in the Package Manager Console:
Install-Package Moq
In general, Moq is an open-source mocking library that allows us to create mock implementations of interfaces or classes. By creating mocked objects, we can test our code in isolation, without any external dependencies. In addition, Moq also provides a way to verify that certain methods have been called on the mock object during the test. This is useful for ensuring that the code being tested is interacting with the mock object correctly.
Once we have installed Moq, we use it to create a mock ILogger
object in our unit tests:
private readonly WeatherForecastController _forecastController; private readonly Mock<ILogger<WeatherForecastController>> _mockLogger; public MockedLoggerTests() { _mockLogger = new Mock<ILogger<WeatherForecastController>>(); _forecastController = new WeatherForecastController(_mockLogger.Object); }
To begin testing the logging functionality, we first instantiate a WeatherForecastController
. We create a mock ILogger<WeatherForecastController>
using the Mock<ILogger<WeatherForecastController>>
class. This mock object simulates the behavior of a real logger instance without actually logging into any external resource. Once we have our logger, we pass it as an argument to the constructor of the WeatherForecastController
object.
Now, we can add our test methods:
[Fact] public void WhenGettingForecastInfo_ThenLogsInformationLog() { //Act var response = _forecastController.GetForecastInfo(); //Assert Assert.NotEmpty(response); _mockLogger.Verify(l => l.Log(LogLevel.Information, It.IsAny<EventId>(), It.Is<It.IsAnyType>((v, _) => v.ToString().Contains("Info")), It.IsAny<Exception>(), It.IsAny<Func<It.IsAnyType, Exception, string>>() ),Times.Once ); } [Fact] public void WhenGettingForecastError_ThenLogsErrorLog() { //Act var response = _forecastController.GetForecastError(); //Assert Assert.NotEmpty(response); _mockLogger.Verify(l => l.Log(LogLevel.Error, It.IsAny<EventId>(), It.Is<It.IsAnyType>((v, _) => v.ToString().Contains("Error")), It.IsAny<Exception>(), It.IsAny<Func<It.IsAnyType, Exception, string>>() ),Times.Once ); }
In each test, we call the corresponding method that logs the desired information. In the Assert section, we verify that the log method was called once with the correct log level and that it contains the correct message. Using the Verify
method we can also test additional parameters that can be provided to the Log
method. For the parameters that we didn’t provide, we use It.IsAny<T>
to define that any value is acceptable for them, making sure our test passes.
Unit Test ILogger Using a Real Logger Instance
Another way to test ILogger is to create a real instance of it. To begin, we first install the extension logging package using the Package Manager Console:
Install-Package Microsoft.Extensions.Logging
Now we can take a look at how to test it:
private readonly ILogger<WeatherForecastController> _logger; private readonly WeatherForecastController _controller; private readonly StringWriter _stringWriter; public RealInstanceOfILoggerTest() { _stringWriter = new StringWriter(); Console.SetOut(_stringWriter); using var loggerFactory = LoggerFactory.Create(c => c.AddConsole()); _logger = loggerFactory.CreateLogger<WeatherForecastController>(); _controller = new WeatherForecastController(_logger); }
To verify that we have the correct logs after the method is called, we check the console output after our method is called. To make console output available, we create a new StringWriter
named _stringWriter
. Then, we redirect the console output stream to _stringWriter
by using Console.SetOut(_stringWriter)
.
Because we are using a real instance of the logger, we create a logger factory and configure it to output logs to the console using the AddConsole()
method. Then, we create an instance of ILogger<WeatherForecastController>
using the logger factory’s CreateLogger
method.
Now, we can add our test methods:
[Fact] public void WhenGettingForecastInfo_ThenLogsInformationLog() { //Act var response = _controller.GetForecastInfo(); //Assert var logMessage = _stringWriter.ToString(); Assert.NotEmpty(response); Assert.Contains("WeatherForecastController: Severity - Information", logMessage); } [Fact] public void WhenGettingForecastError_ThenLogsErrorLog() { var response = _controller.GetForecastError(); //Assert var logMessage = _stringWriter.ToString(); Assert.NotEmpty(response); Assert.Contains("WeatherForecastController: Severity - Error", logMessage); }
In the Assert
section of the test, we get the captured console output using _stringWriter.ToString()
. Lastly, we verify our logs are contained in the captured console output.
What is important to note, is that writing to the console during tests can have drawbacks. We can have difficulties parsing the output, interference with test results, or slower test performance.
Unit Test ILogger Using NullLogger
As we mentioned at the beginning of the article, there are some use cases in which we don’t want to test the actual logging, but have a logger dependency in the class we want to test. In those cases, the easiest solution is to use NullLogger
.
For it, we need to install Microsoft.Extensions.Logging.Abstractions
package:
Install-Package Microsoft.Extensions.Logging.Abstractions
Now, instead of using mocked or a real instance of the logger, we create a new instance of NullLogger
inside the NullLoggerTest
class:
private readonly WeatherForecastController _forecastController; public NullLoggerTest() { var logger = new NullLogger <WeatherForecastController>(); _forecastController = new WeatherForecastController(logger); }
As in previous tests, we pass a logger instance into the WeatherForecastController
constructor. NullLogger
is a minimalistic logger that will discard the log messages and not log them anywhere. Therefore, when we use a null logger we cannot test the actual logging, but it is a useful and simple approach in situations where we want to skip actual logging and instead test other behaviors of methods.
When to Use Which Testing Option?
We have seen three possible approaches to testing ILogger
. The choice between using a mocked logger, a real logger instance, or a null logger depends on our specific use case and what we want to test. There are some general guidelines of when to use which approach.
If we want to suppress logging during unit tests and don’t need to test logging behavior, using NullLogger
is probably the best choice. It is straightforward to set up, and when testing, it will discard logging completely.
On the other hand, if we wish to test actual logging we have two possible options; to use a mocked logger or a real instance. If we want to isolate testing from the logging infrastructure, and not write to any external resource, we can use Mocked logger. One additional benefit is that we have a built-in Verify
method, so it is easy to validate the logging parameters that we used. A potential problem with using mocked logger is that we cannot fully replicate the behavior of a real logger. This can lead to not catching all potential issues related to actual logging, like configuration problems, or formatting issues.
Using a real instance of a logger is suitable if we are trying to test a real logging setup and check that log messages are being written to the console, log file, or any other external destination. One drawback of this setup is that it is not so easy to test logging levels and other parameters. Also, writing to external resources during the execution of unit tests can potentially slow down test execution.
Conclusion
In this article, we touched upon the importance of logging in our applications and we learned different ways we can set it up and test it. Also, we learned about the situations in which we only want to test the business logic of our code, and the easiest way to create a logger instance in that case. Lastly, we compared the various approaches and learned the pros and cons of each one.