DEV Community

Cover image for Better ILogger testing in .NET
Eelco Los
Eelco Los

Posted on

Better ILogger testing in .NET

Logging in .NET is solid in production, but when it comes to unit and integration tests… things get messy.
I’ve often found myself skipping over logger checks because mocking ILogger<T> felt brittle or just not worth the effort.

But here’s the thing: logs aren’t just background noise. They’re often the first place you look when something goes wrong in production. If the logging isn’t there, or if it’s wrong, you’ll notice it the hard way.

In this post, I’ll walk through:

  • Why it’s actually worth checking logs in tests
  • Why mocking loggers is frustrating
  • How FakeLogger<T> makes this easy, with examples you can drop straight into your tests (Nuget)

Why check logging?

Logs are your safety net. They give you visibility into what your app was doing when something went sideways. Without them, debugging turns into guesswork.

A few reasons to test your logs:

  • Catch silly mistakes early, like logging the wrong variable, or writing unreadable object dumps, like [Object object].
  • Make sure log levels make sense. LogError vs LogDebug matters when monitoring and alerting are wired up.
  • Keep operational contracts intact. Logs are consumed by dashboards, pipelines, and alerting systems. Breaking them silently can hurt in production.

So while it’s tempting to skip logging checks in tests, a little validation here goes a long way.

Mocking ILogger<T> feels awkward

If you’ve ever tried mocking ILogger with Moq, NSubstitute, or FakeItEasy, you probably know the pain.

For example, to me, FakeItEasy states: if you use A.Fake on any interface, it'll create something usable. However, when using

var logger = A.Fake<ILogger<DemoClass>>();
Enter fullscreen mode Exit fullscreen mode

And then try and run it, it'll show:

FakeItEasy error output
Failed to create fake of type Microsoft.Extensions.Logging.ILogger`1:
    No usable default constructor was found on the type Microsoft.Extensions.Logging.ILogger`1.
    An exception of type System.ArgumentException was caught during this call. Its message was:
    Can not create proxy for type Microsoft.Extensions.Logging.ILogger`1 because the target type is not accessible. 
    Make it public, or internal and mark your assembly with 
    [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=...")] 
    attribute, because assembly Microsoft.Extensions.Logging.Abstractions is strong-named. 
    (Parameter 'additionalInterfacesToProxy')
Enter fullscreen mode Exit fullscreen mode

Another way is shown at Moq (from https://www.freecodecamp.org/news/how-to-use-fakelogger-to-make-testing-easier-in-net/ ):

Moq NotSupported example
// Arrange
        var mockLogger = new Mock<ILogger>();

        // pass the mockedLogger to our service
        var orderService = new OrderService(
            mockLogger.Object, 
            new Mock<IInvoiceService>().Object
        );

        var customerId = Guid.NewGuid();
        var order = new Order
        {
            ID = Guid.NewGuid(),
            CustomerId = customerId,
            Products = [new Product { ID = Guid.NewGuid(), Name = "Ping pong balls", Price = 1.00M }],
            OrderDate = default,
        };

        // Act
        orderService.ProcessOrder(order);      

        // Assert
        mockLogger.Verify(x => x.LogInformation("Processing order..."), Times.Once);
        mockLogger.Verify(x => x.LogInformation("Order processed successfully."), Times.Once);
    }
Enter fullscreen mode Exit fullscreen mode

which shows

 System.NotSupportedException: 
Unsupported expression: x => x.LogInformation("Processing order...", new[] {  })
Enter fullscreen mode Exit fullscreen mode

Introducing FakeLogger<T>

Microsoft recognized how painful logging tests could be in real-world services. Mocking ILogger<T> often led to brittle tests or convoluted setups. To address this, they introduced FakeLogger<T> in .NET 8, as part of the testing-friendly tooling described in “Fake It Til You Make It…To Production”.

FakeLogger<T> is tiny, test-oriented, and captures logs in memory. You can assert log messages, levels, exceptions, and even structured state without relying on Moq, FakeItEasy, or reflection hacks.

Here’s a minimal example:

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using Xunit;

public class MyServiceTests
{
    [Fact]
    public void DoWork_LogsExpectedMessage()
    {
        // Arrange: create a FakeLogger for MyService
        var fakeLogger = new FakeLogger<MyService>();

        var service = new MyService(fakeLogger); // inject logger into service

        // Act
        service.DoWork();

        // Assert: verify log message
        var record = fakeLogger.LatestRecord;
        Assert.NotNull(record);
        Assert.Equal(LogLevel.Information, record.LogLevel);
        Assert.Contains("Work done", record.Message);
    }
}
Enter fullscreen mode Exit fullscreen mode

With FakeLogger<T>, you can quickly verify your logs without wrestling with generic delegates or fragile mock setups. For more complex scenarios, it also exposes all captured LogRecords, which is handy for asserting multiple messages or structured logging.

Full integration-test example (xUnit + WebApplicationFactory)

Here’s a complete integration test that:

  1. Creates a FakeLogger<T> for the category T you care about (replace MyService with the concrete type used as ILogger<T> in your app).
  2. Injects that instance into the test host via WithWebHostBuilder.
  3. Calls an endpoint.
  4. Retrieves the registered logger from the test host and asserts using LatestRecord.
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using Xunit;

// Replace `Program` with the class that defines your app's host entry (top-level Program in minimal APIs)
public class BudgetIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public BudgetIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task GetBudget_ReturnsFile_AndLogsMessage()
    {
        // Replace `MyService` with the concrete type T used in ILogger<T>
        var fakeLogger = new FakeLogger<MyService>();

        // Create a factory that injects our fake logger into DI
        var factory = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Remove any existing ILogger<MyService> registration (if present)
                var existing = services.SingleOrDefault(d => d.ServiceType == typeof(ILogger<MyService>));
                if (existing != null) services.Remove(existing);

                // Register our fake logger as the ILogger<MyService> implementation
                services.AddSingleton<ILogger<MyService>>((ILogger<MyService>)fakeLogger);
            });
        });

        // Create client and call the endpoint that triggers the log
        using var client = factory.CreateClient();
        var response = await client.GetAsync("/api/budget/download"); // adjust path to your endpoint
        response.EnsureSuccessStatusCode();

        // Retrieve the registered logger from the test host and assert
        var logger = (FakeLogger<MyService>)factory.Services.GetRequiredService<ILogger<MyService>>();
        logger.LatestRecord.Should().NotBeNull();
        logger.LatestRecord.Message.Should().Contain("Returning budget file in");
    }
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • MyService should be replaced with the type used when your code calls logger.LogInformation(...) or similar (often the service or controller class).
  • If the app registers loggers differently (or uses factory-style providers), removing the existing descriptor ensures your fake gets picked up.
  • LatestRecord will be null if nothing was logged. Use assertions accordingly.

Wrap-up

ILogger<T> is fantastic for production observability, but not designed to be easily asserted in tests. Instead of wrestling with Log<TState> signatures or brittle mock setups, FakeLogger<T> gives you a simple, inspectable test surface and keeps tests readable.

I added a small FakeLogger demo that shows how to capture and assert ILogger output
from a .NET service in unit tests. See the code and run the tests locally:

Run locally (PowerShell)

dotnet test .\apps\fakelogger-demo.Test\fakelogger-demo.Test.csproj
Enter fullscreen mode Exit fullscreen mode

Happy testing! 🚀

Top comments (0)