Streamline .NET 8 Unit Tests: Simplify Logging with FakeLogger
In software development, logging and unit testing are foundational tools that significantly enhance the reliability and functionality of applications. Unit testing quickly identifies changes in business logic, while logging monitors errors, performance metrics, and runtime execution flow. Despite their importance, these tools are often overlooked in many development workflows like validating logging messages. There are several reasons for validating logging messages as explained below.
- Logging provides valuable insights into the application’s behavior and is often the first-place developers look when diagnosing issues. By validating logging messages, you ensure that the correct information is logged at appropriate times. This helps maintain a consistent and useful logging strategy throughout the application’s lifecycle.
- Accurate and informative logging messages make debugging and maintenance significantly easier. When log messages are validated in unit tests, you can be confident that they will provide the necessary context and detail during real-world troubleshooting, thus speeding up the problem-solving process.
- In production environments, logs are a critical component of observability, providing insights into system performance and behavior. Validating logs as part of unit tests ensures that the application emits relevant and actionable log messages, which can be used by monitoring tools to detect anomalies and trigger alerts.
- Logging often reflects key aspects of business logic. By verifying log messages in unit tests, you indirectly confirm that important parts of your business logic are executed correctly. For instance, a successful log message after a payment processing function indicates that the function executed as intended.
- When developers write tests that include log validation, they are more likely to think critically about what should be logged and why. This practice encourages more thoughtful and purposeful logging, which can lead to better overall logging practices in the development team.
- Sometimes, certain failures might not cause the application to crash but still indicate a problem (e.g., a failed attempt to connect to a database). By validating logs, you can catch these silent failures early, as you’ll notice missing or incorrect log entries that would otherwise go unnoticed.
- In some industries, logging is crucial for compliance and auditing purposes. Validating log messages as part of unit tests ensures that the application consistently logs the necessary information to meet regulatory requirements.
- Logs serve as a form of documentation, explaining what the application is doing at any given time. By ensuring these logs are accurate and comprehensive through unit tests, you improve communication among team members, as the logs provide a reliable source of truth about the application’s behavior.
First let us see how we can verify logs with out using FakeLogger. For that let us take below class as example for writing unit tests:
using BusinessLogic.Interfaces;
using Microsoft.Extensions.Logging;
namespace BusinessLogic
{
public class OrderManager : IOrderManager
{
private readonly IOrderRepository _orderRepository;
private readonly IExternalCustomerApiClient _externalCustomerApiClient;
private readonly ILogger<OrderManager> _logger;
public OrderManager(IOrderRepository orderRepository,
IExternalCustomerApiClient externalCustomerApiClient,
ILogger<OrderManager> logger)
{
_orderRepository = orderRepository;
_externalCustomerApiClient = externalCustomerApiClient;
_logger = logger;
}
public async Task<Guid?> CreateOrderAsync(string email, string productName, int productQuantity)
{
try
{
_logger.LogDebug($"CreateOrderAsync method called using customer email: {email} for product: {productName} with quantity: {productQuantity}");
var customer = await _externalCustomerApiClient.GetCustomerAsync(email);
if (customer == null)
{
_logger.LogWarning("No customer found for email provided.");
return null;
}
var orderId = await _orderRepository.CreateOrderAsync(customer.CustomerId, productName, productQuantity);
if(orderId == null)
{
_logger.LogWarning("Order not created, please check logs for more details.");
}
return orderId;
}
catch (Exception ex)
{
_logger.LogWarning($"CreateOrderAsync got error: {ex.ToString()}");
throw;
}
}
}
}
Now for verifying the logging messages we need to mock the logger and verify as shown below:
using BusinessLogic;
using BusinessLogic.Interfaces;
using BusinessLogic.Models;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
namespace BusinessLogicTests
{
[TestFixture]
public class OrderManagerTestsWithoutFakeLogger
{
private Mock<IExternalCustomerApiClient> _customerApiClientMock;
private Mock<IOrderRepository> _orderRepositoryMock;
private Mock<ILogger<OrderManager>> _loggerMock;
private IOrderManager _orderManager;
[SetUp]
public void Setup()
{
_customerApiClientMock = new Mock<IExternalCustomerApiClient>(MockBehavior.Default);
_orderRepositoryMock = new Mock<IOrderRepository>(MockBehavior.Default);
_loggerMock = new Mock<ILogger<OrderManager>>(MockBehavior.Default);
_orderManager = new OrderManager(_orderRepositoryMock.Object, _customerApiClientMock.Object, _loggerMock.Object);
}
[Test]
public async Task OrderManager_Should_Logwarning_If_Customer_NotFound()
{
_customerApiClientMock.Setup(x => x.GetCustomerAsync(It.IsAny<string>())).ReturnsAsync((Customer?)null);
var orderId = await _orderManager.CreateOrderAsync("test@gmail.com", "Iphone", 2);
orderId.Should().BeNull();
_loggerMock.Verify(logger => logger.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((o, t) => o.ToString().Contains("No customer found")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once());
}
}
}
While mock objects enable us to test logging lines, setting up assertions demands careful effort and attention. Also, if there are multiple log statements then there is no way we can validate the order and all of them with out too much complex coding. This is where FakeLogger introduced as part of .NET 8 comes to the rescue.
What is FakeLogger?
Introduced with .NET 8.0, FakeLogger functions as an in-memory log provider for unit tests. It offers an alternative to traditional mock object solutions by enabling the testing of log records. With its built-in methods and features, we can easily perform logging operations in our unit tests, ensuring that our logs meet the application’s requirements effectively. FakeLogger provides three essential properties that are crucial for optimizing logging procedures during software testing:
- Collector: This property lets you see all the log messages that were collected during your tests. You can retrieve and examine these logs to understand what happened during test execution.
- LatestRecord: This property gives you access to the most recent log message captured during testing. It’s useful for quickly checking the latest log entry.
- Category: This property shows the logger category that you set when you initialized the FakeLogger. It helps identify the source of the logs.
For setting the FakeLogger, we need to add Microsoft.Extensions.Diagnostics.Testing package to test project using below command:
dotnet add package Microsoft.Extensions.Diagnostics.Testing
Now let us look at how FakeLogger makes above unit test so simple:
using BusinessLogic.Interfaces;
using BusinessLogic.Models;
using BusinessLogic;
using Microsoft.Extensions.Logging;
using Moq;
using FluentAssertions;
using Microsoft.Extensions.Logging.Testing;
namespace BusinessLogicTests
{
[TestFixture]
public class OrderManagerTestsWithFakeLogger
{
private Mock<IExternalCustomerApiClient> _customerApiClientMock;
private Mock<IOrderRepository> _orderRepositoryMock;
private FakeLogger<OrderManager> _fakeLogger;
private IOrderManager _orderManager;
[SetUp]
public void Setup()
{
_customerApiClientMock = new Mock<IExternalCustomerApiClient>(MockBehavior.Default);
_orderRepositoryMock = new Mock<IOrderRepository>(MockBehavior.Default);
_fakeLogger = new FakeLogger<OrderManager>();
_orderManager = new OrderManager(_orderRepositoryMock.Object, _customerApiClientMock.Object, _fakeLogger);
}
[Test]
public async Task OrderManager_Should_Logwarning_If_Customer_NotFound()
{
_customerApiClientMock.Setup(x => x.GetCustomerAsync(It.IsAny<string>())).ReturnsAsync((Customer?)null);
var orderId = await _orderManager.CreateOrderAsync("test@gmail.com", "Iphone", 2);
_fakeLogger.Collector.LatestRecord.Should().NotBeNull();
_fakeLogger.Collector.LatestRecord.Message.Should().Be("No customer found for email provided.");
_fakeLogger.Collector.LatestRecord.Level.Should().Be(LogLevel.Warning);
_fakeLogger.Collector.Count.Should().Be(2);
}
}
}
Customizing FakeLogger
In above example we have seen how to setup FakeLogger, but FakeLogger has some many other options which provides flexibility for validating the logs in unit tests. FakeLogger provides FakeLogCollectorOptions class for customizing logs. Some of the examples are:
- Write logs to output using outputsink.
- Set FilteredLevels which filters the logs to specified LogLevels.
- We can enable logging for disabled log levels as well in unit tests.
Below is example of how the code will look:
[Test]
public async Task OrderManager_Should_Logerror_In_Case_Exception()
{
var options = new FakeLogCollectorOptions()
{
//We can override diabled log levels and collect them
CollectRecordsForDisabledLogLevels = true,
//Write the log messages to console
OutputSink = Console.WriteLine
};
//Filter to certain levels for validation
options.FilteredLevels.Add(LogLevel.Error);
options.FilteredLevels.Add(LogLevel.Warning);
var collection = FakeLogCollector.Create(options);
var fakeLogger = new FakeLogger<OrderManager>(collection);
_orderManager = new OrderManager(_orderRepositoryMock.Object, _customerApiClientMock.Object, fakeLogger);
_customerApiClientMock.Setup(x => x.GetCustomerAsync(It.IsAny<string>())).ReturnsAsync(new Customer() {
CustomerId = 1212,
Email = "test@gmail.com",
FirstName = "FN",
LastName = "LN"
});
_orderRepositoryMock.Setup(x => x.CreateOrderAsync(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<int>())).ReturnsAsync((Guid?)null);
var orderId = await _orderManager.CreateOrderAsync("test@gmail.com", "Iphone", 2);
fakeLogger.Collector.LatestRecord.Should().NotBeNull();
fakeLogger.Collector.LatestRecord.Message.Should().Be("Order not created, please check logs for more details.");
fakeLogger.Collector.LatestRecord.Level.Should().Be(LogLevel.Warning);
//Since we filtered to just warning you will only get 1 record
fakeLogger.Collector.Count.Should().Be(1);
}
Sample of the logs being written to output:
This article explains the importance of including logging in unit tests and provides guidance on how to achieve this. We begin by examining the traditional Mock.Verify() method for testing logging logic. While effective, this method can be complex and difficult to manage. We then introduced FakeLogger, a new feature in .NET 8.0, and provided examples demonstrating its capabilities and how it can be used to test logging code more efficiently.
Source code for this blog can be found below:
🙏Thanks for taking the time to read the article. If you found it helpful and would like to show support, please consider:
- 👏👏👏👏👏👏Clap for the story and bookmark for future reference
- Follow me on Chaitanya (Chey) Penmetsa for more content
- Stay connected on LinkedIn.
Wishing you a happy learning journey 📈, and I look forward to sharing new articles with you soon.