Como fazer o teste de unidade com ILogger no ASP.NET Core


128

Este é meu controlador:

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

Como você pode ver, tenho 2 dependências, uma IDAOe umaILogger

E esta é minha classe de teste, eu uso xUnit para testar e Moq para criar mock e stub, posso simular DAOfacilmente, mas com o ILoggernão sei o que fazer, então apenas passo null e comento a chamada para o controlador de login ao executar o teste. Existe uma maneira de testar, mas ainda manter o logger de alguma forma?

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
Você pode usar uma simulação como um esboço, como Ilya sugere, se não estiver realmente tentando testar se o método de registro em si foi chamado. Se for esse o caso, zombar do logger não funciona e você pode tentar algumas abordagens diferentes. Escrevi um pequeno artigo mostrando uma variedade de abordagens. O artigo inclui um repositório GitHub completo com cada uma das diferentes opções . No final, minha recomendação é usar seu próprio adaptador em vez de trabalhar diretamente com o tipo ILogger <T>, se você precisar
— ssmith

Como @ssmith mencionou, existem alguns problemas com a verificação de chamadas reais ILogger. Ele tem algumas boas sugestões em sua postagem do blog e eu vim com minha solução que parece resolver a maioria dos problemas na resposta abaixo .
— Ilya Chernomordik

Respostas:


139

Basta simular isso, bem como qualquer outra dependência:

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

Você provavelmente terá que instalar o Microsoft.Extensions.Logging.Abstractionspacote para uso ILogger<T>.

Além disso, você pode criar um registrador real:

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

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

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

5
para registrar na janela de saída de depuração, chame AddDebug () na fábrica: var factory = serviceProvider.GetService <ILoggerFactory> () .AddDebug ();
— spottedmahn

3
Achei a abordagem do "verdadeiro logger" mais eficaz!
— DanielV

1
A parte do logger real também funciona muito bem para testar LogConfiguration e LogLevel em cenários específicos.
— Martin Lottering

Essa abordagem permitirá apenas o stub, mas não a verificação das chamadas. Eu vim com minha solução que parece resolver a maioria dos problemas com a verificação na resposta abaixo .
— Ilya Chernomordik

102

Na verdade, descobri Microsoft.Extensions.Logging.Abstractions.NullLogger<>que parece uma solução perfeita. Instale o pacote Microsoft.Extensions.Logging.Abstractionse siga o exemplo para configurá-lo e usá-lo:

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 teste de unidade

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

Isso parece funcionar apenas para o .NET Core 2.0, não para o .NET Core 1.1.
— Thorkil Værge

3
@adospace, seu comentário é muito mais útil do que a resposta
— johnny 5

Você pode dar um exemplo de como isso funcionaria? Durante o teste de unidade, gostaria que os logs aparecessem na janela de saída, não tenho certeza se isso faz isso.
— J86 de

@adospace Isso deveria ir em startup.cs?
— raklos

1
@raklos hum, não, ele deve ser usado em um método de inicialização dentro do teste onde o ServiceCollection é instanciado
— adospace

31

Use um logger customizado que usa ITestOutputHelper(do xunit) para capturar a saída e os logs. A seguir está um pequeno exemplo que grava apenas statena saída.

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

Use-o em seus testes de unidade, como

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
Oi. isso funciona bem para mim. agora como posso verificar ou visualizar minhas informações de registro
— malik saifullah

Estou executando os casos de teste de unidade diretamente do VS. Eu não tenho console para isso
— Malik Saifullah

1
@maliksaifullah estou usando resharper. deixe-me verificar isso com vs
— Jehof

1
@maliksaifullah o TestExplorer do VS fornece um link para abrir a saída de um teste. selecione seu teste no TestExplorer e na parte inferior há um link
— Jehof

1
Isso é ótimo, obrigado! Algumas sugestões: 1) não precisa ser genérico, pois o parâmetro type não é usado. Implementar apenas ILoggero tornará mais amplamente utilizável. 2) O BeginScopenão deve retornar a si mesmo, pois isso significa que qualquer método testado que inicie e termine um escopo durante a execução descartará o logger. Em vez disso, crie uma classe aninhada "fictícia" privada que implementa IDisposablee retorna uma instância dela (depois remova IDisposablede XunitLogger).
— Tobias J

27

Para .net core 3 respostas que usam Moq

Felizmente, o stakx forneceu uma boa solução alternativa . Então, estou postando na esperança de economizar tempo para outras pessoas (demorou um pouco para descobrir as coisas):

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

Você salvou meu dia .. Obrigado.
— KiddoDeveloper

15

Adicionando meus 2 centavos, este é um método de extensão auxiliar normalmente colocado em uma classe auxiliar estática:

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

Então, você o usa assim:

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

//Act

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

E, claro, você pode facilmente estendê-lo para zombar de qualquer expectativa (ou seja, expectativa, mensagem, etc ...)


Esta é uma solução muito elegante.
— MichaelDotKnox

Eu concordo, essa resposta foi muito boa. Não entendo por que não tem tantos votos
— Farzad

1
Fab. Esta é uma versão para o não genérico ILogger: gist.github.com/timabell/d71ae82c6f3eaa5df26b147f9d3842eb
— Tim Abell

Seria possível criar um mock para verificar a string que passamos no LogWarning? Por exemplo:It.Is<string>(s => s.Equals("A parameter is empty!"))
— Serhat

Isso ajuda muito. A única peça que falta para mim é como posso configurar um retorno de chamada no mock que grava na saída XUnit? Nunca acerta o callback para mim.
— flipdoubt

6

É fácil como outras respostas sugerem passar simulado ILogger, mas de repente se torna muito mais problemático verificar se as chamadas realmente foram feitas para o logger. O motivo é que a maioria das chamadas não pertence realmente à ILoggerprópria interface.

Portanto, a maioria das chamadas são métodos de extensão que chamam apenas Log método da interface. O motivo pelo qual parece é que é muito mais fácil fazer a implementação da interface se você tiver apenas uma e não muitas sobrecargas que se resumem ao mesmo método.

A desvantagem é que, de repente, fica muito mais difícil verificar se uma chamada foi feita, pois a chamada que você deve verificar é muito diferente da chamada que você fez. Existem algumas abordagens diferentes para contornar isso, e descobri que os métodos de extensão personalizados para a estrutura de simulação tornarão mais fácil escrever.

Aqui está um exemplo de um método que criei para trabalhar 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>>());
    }

}

E é assim que pode ser usado:

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

Parece exatamente como se você tivesse usado o método diretamente, o truque aqui é que nosso método de extensão tem prioridade porque é "mais próximo" em namespaces do que o original, então ele será usado em seu lugar.

Infelizmente não dá 100% do que queremos, ou seja, as mensagens de erro não serão tão boas, já que não verificamos diretamente em uma string, mas sim em um lambda que envolve a string, mas 95% é melhor do que nada :) Adicionalmente esta abordagem fará com que o código de teste

PS Para Moq pode-se usar a abordagem de escrever um método de extensão para o Mock<ILogger<T>>que fazVerify para alcançar resultados semelhantes.

PPS Isso não funciona mais no .Net Core 3, verifique este tópico para mais detalhes: https://github.com/nsubstitute/NSubstitute/issues/597#issuecomment-573742574


Por que você verifica as chamadas do logger? Eles não fazem parte da lógica de negócios. Se algo de ruim aconteceu, prefiro verificar o comportamento real do programa (como chamar um manipulador de erros ou lançar uma exceção) do que registrar uma mensagem.
— Ilya Chumakov

1
Bem, acho que é muito importante testar isso também, pelo menos em alguns casos. Já vi muitas vezes que um programa falha silenciosamente, então acho que faz sentido verificar se o registro ocorreu quando ocorreu uma exceção, por exemplo, e não é como "um ou outro", mas sim testar o comportamento real do programa e o registro.
— Ilya Chernomordik

5

Já mencionei, você pode simular como qualquer outra interface.

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

Por enquanto, tudo bem.

O bom é que você pode usar Moqpara verificar se certas chamadas foram realizadas . Por exemplo, aqui eu verifico se o log foi chamado com um particular 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>>()));

Ao usar, Verifyo objetivo é fazer isso contra o Logmétodo real da ILoogerinterface e não contra os métodos de extensão.


5

Construindo ainda mais o trabalho de @ ivan-samygin e @stakx, aqui estão os métodos de extensão que também podem corresponder na Exceção e em todos os valores de log (KeyValuePairs).

Eles funcionam (na minha máquina;)) com .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

A simples criação de um manequim ILoggernão é muito valiosa para o teste de unidade. Você também deve verificar se as chamadas de registro foram feitas. Você pode injetar uma simulação ILoggercom o Moq, mas verificar a chamada pode ser um pouco complicado. Este artigo detalha a verificação com o Moq.

Aqui está um exemplo muito simples do artigo:

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

Ele verifica se uma mensagem de informação foi registrada. Mas, se quisermos verificar informações mais complexas sobre a mensagem, como o modelo de mensagem e as propriedades nomeadas, fica mais complicado:

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

Tenho certeza de que você poderia fazer o mesmo com outras estruturas de simulação, mas a ILoggerinterface garante que seja difícil.


1
Eu concordo com o sentimento e, como você diz, pode ser um pouco difícil construir a expressão. Eu tinha o mesmo problema, muitas vezes, então recentemente montei o Moq.Contrib.ExpressionBuilders.Logging para fornecer uma interface fluente que o torna muito mais palatável.
— rgvlee

1

Se ainda for real. Maneira simples de registrar a saída em testes para .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

Eu tentei simular aquela interface do Logger usando NSubstitute (e falhei porque Arg.Any<T>()requer um parâmetro de tipo, que eu não posso fornecer), mas acabei criando um logger de teste (semelhante à resposta de @ jehof) da seguinte maneira:

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

Você pode acessar facilmente todas as mensagens registradas e afirmar todos os parâmetros significativos fornecidos com ele.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.