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.