19

I am trying to write unit tests around my exception handling so that I can verify the my logger is properly logging the exception. I am using NSubstitute as a mocking framework and Microsoft.Extensions.Logging.ILogger I have to following for my test:

[Fact]
public void LogsExcpetionWhenErrorOccursInCreate()
{
   var newUser = new UserDataModel
   {
      FirstName = "Rick",
      MiddleName = "Jason",
      LastName = "Grimes",
      Email = "rick.grimes@thedead.com",
      Created = new DateTime(2007, 8, 15)
   };
   var exception = new Exception("Test Exception");
   // configure InsertOne to throw a generic excpetion
   _mongoContext.InsertOne(newUser).Returns(x => { throw exception; });

   try
   {
      _collection.Create(newUser);
   }
   catch
   {
      // validate that the logger logs the exception as an error
      _logger.Received().LogError(exception.Message);
   }
}

to test the logging in the following method:

public UserDataModel Create(UserDataModel user)
{
     try
     {
          return MongoContext.InsertOne(user);                
     }
     catch(Exception e)
     {
           _logger?.LogError(e.Message);
           throw new DataAccessException("An error occurred while attempting to create a user.", e);
      }

}

My test fails with the following error:

Message: NSubstitute.Exceptions.ReceivedCallsException : Expected to receive a call matching:
    Log<Object>(Error, 0, Test Exception, <null>, Func<Object, Exception, String>)
Actually received no matching calls.
Received 1 non-matching call (non-matching arguments indicated with '*' characters):
    Log<Object>(Error, 0, *Test Exception*, <null>, Func<Object, Exception, String>)

I'm not sure why this is failing because even in then error message the calls are the same.

Thanks in advance!

Update:

Here is the constructor for the test, this is where I am injecting the logger mock:

public UserCollectionTest()
{
   _mongoContext = Substitute.For<IMongoContext<UserDataModel>>();
   _logger = Substitute.For<ILogger>();
   // create UserCollection with our mock client
   _collection = new UserCollection(_mongoContext, _logger);
}
Nick Dichiaro
  • 330
  • 1
  • 2
  • 10

4 Answers4

22

LogError is not a ILogger method, so when you try to check that this method was called with certain parameters NSubstitute tries to handle it somehow(I do not know how exactly) and fails.

The code of LogError extension method is:

public static void LogError(this ILogger logger, string message, params object[] args)
{
  if (logger == null)
    throw new ArgumentNullException("logger");
  logger.Log<object>(LogLevel.Error, (EventId) 0, (object) new FormattedLogValues(message, args), (Exception) null, LoggerExtensions._messageFormatter);
}

So you have to check that Log method was called.

I simplified your example a bit. I think idea should be clear.

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        var logger = Substitute.For<ILogger>();
        try
        {
            Create(logger);
        }
        catch
        {
            logger.CheckErrorMessage("My Message");
        }
    }

    public string Create(ILogger logger)
    {
        try
        {
            throw new Exception("My Message");
        }
        catch (Exception e)
        {
            logger?.LogError(e.Message);
            throw new Exception("An error occurred while attempting to create a user.", e);
        }
    }
}

public static class TestExtensions
{
    public static void CheckErrorMessage(this ILogger logger, string message)
    {
        logger.Received().Log(
            LogLevel.Error,
            Arg.Any<EventId>(),
            Arg.Is<object>(o => o.ToString() == message),
            null,
            Arg.Any<Func<object, Exception, string>>());
    }
}
Valerii
  • 2,147
  • 2
  • 13
  • 27
  • 4
    Did not work for me. It simply doesn't match. – Andre Soares Mar 31 '21 at 13:05
  • What doesn't match. Maybe it's because newer version of NSubstitute. Can you please be more specific? – Valerii Apr 01 '21 at 16:11
  • 4
    @AndreSoares looks like this solution doesn't work anymore in newer version of .NET which didn't exists at the time the answer was posted. https://github.com/nsubstitute/NSubstitute/issues/597 – Justin Lessard May 19 '21 at 20:29
  • Same problem here. It does not work anymore for me either. Bummer. – Matthew MacFarland Apr 29 '22 at 15:10
  • 1
    `logger.ReceivedWithAnyArgs(1).Log(LogLevel.Information, default, default, default, default);` works for me but it has the obvious limitation of not asserting the info message (only that 1 info msg has taken place). – Fredrik Feb 13 '23 at 16:09
16

Looks like the accepted answer does not work in .NET Core 3 or .NET 5.

Here is the workaround found in the github issue

Create a new MockLogger class

public abstract class MockLogger : ILogger
{
    void ILogger.Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter) => 
        Log(logLevel, formatter(state, exception));

    public abstract void Log(LogLevel logLevel, string message);

    public virtual bool IsEnabled(LogLevel logLevel) => true;

    public abstract IDisposable BeginScope<TState>(TState state);
}

Replace your mocked logger with the new mock logger

// Old 
// var logger = Substitute.For<ILogger>();
// New
var logger = Substitute.For<MockLogger>();

Use the new logger to check calls

logger.Received().Log(LogLevel.Error, Arg.Is<string>(s => s.Contains("some log message")));

To use with a generic ILogger<T>, simply change the class to

public abstract class MockLogger<T> : ILogger<T>
Justin Lessard
  • 10,804
  • 5
  • 49
  • 61
  • With the MockLogger I could use the LogWarning in the assert: _logProvider.Received().LogWarning("message"); – Bart VdA Jan 18 '23 at 15:31
  • Just verified that this solution works with new the newest libraries at the moment. For those who have to use explicit types, the type of the instance should be MockLogger rather than ILogger, e.g: `MockLogger logger = Substitute.For>();`. Otherwise, a test will be failing. – SergeyLazarev Jan 23 '23 at 18:35
2

And here is my approach that is going a bit further. And extending Githhub issue.

First logger:

using Microsoft.Extensions.Logging;

public abstract class MockLogger<T> : ILogger<T>
{
    void ILogger.Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
    {
        var unboxed = (IReadOnlyList<KeyValuePair<string, object>>)state!;
        string message = formatter(state, exception);

        this.Log();
        this.Log(logLevel, message, exception);
        this.Log(logLevel, unboxed.ToDictionary(k => k.Key, v => v.Value), exception);
    }

    public abstract void Log();

    public abstract void Log(LogLevel logLevel, string message, Exception? exception = null);

    public abstract void Log(LogLevel logLevel, IDictionary<string, object> state, Exception? exception = null);

    public virtual bool IsEnabled(LogLevel logLevel)
    {
        return true;
    }

    public abstract IDisposable BeginScope<TState>(TState state);
}

Now usage:

MockLogger<Service> logger = Substitute.For<MockLogger<Service>>();
logger.IsEnabled(LogLevel.Error).Returns(true);

string originalFormat = "Service returned error with code: '{statusCode}' and reason: '{reason}'";
var statusCode = HttpStatusCode.BadRequest;
string reason = "The reason...";

string message = $"Service returned error with code: '{(int)statusCode}' and reason: '{reason}'";

// Assert
logger.Received(1).Log(
            LogLevel.Error,
            Arg.Is<IDictionary<string, object>>(
                dict =>
                    dict.Any(kv => kv.Key == "{OriginalFormat}" && (string)kv.Value == originalFormat) &&
                    dict.Any(kv => kv.Key == "statusCode" && (int)kv.Value == (int)statusCode) &&
                    dict.Any(kv => kv.Key == "reason" && (string)kv.Value == reason)));

logger.Received(1).Log(LogLevel.Error, message);
logger.Received(1).Log();

It allows testing log parameters separately.

Marcin
  • 3,232
  • 4
  • 31
  • 48
0

Use name space below

using NSubstitute.ExceptionExtensions;

And update code to throw exception instead of returning it.

_mongoContext.InsertOne(newUser).Throw(exception);

Manthan Makani
  • 119
  • 1
  • 6