Using FakeLoggerProvider (and ILoggerFactory) in FastEndpoints



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 as ILoggerProvider.
  • We create a concrete LoggerFactory so anything resolving ILogger<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