Test ILogger in .NET the easy way (with an in-memory logger)

Test ILogger in .NET the easy way (with an in-memory logger)

Logging is part of the contract of many components: when things fail, when branches are taken, when work completes. If a class owns logic, it should own its log output too. That makes logs worth testing–especially for diagnostics, reliability, and supportability.

But testing ILogger is often neglected. Mocking libraries can make this awkward because most of the convenience API sits in extension methods (e.g., LogInformation(...)), which delegate down to ILogger.Log(...). You either end up matching on the complex Log<TState> call or bending your tests around the mocking framework.

Here’s a clean alternative: a tiny, dependency-free in-memory logger designed for unit tests.

What a good in-memory logger for tests should provide:

  • Thread-safety: production code might log from multiple threads; tests must be reliable
  • Snapshot API: capture a stable view for assertions without “collection modified” races
  • Useful context: store LogLevel, EventId, Exception, the resolved message
  • Simple: drop-in replacement for ILogger/ILogger<T>

Implementation

  1using System;
  2using System.Collections.Generic;
  3using Microsoft.Extensions.Logging;
  4
  5/// <summary>
  6/// An in-memory, thread-safe <see cref="ILogger"/> implementation intended for unit tests.
  7/// </summary>
  8/// <remarks>
  9/// <para>
 10/// Use this logger when you want to assert that particular messages, event ids or log levels were
 11/// produced by the system under test. The implementation records entries in memory and exposes a
 12/// snapshot API so test code can inspect logs without risking concurrent-modification exceptions.
 13/// </para>
 14/// <para>Design goals:</para>
 15/// <list type="bullet">
 16///   <item><description>Simple to use from tests: instantiate <c>new InMemoryLogger&lt;T&gt;()</c>.</description></item>
 17///   <item><description>Thread-safe append and snapshot operations so test assertions are reliable.</description></item>
 18///   <item><description>Store enough context (LogLevel, EventId, Exception, Message) to make assertions precise.</description></item>
 19/// </list>
 20/// <para>
 21/// Example usage:
 22/// <code>
 23/// var logger = new InMemoryLogger&lt;MyService&gt;();
 24/// var svc = new MyService(logger); // accepts ILogger&lt;MyService&gt;
 25/// svc.Run();
 26/// var snapshot = logger.GetEntriesSnapshot();
 27/// Assert.Contains(snapshot.Select(e => e.Message), m => m.Contains("expected text"));
 28/// </code>
 29/// </para>
 30/// </remarks>
 31public class InMemoryLogger : ILogger
 32{
 33    // Lock used to synchronize writes and snapshot reads. Kept internal to avoid external locking mistakes.
 34    private readonly Lock _lock = new();
 35
 36    /// <summary>
 37    /// The raw recorded log entries. For production code this would be a stream to a log sink; for tests
 38    /// an in-memory list is easier to assert against. Accessing this collection directly from tests is
 39    /// supported but <see cref="GetEntriesSnapshot"/> is the preferred safe option.
 40    /// </summary>
 41    public List<(LogLevel Level, EventId Id, Exception? Ex, string Message)> Entries = [];
 42
 43    /// <summary>
 44    /// Begins a logical operation scope. This implementation does not track scopes; it returns null.
 45    /// The method exists to satisfy the <see cref="ILogger"/> contract used by many Microsoft
 46    /// APIs, and keeps tests simple when the system under test calls <c>BeginScope</c>.
 47    /// </summary>
 48    public IDisposable? BeginScope<TState>(TState state) where TState : notnull => default!;
 49
 50    /// <summary>
 51    /// Always enabled in test scenarios so that assertions can rely on the presence of log lines.
 52    /// </summary>
 53    /// <param name="logLevel">The log level queried.</param>
 54    /// <returns>Always <c>true</c> for the in-memory logger.</returns>
 55    public bool IsEnabled(LogLevel logLevel) => true;
 56
 57    /// <summary>
 58    /// Records a single structured log entry. This method is thread-safe and will append the resolved
 59    /// message produced by the provided <paramref name="formatter"/>.
 60    /// </summary>
 61    /// <typeparam name="TState">The state type provided by the caller.</typeparam>
 62    /// <param name="logLevel">The severity of the log entry.</param>
 63    /// <param name="eventId">Optional event identifier.</param>
 64    /// <param name="state">The structured state object passed to the logger.</param>
 65    /// <param name="exception">Optional exception associated with the log event.</param>
 66    /// <param name="formatter">A function that formats the state and exception into a message string.</param>
 67    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
 68      Func<TState, Exception?, string> formatter)
 69    {
 70        // Keep the append atomic with the lock so snapshots can be taken reliably without race conditions.
 71        lock (_lock)
 72        {
 73            Entries.Add(new(logLevel, eventId, exception, formatter(state, exception)));
 74        }
 75    }
 76
 77    /// <summary>
 78    /// Returns whether a log with the specified level and id was recorded. Uses a lock to avoid races
 79    /// with concurrent writers.
 80    /// </summary>
 81    /// <param name="logLevel">The log level to search for.</param>
 82    /// <param name="id">The event id to search for.</param>
 83    /// <returns><c>true</c> if an entry exists; otherwise <c>false</c>.</returns>
 84    public bool Has(LogLevel logLevel, EventId id)
 85    {
 86        lock (_lock)
 87        {
 88            return Entries.Exists(x => x.Level == logLevel && x.Id == id);
 89        }
 90    }
 91
 92    /// <summary>
 93    /// Returns a thread-safe snapshot of the recorded entries as an array. Tests should use this
 94    /// snapshot for assertions to avoid collection-modified exceptions when the system under test
 95    /// is concurrently writing logs.
 96    /// </summary>
 97    /// <returns>A stable array copy of the recorded log entries at the time of the call.</returns>
 98    public IReadOnlyList<(LogLevel Level, EventId Id, Exception? Ex, string Message)> GetEntriesSnapshot()
 99    {
100        lock (_lock)
101        {
102            return Entries.ToArray();
103        }
104    }
105}
106
107/// <summary>
108/// Generic convenience type so tests can inject <see cref="ILogger{TCategoryName}"/> easily.
109/// Example: <c>var logger = new InMemoryLogger&lt;MyService&gt;();</c>
110/// </summary>
111public class InMemoryLogger<T> : InMemoryLogger, ILogger<T>;

Using it in a unit test

Here’s a minimal example with xUnit. The system under test logs an info message and an error with an exception.

 1using System;
 2using System.Linq;
 3using Microsoft.Extensions.Logging;
 4using Xunit;
 5
 6public static class EventIds
 7{
 8    public static readonly EventId Started = new(1001, nameof(Started));
 9    public static readonly EventId Failed  = new(1002, nameof(Failed));
10}
11
12public sealed class MyService
13{
14    private readonly ILogger<MyService> _logger;
15    public MyService(ILogger<MyService> logger) => _logger = logger;
16
17    public void Run()
18    {
19        _logger.LogInformation(EventIds.Started, "Starting work");
20        try
21        {
22            throw new InvalidOperationException("boom");
23        }
24        catch (Exception ex)
25        {
26            _logger.LogError(EventIds.Failed, ex, "Work failed");
27        }
28    }
29}
30
31public class MyServiceTests
32{
33    [Fact]
34    public void Logs_expected_messages_and_event_ids()
35    {
36        InMemoryLogger<MyService> logger = new();
37        MyService sut = new(logger);
38
39        sut.Run();
40
41        var snapshot = logger.GetEntriesSnapshot();
42
43        Assert.Contains(snapshot, e => e.Level == LogLevel.Information
44            && e.Id == EventIds.Started
45            && e.Message.Contains("Starting work"));
46
47        Assert.Contains(snapshot, e => e.Level == LogLevel.Error
48            && e.Id == EventIds.Failed
49            && e.Message.Contains("Work failed")
50            && e.Ex is InvalidOperationException);
51    }
52}

Tips:

  • Prefer EventId to make assertions precise and resilient to message text changes
  • Use the snapshot for assertions when your code might log concurrently
  • If your code uses scopes, you can extend the logger to record scope values (not needed for many tests)

Conclusion

You don’t need a heavy mocking setup to validate logging. A tiny in-memory ILogger gives you:

  • Simple, readable tests
  • Reliable, thread-safe assertions
  • Enough context (level, id, exception, message) to catch regressions

Drop it into your test project and start asserting the logs that matter.


Comments

Twitter Facebook LinkedIn WhatsApp