Come unit test con ILogger in ASP.NET Core


129

Questo è il mio controller:

public class BlogController : Controller
{
    private IDAO<Blog> _blogDAO;
    private readonly ILogger<BlogController> _logger;

    public BlogController(ILogger<BlogController> logger, IDAO<Blog> blogDAO)
    {
        this._blogDAO = blogDAO;
        this._logger = logger;
    }
    public IActionResult Index()
    {
        var blogs = this._blogDAO.GetMany();
        this._logger.LogInformation("Index page say hello", new object[0]);
        return View(blogs);
    }
}

Come puoi vedere ho 2 dipendenze, una IDAOe unaILogger

E questa è la mia classe di test, uso xUnit per testare e Moq per creare mock e stub, posso simulare DAOfacilmente, ma con ILoggernon so cosa fare quindi passo null e commenta la chiamata per accedere al controller quando si esegue il test. C'è un modo per testare ma mantenere comunque il logger in qualche modo?

public class BlogControllerTest
{
    [Fact]
    public void Index_ReturnAViewResult_WithAListOfBlog()
    {
        var mockRepo = new Mock<IDAO<Blog>>();
        mockRepo.Setup(repo => repo.GetMany(null)).Returns(GetListBlog());
        var controller = new BlogController(null,mockRepo.Object);

        var result = controller.Index();

        var viewResult = Assert.IsType<ViewResult>(result);
        var model = Assert.IsAssignableFrom<IEnumerable<Blog>>(viewResult.ViewData.Model);
        Assert.Equal(2, model.Count());
    }
}

1
Puoi usare un mock come stub, come suggerisce Ilya, se non stai effettivamente cercando di testare che il metodo di registrazione stesso è stato chiamato. In tal caso, deridere il logger non funziona e puoi provare alcuni approcci diversi. Ho scritto un breve articolo che mostra una varietà di approcci. L'articolo include un repository GitHub completo con ciascuna delle diverse opzioni . Alla fine, la mia raccomandazione è di utilizzare il tuo adattatore piuttosto che lavorare direttamente con il tipo ILogger <T>, se devi essere in grado di
ssmith

Come menzionato da @ssmith, ci sono alcuni problemi con la verifica delle chiamate effettive per ILogger. Ha alcuni buoni suggerimenti nel suo blogpost e sono arrivato con la mia soluzione che sembra risolvere la maggior parte dei problemi nella risposta qui sotto .
Ilya Chernomordik

Risposte:


141

Basta prenderlo in giro così come qualsiasi altra dipendenza:

var mock = new Mock<ILogger<BlogController>>();
ILogger<BlogController> logger = mock.Object;

//or use this short equivalent 
logger = Mock.Of<ILogger<BlogController>>()

var controller = new BlogController(logger);

Probabilmente dovrai installare il Microsoft.Extensions.Logging.Abstractionspacchetto per usare ILogger<T>.

Inoltre puoi creare un vero e proprio logger:

var serviceProvider = new ServiceCollection()
    .AddLogging()
    .BuildServiceProvider();

var factory = serviceProvider.GetService<ILoggerFactory>();

var logger = factory.CreateLogger<BlogController>();

5
per accedere alla finestra di output del debug, chiamare AddDebug () in fabbrica: var factory = serviceProvider.GetService <ILoggerFactory> () .AddDebug ();
spottedmahn

3
Ho trovato l'approccio "vero logger" più efficace!
DanielV

1
La parte del logger reale funziona perfettamente anche per testare LogConfiguration e LogLevel in scenari specifici.
Martin Lottering

Questo approccio consentirà solo lo stub, ma non la verifica delle chiamate. Sono arrivato con la mia soluzione che sembra risolvere la maggior parte dei problemi con la verifica nella risposta di seguito .
Ilya Chernomordik

102

In realtà, ho trovato Microsoft.Extensions.Logging.Abstractions.NullLogger<>che sembra una soluzione perfetta. Installa il pacchetto Microsoft.Extensions.Logging.Abstractions, quindi segui l'esempio per configurarlo e utilizzarlo:

using Microsoft.Extensions.Logging;

public void ConfigureServices(IServiceCollection services)
{
    ...

    services.AddSingleton<ILoggerFactory, NullLoggerFactory>();

    ...
}
using Microsoft.Extensions.Logging;

public class MyClass : IMyClass
{
    public const string ErrorMessageILoggerFactoryIsNull = "ILoggerFactory is null";

    private readonly ILogger<MyClass> logger;

    public MyClass(ILoggerFactory loggerFactory)
    {
        if (null == loggerFactory)
        {
            throw new ArgumentNullException(ErrorMessageILoggerFactoryIsNull, (Exception)null);
        }

        this.logger = loggerFactory.CreateLogger<MyClass>();
    }
}

e unit test

//using Microsoft.VisualStudio.TestTools.UnitTesting;
//using Microsoft.Extensions.Logging;

[TestMethod]
public void SampleTest()
{
    ILoggerFactory doesntDoMuch = new Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory();
    IMyClass testItem = new MyClass(doesntDoMuch);
    Assert.IsNotNull(testItem);
}   

Sembra funzionare solo per .NET Core 2.0, non per .NET Core 1.1.
Thorkil Værge

3
@adospace, il tuo commento è molto più utile della risposta
johnny 5

Puoi fare un esempio di come funzionerebbe? Durante il test di unità, vorrei che i log appaiano nella finestra di output, non sono sicuro che ciò avvenga.
J86

@adospace Dovrebbe andare in startup.cs?
raklos

1
@raklos hum, no, dovrebbe essere utilizzato in un metodo di avvio all'interno del test in cui viene istanziato il ServiceCollection
adospace

32

Utilizza un logger personalizzato che utilizza ITestOutputHelper(da xunit) per acquisire output e log. Quello che segue è un piccolo esempio che scrive solo stateil file nell'output.

public class XunitLogger<T> : ILogger<T>, IDisposable
{
    private ITestOutputHelper _output;

    public XunitLogger(ITestOutputHelper output)
    {
        _output = output;
    }
    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
    {
        _output.WriteLine(state.ToString());
    }

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

    public IDisposable BeginScope<TState>(TState state)
    {
        return this;
    }

    public void Dispose()
    {
    }
}

Usalo nelle tue riunioni come

public class BlogControllerTest
{
  private XunitLogger<BlogController> _logger;

  public BlogControllerTest(ITestOutputHelper output){
    _logger = new XunitLogger<BlogController>(output);
  }

  [Fact]
  public void Index_ReturnAViewResult_WithAListOfBlog()
  {
    var mockRepo = new Mock<IDAO<Blog>>();
    mockRepo.Setup(repo => repo.GetMany(null)).Returns(GetListBlog());
    var controller = new BlogController(_logger,mockRepo.Object);
    // rest
  }
}

1
Ciao. questo funziona bene per me. ora come posso controllare o visualizzare le mie informazioni di registro
malik saifullah

Sto eseguendo i casi di unit test direttamente da VS. Non ho consolle per quello
malik saifullah

1
@maliksaifullah sto usando resharper. fammi controllare con vs
Jehof

1
@maliksaifullah il TestExplorer di VS fornisce un collegamento per aprire l'output di un test. seleziona il tuo test in TestExplorer e in basso c'è un link
Jehof

1
È fantastico, grazie! Un paio di suggerimenti: 1) non è necessario che sia generico, poiché il parametro type non viene utilizzato. L'implementazione ILoggerlo renderà più ampiamente utilizzabile. 2) Non BeginScopedovrebbe restituire se stesso, poiché ciò significa che qualsiasi metodo testato che inizia e termina uno scope durante l'esecuzione eliminerà il logger. Invece, crea una classe nidificata "fittizia" privata che implementa IDisposablee restituisce un'istanza di quella (quindi rimuovi IDisposableda XunitLogger).
Tobias J,

27

Per le risposte .net core 3 che utilizzano Moq

Fortunatamente stakx ha fornito una bella soluzione . Quindi lo sto postando nella speranza che possa far risparmiare tempo agli altri (ci è voluto un po 'per capire le cose):

 loggerMock.Verify(
                x => x.Log(
                    LogLevel.Information,
                    It.IsAny<EventId>(),
                    It.Is<It.IsAnyType>((o, t) => string.Equals("Index page say hello", o.ToString(), StringComparison.InvariantCultureIgnoreCase)),
                    It.IsAny<Exception>(),
                    (Func<It.IsAnyType, Exception, string>) It.IsAny<object>()),
                Times.Once);

Mi hai salvato la giornata ... Grazie.
KiddoDeveloper

15

Aggiungendo i miei 2 centesimi, questo è un metodo di estensione helper in genere inserito in una classe helper statica:

static class MockHelper
{
    public static ISetup<ILogger<T>> MockLog<T>(this Mock<ILogger<T>> logger, LogLevel level)
    {
        return logger.Setup(x => x.Log(level, It.IsAny<EventId>(), It.IsAny<object>(), It.IsAny<Exception>(), It.IsAny<Func<object, Exception, string>>()));
    }

    private static Expression<Action<ILogger<T>>> Verify<T>(LogLevel level)
    {
        return x => x.Log(level, 0, It.IsAny<object>(), It.IsAny<Exception>(), It.IsAny<Func<object, Exception, string>>());
    }

    public static void Verify<T>(this Mock<ILogger<T>> mock, LogLevel level, Times times)
    {
        mock.Verify(Verify<T>(level), times);
    }
}

Quindi, lo usi in questo modo:

//Arrange
var logger = new Mock<ILogger<YourClass>>();
logger.MockLog(LogLevel.Warning)

//Act

//Assert
logger.Verify(LogLevel.Warning, Times.Once());

E ovviamente puoi facilmente estenderlo per deridere qualsiasi aspettativa (es. Aspettativa, messaggio, ecc ...)


Questa è una soluzione molto elegante.
MichaelDotKnox

Sono d'accordo, questa risposta è stata molto buona. Non capisco perché non abbia così tanti voti
Farzad

1
Fab. Ecco una versione per il non generico ILogger: gist.github.com/timabell/d71ae82c6f3eaa5df26b147f9d3842eb
Tim Abell

Sarebbe possibile creare un mock per controllare la stringa che abbiamo passato in LogWarning? Ad esempio:It.Is<string>(s => s.Equals("A parameter is empty!"))
Serhat

Questo aiuta molto. L'unico pezzo che manca per me è come posso impostare un callback sul mock che scrive sull'output di XUnit? Non viene mai richiamato per me.
flipdoubt

6

È facile, come suggeriscono altre risposte, passare il mock ILogger, ma improvvisamente diventa molto più problematico verificare che le chiamate siano state effettivamente effettuate al logger. Il motivo è che la maggior parte delle chiamate non appartengono effettivamente ILoggerall'interfaccia stessa.

Quindi la maggior parte delle chiamate sono metodi di estensione che chiamano l'unico Logmetodo dell'interfaccia. Il motivo sembra sia che è molto più semplice implementare l'interfaccia se si dispone di uno solo e non molti sovraccarichi che si riducono allo stesso metodo.

Lo svantaggio è ovviamente che improvvisamente è molto più difficile verificare che sia stata effettuata una chiamata poiché la chiamata che dovresti verificare è molto diversa dalla chiamata che hai effettuato. Esistono diversi approcci per ovviare a questo problema e ho scoperto che i metodi di estensione personalizzati per il mocking framework renderanno più facile la scrittura.

Ecco un esempio di un metodo con cui ho lavorato NSubstitute:

public static class LoggerTestingExtensions
{
    public static void LogError(this ILogger logger, string message)
    {
        logger.Log(
            LogLevel.Error,
            0,
            Arg.Is<FormattedLogValues>(v => v.ToString() == message),
            Arg.Any<Exception>(),
            Arg.Any<Func<object, Exception, string>>());
    }

}

Ed è così che può essere utilizzato:

_logger.Received(1).LogError("Something bad happened");   

Sembra esattamente come se avessi usato il metodo direttamente, il trucco qui è che il nostro metodo di estensione ha la priorità perché è "più vicino" negli spazi dei nomi rispetto a quello originale, quindi verrà utilizzato al suo posto.

Sfortunatamente non dà il 100% di ciò che vogliamo, vale a dire i messaggi di errore non saranno così buoni, poiché non controlliamo direttamente su una stringa ma piuttosto su un lambda che coinvolge la stringa, ma il 95% è meglio di niente :) Inoltre questo approccio renderà il codice di test

PS Per Moq si può usare l'approccio di scrivere un metodo di estensione per il Mock<ILogger<T>>che fa Verifyper ottenere risultati simili.

PPS Questo non funziona più in .Net Core 3, controlla questo thread per maggiori dettagli: https://github.com/nsubstitute/NSubstitute/issues/597#issuecomment-573742574


Perché verifichi le chiamate del logger? Non fanno parte della logica aziendale. Se succede qualcosa di brutto, preferisco verificare il comportamento effettivo del programma (come chiamare un gestore di errori o lanciare un'eccezione) piuttosto che registrare un messaggio.
Ilya Chumakov

1
Beh, penso che sia abbastanza importante testare anche questo, almeno in alcuni casi. Ho visto troppe volte che un programma fallisce silenziosamente, quindi penso che abbia senso verificare che la registrazione sia avvenuta quando si è verificata un'eccezione, ad es. E non è come "uno o l'altro", ma piuttosto testare sia il comportamento effettivo del programma che la registrazione.
Ilya Chernomordik

5

Già menzionato puoi prenderlo in giro come qualsiasi altra interfaccia.

var logger = new Mock<ILogger<QueuedHostedService>>();

Fin qui tutto bene.

La cosa bella è che puoi usare Moqper verificare che alcune chiamate siano state eseguite . Ad esempio qui controllo che il log sia stato chiamato con un particolare Exception.

logger.Verify(m => m.Log(It.Is<LogLevel>(l => l == LogLevel.Information), 0,
            It.IsAny<object>(), It.IsAny<TaskCanceledException>(), It.IsAny<Func<object, Exception, string>>()));

Quando si utilizza Verifyil punto è farlo contro il Logmetodo reale ILoogerdall'interfaccia e non i metodi di estensione.


5

Basandosi ulteriormente sul lavoro di @ ivan-samygin e @stakx, ecco i metodi di estensione che possono corrispondere anche all'eccezione e a tutti i valori di registro (KeyValuePairs).

Funzionano (sulla mia macchina;)) con .Net Core 3, Moq 4.13.0 e Microsoft.Extensions.Logging.Abstractions 3.1.0.

/// <summary>
/// Verifies that a Log call has been made, with the given LogLevel, Message and optional KeyValuePairs.
/// </summary>
/// <typeparam name="T">Type of the class for the logger.</typeparam>
/// <param name="loggerMock">The mocked logger class.</param>
/// <param name="expectedLogLevel">The LogLevel to verify.</param>
/// <param name="expectedMessage">The Message to verify.</param>
/// <param name="expectedValues">Zero or more KeyValuePairs to verify.</param>
public static void VerifyLog<T>(this Mock<ILogger<T>> loggerMock, LogLevel expectedLogLevel, string expectedMessage, params KeyValuePair<string, object>[] expectedValues)
{
    loggerMock.Verify(mock => mock.Log(
        expectedLogLevel,
        It.IsAny<EventId>(),
        It.Is<It.IsAnyType>((o, t) => MatchesLogValues(o, expectedMessage, expectedValues)),
        It.IsAny<Exception>(),
        It.IsAny<Func<object, Exception, string>>()
        )
    );
}

/// <summary>
/// Verifies that a Log call has been made, with LogLevel.Error, Message, given Exception and optional KeyValuePairs.
/// </summary>
/// <typeparam name="T">Type of the class for the logger.</typeparam>
/// <param name="loggerMock">The mocked logger class.</param>
/// <param name="expectedMessage">The Message to verify.</param>
/// <param name="expectedException">The Exception to verify.</param>
/// <param name="expectedValues">Zero or more KeyValuePairs to verify.</param>
public static void VerifyLog<T>(this Mock<ILogger<T>> loggerMock, string expectedMessage, Exception expectedException, params KeyValuePair<string, object>[] expectedValues)
{
    loggerMock.Verify(logger => logger.Log(
        LogLevel.Error,
        It.IsAny<EventId>(),
        It.Is<It.IsAnyType>((o, t) => MatchesLogValues(o, expectedMessage, expectedValues)),
        It.Is<Exception>(e => e == expectedException),
        It.Is<Func<It.IsAnyType, Exception, string>>((o, t) => true)
    ));
}

private static bool MatchesLogValues(object state, string expectedMessage, params KeyValuePair<string, object>[] expectedValues)
{
    const string messageKeyName = "{OriginalFormat}";

    var loggedValues = (IReadOnlyList<KeyValuePair<string, object>>)state;

    return loggedValues.Any(loggedValue => loggedValue.Key == messageKeyName && loggedValue.Value.ToString() == expectedMessage) &&
           expectedValues.All(expectedValue => loggedValues.Any(loggedValue => loggedValue.Key == expectedValue.Key && loggedValue.Value == expectedValue.Value));
}


1

La semplice creazione di un manichino ILoggernon è molto utile per i test unitari. È inoltre necessario verificare che le chiamate di registrazione siano state effettuate. Puoi iniettare un mock ILoggercon Moq ma verificare la chiamata può essere un po 'complicato. Questo articolo approfondisce la verifica con Moq.

Ecco un esempio molto semplice tratto dall'articolo:

_loggerMock.Verify(l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
(Func<It.IsAnyType, Exception, string>)It.IsAny<object>()), Times.Exactly(1));

Verifica che sia stato registrato un messaggio informativo. Ma, se vogliamo verificare informazioni più complesse sul messaggio come il modello di messaggio e le proprietà denominate, diventa più complicato:

_loggerMock.Verify
(
    l => l.Log
    (
        //Check the severity level
        LogLevel.Error,
        //This may or may not be relevant to your scenario
        It.IsAny<EventId>(),
        //This is the magical Moq code that exposes internal log processing from the extension methods
        It.Is<It.IsAnyType>((state, t) =>
            //This confirms that the correct log message was sent to the logger. {OriginalFormat} should match the value passed to the logger
            //Note: messages should be retrieved from a service that will probably store the strings in a resource file
            CheckValue(state, LogTest.ErrorMessage, "{OriginalFormat}") &&
            //This confirms that an argument with a key of "recordId" was sent with the correct value
            //In Application Insights, this will turn up in Custom Dimensions
            CheckValue(state, recordId, nameof(recordId))
    ),
    //Confirm the exception type
    It.IsAny<NotImplementedException>(),
    //Accept any valid Func here. The Func is specified by the extension methods
    (Func<It.IsAnyType, Exception, string>)It.IsAny<object>()),
    //Make sure the message was logged the correct number of times
    Times.Exactly(1)
);

Sono sicuro che potresti fare lo stesso con altri framework beffardi, ma l' ILoggerinterfaccia assicura che sia difficile.


1
Sono d'accordo con il sentimento e come dici tu può diventare un po 'difficile costruire l'espressione. Ho avuto lo stesso problema, spesso, quindi di recente ho messo insieme Moq.Contrib.ExpressionBuilders.Logging per fornire un'interfaccia fluente che lo rende molto più appetibile.
rgvlee

1

Se un file. Un modo semplice per accedere all'output nei test per .net core> = 3

[Fact]
public void SomeTest()
{
    using var logFactory = LoggerFactory.Create(builder => builder.AddConsole());
    var logger = logFactory.CreateLogger<AccountController>();
    
    var controller = new SomeController(logger);

    var result = controller.SomeActionAsync(new Dto{ ... }).GetAwaiter().GetResult();
}

0

Usa Telerik Just Mock per creare un'istanza derisa del logger:

using Telerik.JustMock;
...
context = new XDbContext(Mock.Create<ILogger<XDbContext>>());

0

Ho provato a deridere quell'interfaccia del logger usando NSubstitute (e non è riuscito perché Arg.Any<T>()richiede un parametro di tipo, che non posso fornire), ma ho finito per creare un logger di test (in modo simile alla risposta di @ jehof) nel modo seguente:

    internal sealed class TestLogger<T> : ILogger<T>, IDisposable
    {
        private readonly List<LoggedMessage> _messages = new List<LoggedMessage>();

        public IReadOnlyList<LoggedMessage> Messages => _messages;

        public void Dispose()
        {
        }

        public IDisposable BeginScope<TState>(TState state)
        {
            return this;
        }

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

        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
        {
            var message = formatter(state, exception);
            _messages.Add(new LoggedMessage(logLevel, eventId, exception, message));
        }

        public sealed class LoggedMessage
        {
            public LogLevel LogLevel { get; }
            public EventId EventId { get; }
            public Exception Exception { get; }
            public string Message { get; }

            public LoggedMessage(LogLevel logLevel, EventId eventId, Exception exception, string message)
            {
                LogLevel = logLevel;
                EventId = eventId;
                Exception = exception;
                Message = message;
            }
        }
    }

È possibile accedere facilmente a tutti i messaggi registrati e affermare tutti i parametri significativi forniti con esso.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.