This content originally appeared on DEV Community and was authored by Eelco Los
In the first post we focused on FakeLogger<T>
for simple, targeted logger assertions.
This follow-up goes a layer deeper: capturing all logging via FakeLoggerProvider
, optionally wiring it into an ILoggerFactory
, and asserting over snapshots (not just the latest record). This is especially useful when:
- Multiple categories log during a request
- Ordering matters (e.g. warning → error path)
- You want to assert absence or presence of specific patterns
- You’re testing pipeline-style frameworks (here: FastEndpoints)
All examples use .NET 8+
and Microsoft.Extensions.Diagnostics.Testing
.
When to use FakeLoggerProvider instead of FakeLogger
Need | Use |
---|---|
Just assert one logger category | FakeLogger<T> |
Observe everything (multiple categories) | FakeLoggerProvider |
Need a factory (framework constructs loggers) |
ILoggerFactory + provider |
Query all log records with LINQ | provider.Collector.GetSnapshot() |
FakeLoggerProvider
acts like any other logging provider. It gathers every LogRecord
. You can get:
-
Collector.LatestRecord
(single) -
Collector.GetSnapshot()
(immutable list) - Structured state (
record.StateValues
) - Exceptions (
record.Exception
) - Scopes (
record.Scopes
)
Minimal example (provider + factory)
// FastEndpoints test sample
// NuGet: Microsoft.Extensions.Diagnostics.Testing
using FastEndpoints;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using Xunit;
namespace Demo.LoggingTests;
public class GetThingEndpoint : EndpointWithoutRequest<string>
{
private readonly ILogger<GetThingEndpoint> _log;
public GetThingEndpoint(ILogger<GetThingEndpoint> log) => _log = log;
public override void Configure() => Get("/api/things/{id}");
public override Task HandleAsync(CancellationToken ct)
{
var id = Route<string>("id");
if (string.IsNullOrWhiteSpace(id))
{
_log.LogWarning("Empty id provided");
ThrowError("Invalid id");
}
_log.LogInformation("Fetching thing {ThingId}", id);
_log.LogDebug("Repository call starting");
_log.LogInformation("Returning thing {ThingId}", id);
return SendAsync(id!, cancellation: ct);
}
}
public class GetThingEndpointTests
{
[Fact]
public async Task Logs_All_Expected_Messages_In_Order()
{
var provider = new FakeLoggerProvider();
var ep = Factory.Create<GetThingEndpoint>(svc =>
svc.AddTestServices(services =>
{
services.AddSingleton<ILoggerProvider>(provider);
// If the framework builds loggers via ILoggerFactory, supply one:
services.AddSingleton<ILoggerFactory>(sp =>
new LoggerFactory([sp.GetRequiredService<ILoggerProvider>()]));
}));
await ep.HandleAsync("ABC123", CancellationToken.None);
var records = provider.Collector.GetSnapshot()
.Where(r => r.CategoryName == typeof(GetThingEndpoint).FullName)
.OrderBy(r => r.Timestamp)
.ToList();
Assert.Collection(records,
r => Assert.Equal(LogLevel.Information, r.Level),
r => Assert.Equal(LogLevel.Debug, r.Level),
r => Assert.Equal(LogLevel.Information, r.Level));
Assert.Contains(records, r => r.Message.Contains("Returning thing"));
var structured = records.First(r => r.Message.StartsWith("Fetching thing"));
Assert.Contains(structured.StateValues, kv => kv.Key == "ThingId" && (string?)kv.Value == "ABC123");
}
}
Key points:
- We register
FakeLoggerProvider
asILoggerProvider
. - We create a concrete
LoggerFactory
so anything resolvingILogger<T>
via factory still flows through our provider. -
Collector.GetSnapshot()
gives an immutable list—safe to query multiple times.
Using LatestRecord vs GetSnapshot
var last = provider.Collector.LatestRecord;
Assert.NotNull(last);
Assert.Equal(LogLevel.Information, last.Level);
// Full list (ordered by arrival)
var all = provider.Collector.GetSnapshot();
var warnings = all.Where(r => r.Level == LogLevel.Warning).ToList();
Use LatestRecord
when you just expect one or want a quick “something logged” assertion. Prefer snapshots plus LINQ for clarity when order or filtering matters.
Handling no logs (expected or defensive)
FakeLoggerProvider.Collector.LatestRecord
throws InvalidOperationException
if nothing was logged yet. Catch/Assert when verifying absence:
var ex = Assert.Throws<InvalidOperationException>(() =>
{
var _ = provider.Collector.LatestRecord;
});
Assert.Equal("No records logged.", ex.Message);
Or simply:
Assert.Empty(provider.Collector.GetSnapshot());
Asserting warnings for not-found cases (mirroring the example)
From the original test style:
var warning = provider.Collector
.GetSnapshot()
.FirstOrDefault(r => r.Level == LogLevel.Warning &&
r.Message == $"Unable to find user by name {username}");
Assert.NotNull(warning);
Structured state and scopes
var rec = provider.Collector.GetSnapshot()
.Single(r => r.Message.StartsWith("Fetching thing"));
var thingId = rec.StateValues.Single(kv => kv.Key == "ThingId").Value;
Assert.Equal("ABC123", thingId);
// Scopes (if code used BeginScope)
foreach (var scope in rec.Scopes)
{
// scope is a formatted scope string or key/value pair sequence
}
When you must register ILoggerFactory
Some frameworks (or helper libs) explicitly request ILoggerFactory
. If you only register ILoggerProvider
, they create their own factory and your provider may be skipped. Supplying:
services.AddSingleton<ILoggerFactory>(sp =>
new LoggerFactory([sp.GetRequiredService<ILoggerProvider>()]));
ensures a single pipeline. (If you add multiple providers, pass them all in the array.)
I often start with FakeLogger<T>
and switch to provider + factory the moment I need to assert more than one message or category.
Common pitfalls
Pitfall | Fix |
---|---|
No logs captured | Ensure provider registered before endpoint created |
LatestRecord throws | Use GetSnapshot() for empties |
Wrong category filtered | Compare record.CategoryName to typeof(TheType).FullName
|
Structured value missing | Ensure you used named template: LogInformation(“Id {ThingId}”, id) |
Quick FastEndpoints factory helper (optional)
public static class LoggingTestSetup
{
public static (TEndpoint endpoint, FakeLoggerProvider provider) CreateWithLogging<TEndpoint>()
where TEndpoint : class, IEndpoint
{
var provider = new FakeLoggerProvider();
var endpoint = Factory.Create<TEndpoint>(svc =>
svc.AddTestServices(s =>
{
s.AddSingleton<ILoggerProvider>(provider);
s.AddSingleton<ILoggerFactory>(sp =>
new LoggerFactory([sp.GetRequiredService<ILoggerProvider>()]));
}));
return (endpoint, provider);
}
}
Wrap-up
FakeLoggerProvider
elevates log testing from “one-off assert” to “full pipeline visibility.” Pairing it with an explicit ILoggerFactory
gives deterministic, framework-friendly logging in test hosts (including FastEndpoints). Use it when you outgrow the single-category simplicity of FakeLogger<T>
.
Happy testing.
This content originally appeared on DEV Community and was authored by Eelco Los