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
vsLogDebug
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>>();
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')
Another way is shown at Moq (from https://www.freecodecamp.org/news/how-to-use-fakelogger-to-make-testing-easier-in-net/ ):
which showsMoq 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);
}
System.NotSupportedException:
Unsupported expression: x => x.LogInformation("Processing order...", new[] { })
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);
}
}
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 LogRecord
s, which is handy for asserting multiple messages or structured logging.
Full integration-test example (xUnit + WebApplicationFactory)
Here’s a complete integration test that:
- Creates a
FakeLogger<T>
for the categoryT
you care about (replaceMyService
with the concrete type used asILogger<T>
in your app). - Injects that instance into the test host via
WithWebHostBuilder
. - Calls an endpoint.
- 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");
}
}
Notes:
-
MyService
should be replaced with the type used when your code callslogger.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 benull
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:
- Repo: https://github.com/EelcoLos/nx-tinkering
- Implementation & review (merged PR): https://github.com/EelcoLos/nx-tinkering/pull/726
- Exact file (on main): https://github.com/EelcoLos/nx-tinkering/blob/main/apps/fakelogger-demo.Test/MyServiceTests.cs
Run locally (PowerShell)
dotnet test .\apps\fakelogger-demo.Test\fakelogger-demo.Test.csproj
Happy testing! 🚀
Top comments (0)