Using NullLogger and a FakeLogger in Your .NET Tests

While I was working on a .NET WebApi Logging middleware component, I realized that I needed some unit tests to verify that the Logging middleware was actually working as intended. I started down the usual path of installing a mocking library in my test project so that I could mock the logger. But then I asked myself, do I really need to so that? And decided to take the road less traveled this time…

NullLogger Class

First, if you need to pass an ILogger<T> into a function call or class constructor, but you don’t need to verify the behavior of the logging done by that function or component, you can just use the NullLogger<T> that is available in .NET. It is part of the Microsoft.Extensions.Logging.Abstractions package. (Until investigating this recently, I didn’t even know this class existed and always used mocking libraries in this situation… silly me).

Now you can write a simple unit test for a class that takes an ILogger as input:

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Threading.Tasks;

[TestClass]
public class MySimpleMiddlewareTests
{
    [TestMethod]
    public async Task InvokeAsync_LogsAndCallsNext()
    {
        // Arrange
        var context = new DefaultHttpContext();
        bool nextCalled = false;

        RequestDelegate next = (HttpContext ctx) =>
        {
            nextCalled = true;
            return Task.CompletedTask;
        };

        var logger = NullLogger<MySimpleMiddleware>.Instance;
        var middleware = new MySimpleMiddleware(next, logger);

        // Act
        await middleware.InvokeAsync(context);

        // Assert
        Assert.IsTrue(nextCalled, "The next middleware delegate should have been called.");
    }
}

In this code, the middleware class takes an ILogger reference that is usually provided by the constructor injection. But in our test, we create an instance with NullLogger<MySimpleMiddleware> and pass it to the constructor. The NullLogger implements the ILogger<T> interface but performs no operations.

That was great in this simple case where I didn’t care what was logged… just that the InvokeAsync worked correctly and called the next on in the chain. And not complex mocking library is necessary in your test project.

But what about in cases where I do care about what is logged. And I want to validate that the logged text is what’s expected?

Created the FakeLogger

Following in the inspiration of NullLogger<T>, I created a simple FakeLogger<T> class. It also has a simple implementation of the ILogger<T> interface. But in FakeLogger, we save the each call to the Log function as a string in the _logs (List<string>) member variable.

using Microsoft.Extensions.Logging;
using System.Diagnostics.CodeAnalysis;

namespace D20Tek.MinimalApi.UnitTests.Fakes;

public class FakeLogger<T> : ILogger<T>
{
    private readonly List<string> _logs = [];

    public IReadOnlyList<string> Logs => _logs;

    public IDisposable BeginScope<TState>(TState state) where TState : notnull
    {
        return NullScope.Instance;
    }

    public void Log<TState>(
        LogLevel logLevel,
        EventId eventId,
        TState state,
        Exception? exception,
        Func<TState, Exception?, string> formatter)
    {
        ArgumentNullException.ThrowIfNull(formatter, nameof(formatter));
        var message = formatter(state, exception);
        _logs.Add($"[{logLevel}] {message}");
    }

    public bool IsEnabled(LogLevel logLevel)
    {
        return true;
    }

    private class NullScope : IDisposable
    {
        public static readonly NullScope Instance = new();

        public void Dispose() { }
    }
}

Then, the unit tests can call the Logs property to retrieve a readonly list of each logged string.

Now, let’s take a look at a unit test that uses the FakeLogger<T> to validate the logging output strings:

    [TestMethod]
    public async Task InvokeAsync_WithLoggingOn_LogsOperationStartAndEnd()
    {
        // arrange
        var options = Options.Create(new DevViewOptions());
        var logger = new FakeLogger<RequestLoggingMiddleware>();
        var middleware = new RequestLoggingMiddleware(_next, logger, options);
        var context = CreateConfiguredRequest();

        // act
        await middleware.InvokeAsync(context);

        // assert
        Assert.AreEqual(2, logger.Logs.Count);
        Assert.IsTrue(logger.Logs.All(x => x.StartsWith("[Information]")));
        Assert.AreEqual("[Information] --> GET /api/test", logger.Logs[0]);
        Assert.IsTrue(logger.Logs[1].StartsWith("[Information] <-- 200"));
    }

    private static HttpContext CreateConfiguredRequest()
    {
        var context = new DefaultHttpContext();

        // Configure HttpRequest
        context.Request.Method = "GET";
        context.Request.Scheme = "https";
        context.Request.Host = new HostString("localhost", 5001);
        context.Request.Path = "/api/test";

        return context;
    }

As we can see from the test, we expect the FakeLogger<RequestLoggingMiddleware> to be called twice because it wraps the NextAsync call with start and end logging statements. We can verify the number of times Log was called and that the two messages are what we expect.

This could also be written using a mocking library, like Moq. But I find this FakeLogger class easier to use because mocking the Log calls and retrieving the data passed to each call requires some complex configuration in Moq. And I don’t need to take a requirement on a large package for just one simple thing.

Conclusion

I just wanted to write this article as a reminder that sometimes .NET already has some default classes (like NullLogger and DefaultHttpContext) to help in your test projects, so that you don’t always have to use mocking libraries. In many cases, those easy defaults are all you need. And I wanted to share my FakeLogger<T> class implementation in case you want some easy logging validation in your own test projects. I know that I will be reusing that class in whatever projects need that sort of validation.

Leave a comment