Comment effectuer un test unitaire avec ILogger dans ASP.NET Core

129

Ceci est mon contrôleur:

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);
    }
}

Comme vous pouvez le voir, j'ai 2 dépendances, une IDAOet uneILogger

Et ceci est ma classe de test, j'utilise xUnit pour tester et Moq pour créer une maquette et un stub, je peux me moquer DAOfacilement, mais avec le ILoggerje ne sais pas quoi faire, je passe simplement null et commente l'appel pour se connecter au contrôleur lors de l'exécution du test. Existe-t-il un moyen de tester tout en conservant l'enregistreur d'une manière ou d'une autre?

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());
    }
}
duc
la source
1
Vous pouvez utiliser une maquette comme stub, comme le suggère Ilya, si vous n'essayez pas réellement de tester que la méthode de journalisation elle-même a été appelée. Si tel est le cas, se moquer de l'enregistreur ne fonctionne pas et vous pouvez essayer plusieurs approches différentes. J'ai écrit un court article montrant une variété d'approches. L'article comprend un dépôt GitHub complet avec chacune des différentes options . En fin de compte, ma recommandation est d'utiliser votre propre adaptateur plutôt que de travailler directement avec le type ILogger <T>, si vous avez besoin de pouvoir
ssmith
Comme @ssmith l'a mentionné, la vérification des appels réels pose quelques problèmes ILogger. Il a quelques bonnes suggestions dans son article de blog et je suis venu avec ma solution qui semble résoudre la plupart des problèmes dans la réponse ci-dessous .
Ilya Chernomordik

Réponses:

142

Il suffit de le moquer ainsi que de toute autre dépendance:

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);

Vous devrez probablement installer le Microsoft.Extensions.Logging.Abstractionspackage à utiliser ILogger<T>.

De plus, vous pouvez créer un véritable enregistreur:

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

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

var logger = factory.CreateLogger<BlogController>();
Ilya Chumakov
la source
5
pour vous connecter à la fenêtre de sortie de débogage, appelez AddDebug () sur l'usine: var factory = serviceProvider.GetService <ILoggerFactory> () .AddDebug ();
spottedmahn
3
J'ai trouvé l'approche "real logger" plus efficace!
DanielV
1
La partie de l'enregistreur réel fonctionne également très bien pour tester la configuration LogConfiguration et LogLevel dans des scénarios spécifiques.
Martin Lottering
Cette approche n'autorisera que le stub, mais pas la vérification des appels. Je suis venu avec ma solution qui semble résoudre la plupart des problèmes de vérification dans la réponse ci-dessous .
Ilya Chernomordik
102

En fait, j'ai trouvé Microsoft.Extensions.Logging.Abstractions.NullLogger<>ce qui ressemble à une solution parfaite. Installez le package Microsoft.Extensions.Logging.Abstractions, puis suivez l'exemple pour le configurer et l'utiliser:

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>();
    }
}

et test unitaire

//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);
}   
Amir Shitrit
la source
Cela semble fonctionner uniquement pour .NET Core 2.0, pas pour .NET Core 1.1.
Thorkil Værge
3
@adospace, votre commentaire est bien plus utile que la réponse
johnny 5
Pouvez-vous donner un exemple de la façon dont cela fonctionnerait? Lors des tests unitaires, j'aimerais que les journaux apparaissent dans la fenêtre de sortie, je ne suis pas sûr que cela fasse cela.
J86
@adospace Est-ce censé aller dans startup.cs?
raklos
1
@raklos hum, non, il est censé être utilisé dans une méthode de démarrage à l'intérieur du test où le ServiceCollection est instancié
adospace
32

Utilisez un enregistreur personnalisé qui utilise ITestOutputHelper(à partir de xunit) pour capturer la sortie et les journaux. Voici un petit exemple qui n'écrit que le statedans la sortie.

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()
    {
    }
}

Utilisez-le dans vos unittests comme

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
  }
}
Jehof
la source
1
salut. cela fonctionne bien pour moi. maintenant comment je peux vérifier ou afficher mes informations de journal
malik saifullah
J'exécute les cas de test unitaires directement à partir de VS. Je n'ai pas de console pour ça
malik saifullah
1
@maliksaifullah im utilisant le resharper. laissez-moi vérifier avec vs
Jehof
1
@maliksaifullah le TestExplorer de VS fournit un lien pour ouvrir la sortie d'un test. sélectionnez votre test dans TestExplorer et en bas il y a un lien
Jehof
1
C'est génial, merci! Quelques suggestions: 1) cela n'a pas besoin d'être générique, car le paramètre type n'est pas utilisé. La mise en œuvre ILoggerle rendra plus largement utilisable. 2) Le BeginScopene doit pas se retourner, car cela signifie que toutes les méthodes testées qui commencent et terminent une portée pendant l'exécution éliminent le logger. Au lieu de cela, créer une classe imbriquée « factice » privée qui met en œuvre IDisposableet retourner une instance de ce (puis retirez IDisposablede XunitLogger).
Tobias J
27

Pour les réponses .net core 3 utilisant Moq

Heureusement, stakx a fourni une belle solution de contournement . Je le publie donc dans l'espoir que cela puisse faire gagner du temps aux autres (il a fallu un certain temps pour comprendre les choses):

 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);
Ivan Samygin
la source
Vous avez sauvé ma journée ... Merci.
KiddoDeveloper
15

En ajoutant mes 2 cents, c'est une méthode d'extension d'assistance généralement placée dans une classe d'assistance statique:

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);
    }
}

Ensuite, vous l'utilisez comme ceci:

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

//Act

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

Et bien sûr, vous pouvez facilement l'étendre pour vous moquer de toute attente (ex: attente, message, etc.)

Mahmoud Hanafy
la source
C'est une solution très élégante.
MichaelDotKnox
Je suis d'accord, cette réponse était très bonne. Je ne comprends pas pourquoi il n'y a pas autant de votes
Farzad
1
Fab. Voici une version pour le non générique ILogger: gist.github.com/timabell/d71ae82c6f3eaa5df26b147f9d3842eb
Tim Abell le
Serait-il possible de créer une maquette pour vérifier la chaîne que nous avons passée dans LogWarning? Par exemple:It.Is<string>(s => s.Equals("A parameter is empty!"))
Serhat le
Cela aide beaucoup. La seule pièce manquante pour moi est de savoir comment puis-je configurer un rappel sur la maquette qui écrit sur la sortie XUnit? Ne frappe jamais le rappel pour moi.
flipdoubt
6

Il est facile, comme d'autres réponses le suggèrent, de passer des simulations ILogger, mais il devient soudainement beaucoup plus problématique de vérifier que des appels ont effectivement été passés à l'enregistreur. La raison en est que la plupart des appels n'appartiennent pas réellement à l' ILoggerinterface elle-même.

Donc, la plupart des appels sont des méthodes d'extension qui appellent la seule Logméthode de l'interface. La raison semble être qu'il est beaucoup plus facile de faire la mise en œuvre de l'interface si vous n'avez qu'une seule et pas beaucoup de surcharges qui se résument à la même méthode.

L'inconvénient est bien sûr qu'il est soudainement beaucoup plus difficile de vérifier qu'un appel a été passé car l'appel que vous devez vérifier est très différent de l'appel que vous avez passé. Il existe différentes approches pour contourner ce problème, et j'ai constaté que les méthodes d'extension personnalisées pour le framework moqueur faciliteraient l'écriture.

Voici un exemple de méthode que j'ai conçue pour travailler 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>>());
    }

}

Et voici comment il peut être utilisé:

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

Il semble exactement que vous utilisiez la méthode directement, l'astuce ici est que notre méthode d'extension a la priorité parce qu'elle est "plus proche" dans les espaces de noms que l'original, donc elle sera utilisée à la place.

Cela ne donne malheureusement pas 100% de ce que nous voulons, à savoir que les messages d'erreur ne seront pas aussi bons, car nous ne vérifions pas directement sur une chaîne mais plutôt sur un lambda qui implique la chaîne, mais 95% c'est mieux que rien :) cette approche rendra le code de test

PS Pour Moq, on peut utiliser l'approche consistant à écrire une méthode d'extension pour le Mock<ILogger<T>>faire Verifypour obtenir des résultats similaires.

PPS Cela ne fonctionne plus dans .Net Core 3, consultez ce fil pour plus de détails: https://github.com/nsubstitute/NSubstitute/issues/597#issuecomment-573742574

Ilya Chernomordik
la source
Pourquoi vérifiez-vous les appels des enregistreurs? Ils ne font pas partie de la logique métier. Si quelque chose de mauvais se produit, je préfère vérifier le comportement réel du programme (comme appeler un gestionnaire d'erreurs ou lancer une exception) plutôt que d'enregistrer un message.
Ilya Chumakov
1
Eh bien, je pense qu'il est très important de tester cela également, du moins dans certains cas. J'ai vu trop de fois qu'un programme échouait silencieusement, donc je pense qu'il est logique de vérifier que la journalisation s'est produite lorsqu'une exception s'est produite, par exemple Et ce n'est pas comme "l'un ou l'autre", mais plutôt tester à la fois le comportement réel du programme et la journalisation.
Ilya Chernomordik
5

Déjà mentionné, vous pouvez vous en moquer comme n'importe quelle autre interface.

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

Jusqu'ici tout va bien.

Une bonne chose est que vous pouvez utiliser Moqpour vérifier que certains appels ont été effectués . Par exemple ici je vérifie que le journal a été appelé avec un particulier 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>>()));

Lorsque vous utilisez Verifyle point est de le faire contre la Logméthode réelle de l' ILoogerinterface et non les méthodes d'extension.

guillem
la source
5

En s'appuyant encore plus sur le travail de @ ivan-samygin et @stakx, voici des méthodes d'extension qui peuvent également correspondre à l'exception et à toutes les valeurs de journal (KeyValuePairs).

Ceux-ci fonctionnent (sur ma machine;)) avec .Net Core 3, Moq 4.13.0 et 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));
}
Mathias R
la source
1

Créer simplement un mannequin ILoggern'est pas très utile pour les tests unitaires. Vous devez également vérifier que les appels de journalisation ont été effectués. Vous pouvez injecter une simulation ILoggeravec Moq, mais la vérification de l'appel peut être un peu délicate. Cet article approfondit la vérification avec Moq.

Voici un exemple très simple de l'article:

_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));

Il vérifie qu'un message d'information a été enregistré. Mais, si nous voulons vérifier des informations plus complexes sur le message comme le modèle de message et les propriétés nommées, cela devient plus délicat:

_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)
);

Je suis sûr que vous pourriez faire la même chose avec d'autres frameworks moqueurs, mais l' ILoggerinterface garantit que c'est difficile.

Christian Findlay
la source
1
Je suis d'accord avec le sentiment, et comme vous le dites, il peut être un peu difficile de construire l'expression. J'ai eu le même problème, souvent, si récemment mis en place Moq.Contrib.ExpressionBuilders.Logging pour fournir une interface fluide qui le rend beaucoup plus acceptable.
rgvlee
1

Si un encore réel. Manière simple de se connecter pour générer des tests pour .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();
}
photoscar
la source
0

Utilisez Telerik Just Mock pour créer une instance simulée de l'enregistreur:

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

J'ai essayé de me moquer de cette interface Logger en utilisant NSubstitute (et j'ai échoué parce que Arg.Any<T>()requiert un paramètre de type, que je ne peux pas fournir), mais j'ai fini par créer un enregistreur de test (similaire à la réponse de @ jehof) de la manière suivante:

    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;
            }
        }
    }

Vous pouvez facilement accéder à tous les messages enregistrés et affirmer tous les paramètres significatifs fournis avec.

Igor Kustov
la source