In software development, logging, and unit testing serve as foundational tools, each actively contributing to the reliability and functionality of our applications. While unit testing promptly alerts us to changes in our application’s business logic, logging actively monitors errors, performance metrics, and runtime execution flow. Despite their significance, many development workflows often overlook their incorporation.

Today, we aim to delve deeper into this topic, emphasizing the importance of integrating logging into our unit tests. Throughout this article, we will explore the use of FakeLogger to test logging code, showcasing its practical usage and how it effectively verifies the logging behavior in our ASP.NET projects.

To download the source code for this article, you can visit our GitHub repository.

So let’s dive in.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

A Classical Approach To Test Logging

Implementing unit tests enhances the reliability of our code and enables accurate replication of every scenario our business logic should handle. We use fixtures to generate input values and utilize mock services or repositories to control the responses returned when service or repository methods are invoked. This approach guarantees that our code behaves as expected, empowering us to build our application with confidence and robustness.

In our development workflow, there are situations where we need to log the status of the process to determine whether an error has occurred or to log the result returned from any service call. We encounter scenarios where our business logic might not yield a meaningful response, perhaps merely returning void, or where a certain condition within our method logs the operation before continuing with execution. In such cases, to construct thorough unit tests that cover all lines of code and potential scenarios, we may need to develop tests specifically targeting logging scenarios.

Logging Preparation

Let’s take a look at how we can test our logs in a classical approach. First, we prepare a sample file read logic that logs some information:

public class FileManager(ILogger logger)
{
    public string ReadFile(string fileName)
    {
        try
        {
            logger.LogInformation("Reading file: {FileName}", fileName);

            var extension = Path.GetExtension(fileName);
            var allowedExtensions = new string[] { ".pdf", ".txt" };

            if (!allowedExtensions.Contains(extension))
            {
                logger.LogWarning("Invalid extension");
                throw new ArgumentException("Invalid file extension");
            }

            var filePath = Path.Combine("./files/", fileName);
            if (File.Exists(filePath))
            {
                return File.ReadAllText(filePath);
            }
            else
            {
                throw new FileNotFoundException("File not found.");
            }
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error occurred");
            return string.Empty;
        }
    }
}

Here, we introduce the FileManager class, featuring a straightforward ReadFile() method designed for demonstration purposes. In this method, we first validate whether the file extension is allowed. If it’s not, we issue a warning message via logging. Subsequently, we attempt to access and read the file from the local directory. If the file is located and successfully read, we return its content. However, if the file is not found, we intentionally raise a FileNotFoundException, triggering the catch block to log the exception as an error.

Testing Logging Logic

Once our logic is ready, we’ll create a unit test to encompass the logging statements. To achieve this, we’ll utilize the Moq library. It is a potent tool employed in .NET development to generate mock objects for unit testing purposes. It allows developers to easily simulate the behavior of dependencies, enabling comprehensive and efficient testing of application components.

Let’s now create a test class:

[TestClass]
public class FileManagerTests
{
    private IFixture _fixture;
    private Mock<ILogger> _loggerMock;
    private FileManager _fileManager;

    [TestInitialize]
    public void Setup()
    {
        _fixture = new Fixture();
        _loggerMock = new Mock<ILogger>();
        _fileManager = new FileManager(_loggerMock.Object);
    }
}

Here, we establish an xUnit test class titled FileManagerTests. Within the Setup() method, we set up a mock object for the logger and initialize our logic class using this logger.

Now, we’ll proceed to develop a test method that utilizes the logger mock:

[TestMethod]
public void WhenReadFileCalled_ForARandomString_ThenInvalidExtensionLogTestMustPass()
{
    var fileName = _fixture.Create<string>();

    var result = _fileManager.ReadFile(fileName);

    Assert.AreEqual(string.Empty, result);
    _loggerMock.Verify(logger => logger.Log(
            LogLevel.Warning,
            It.IsAny<EventId>(),
            It.Is<It.IsAnyType>((o, t) => o.ToString().Contains("Invalid extension")),
            It.IsAny<Exception>(),
            It.IsAny<Func<It.IsAnyType, Exception, string>>()),
        Times.Once());
}

Here, we construct a unit test utilize _loggerMock instance. Since we generate the fileName input through the fixture, we can ensure it does not have a valid extension. We expect our test to proceed and log a warning due to the invalid extension. To confirm this, we invoke the _loggerMock.Verify() method in the assertion section, specifying that the Log() method of the logger dependency will be invoked with a warning level and the message “Invalid extension”.

If you want to delve deeper into writing unit tests for ILogger, you can check out our article How to Unit Test ILogger in ASP.NET Core.

Although mock objects allow us to test logging lines, configuring assertions requires some effort and attention. Therefore, let’s delve into an alternative and pragmatic solution:

Understanding FakeLogger

Introduced with .NET 8.0, FakeLogger operates as an in-memory log provider for unit tests, offering an alternative to traditional mock object solutions by enabling us to test log records. With its built-in methods and features, we can now effortlessly perform logging operations in our unit tests. This ensures that our logs effectively meet the requirements of our application.

FakeLogger Properties

FakeLogger offers three essential properties crucial for optimizing logging procedures during software testing:

NameDescription
CollectorIt allows us to access the log messages collected during testing. We can retrieve and inspect the log messages that were generated during the test execution.
LatestRecord It provides access to the most recent log message collected during testing.
CategoryThe logger category that we provide during initialization

We’ll see these properties in action shortly.

Setting Up FakeLogger

To use FakeLogger we need to include the Microsoft.Extensions.Diagnostics.Testing package:

dotnet add package Microsoft.Extensions.Diagnostics.Testing

Once installed, we can start using FakeLogger in our test projects.

Use FakeLogger to Test Logging Code

We need to adjust our test setup to initialize and utilize FakeLogger. Although we can initialize and use FakeLogger separately within each unit test, if we want to use a single instance across all tests within our test class, we should initialize our global instance in the Setup() method:

private FakeLogger<FileManager> _fakeLogger;
private FileManager _fileManager2;

[TestInitialize]
public void Setup()
{
    _fakeLogger = new FakeLogger<FileManager&gt;();
    _fileManager2 = new FileManager(_fakeLogger);
}

Here, we create an instance of FakeLogger<FileManager>, named _fakeLogger. Subsequently, we instantiate a new FileManager object utilizing the _fakeLogger instance.

Let’s proceed to establish a second unit test utilizing FakeLogger:

[TestMethod]
public void WhenReadFileCalled_WithFakeLogger_ForARandomString_ThenInvalidExtensionLogTestMustPass()
{
    var fileName = _fixture.Create<string>();

    var result = _fileManager2.ReadFile(fileName);

    Assert.AreEqual(string.Empty, result);
    Assert.IsNotNull(_fakeLogger.Collector.LatestRecord);
    Assert.AreEqual(1, _fakeLogger.Collector.Count);
    Assert.AreEqual(LogLevel.Warning, _fakeLogger.Collector.LatestRecord.Level);
    Assert.AreSame("Invalid extension", _fakeLogger.Collector.LatestRecord.Message);
}

Here, we have simply modified the assertion section to utilize FakeLogger for validating log messages. Like the previous test, we provide a fileName input with an invalid extension. We ensure that _fakeLogger captures exactly one log message. Additionally, we verify that the latest log message recorded by FakeLogger has a LogLevel of Warning and a message stating “Invalid extension”.

When we compare the two tests, Mock.Verify() is more generic and hard to use. It allows us to set up expectations for method calls on a mock object and then we use Mock.Verify() to ensure that these expectations were met during the test. However, FakeLogger specializes in testing logging behavior and provides specific tools like Collector or LatestRecord for inspecting log messages generated during the test and making assertions based on them.

FakeLogger Options

So far, we’ve created a test scenario for warning logs and seen how FakeLogger simplifies our task. Now, let’s explore how to write a test for error logs when an exception occurs, and in the meantime, discover a few more features of the FakeLogger:

[TestMethod]
public void WhenReadFileCalled_WithFakeLogger_ForValidFilename_ThenErrorOccurredLogTestMustPass()
{
    var fileName = "test-file.pdf";

    var result = _fileManager2.ReadFile(fileName);

    Assert.AreEqual(string.Empty, result);
    Assert.IsNotNull(_fakeLogger.Collector.LatestRecord);
    Assert.AreEqual(2, _fakeLogger.Collector.Count);
    Assert.AreEqual(LogLevel.Error, _fakeLogger.Collector.LatestRecord.Level);
    Assert.AreEqual(LogLevel.Information, _fakeLogger.Collector.GetSnapshot()[0].Level);
    Assert.AreSame("Error occurred", _fakeLogger.Collector.LatestRecord.Message);
}

Here, we establish a fresh unit test. In this test, we provide test-file.pdf as the fileName input, which has a valid extension. Therefore, we anticipate that our informational log will be initially captured by FakeLogger:

logger.LogInformation("Reading file: {FileName}", fileName);

We perform an assertion to verify this informational log:

Assert.AreEqual(LogLevel.Information, _fakeLogger.Collector.GetSnapshot()[0].Level);

Here, by using the GetSnapshot() method of the Collector object, we can retrieve all recorded logs. Therefore, we inspect the first record to confirm whether it has a log level of Information or not. After that, our method ReadFile() will try to read the file with the name test-file.pdf but it will fail and throw an exception since there is no such a file. Thus, we performed assertions to check the error log created:

Assert.AreEqual(2, _fakeLogger.Collector.Count);
Assert.AreEqual(LogLevel.Error, _fakeLogger.Collector.LatestRecord.Level);
Assert.AreSame("Error occurred", _fakeLogger.Collector.LatestRecord.Message);

In this scenario, we’ve checked all log levels such as information and error. 

Customizing Logging Behavior

What if we wanted to track only specific log levels, like error logs, for unit tests?

Let’s exemplify this and use FakeLogger to test the logging code:

[TestMethod]
public void WhenReadFileCalled_WithFakeLoggerOptions_ForValidFilename_ThenErrorOccurredLogTestMustPass()
{
    var options = new FakeLogCollectorOptions()
    {
        CollectRecordsForDisabledLogLevels = true,
        OutputSink = Console.WriteLine
    };
    options.FilteredLevels.Add(LogLevel.Error);

    var collection = FakeLogCollector.Create(options);
    var fakeLogger = new FakeLogger<FileManager>(collection);
    var fileManager = new FileManager(fakeLogger);

    var fileName = "test-file.pdf";

    var result = fileManager.ReadFile(fileName);

    Assert.AreEqual(string.Empty, result);
    Assert.IsNotNull(fakeLogger.Collector.LatestRecord);
    Assert.AreEqual(1, fakeLogger.Collector.Count);
    Assert.AreEqual(LogLevel.Error, fakeLogger.Collector.LatestRecord.Level);
    Assert.AreSame("Error occurred", fakeLogger.Collector.LatestRecord.Message);
}

In this unit test, we focus on examining the behavior of FakeLogger, specifically targeting error logs. To customize the behavior of the log collector, we initialize a FakeLogCollectorOptions object. To ensure that logs for disabled log levels are still collected, we set CollectRecordsForDisabledLogLevels to true, and we designate Console.WriteLine as the output sink for displaying log messages on the console. Adding LogLevel.Error to the list of filtered log levels in the options object indicates our interest solely in error logs. Following this, we proceed to create a FakeLogCollector instance using the options object, followed by instantiating a FakeLogger<FileManager> object with the collection instance as a parameter. Finally, utilizing this logger instance, we conduct the unit test.

Overall, this test illustrates how we can configure FakeLogger to collect logs for specific log levels, allowing us to write unit tests tailored to our logging requirements.

Conclusion

This article explores the importance of integrating logging scenarios into unit tests. It illustrates the methodologies for doing so when testing logging logic. Initially, we examine a traditional approach utilizing the Mock.Verify() strategy.

However, while it offers a solution, this method can be complex to implement and manage when testing logging code. Subsequently, we introduce a novel solution called FakeLogger, introduced with .NET 8.0. We offer examples showcasing the functionalities FakeLogger offers and demonstrate how to use FakeLogger to test logging code.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!