Как выполнить модульное тестирование с помощью ILogger в ASP.NET Core


129

Это мой контроллер:

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

Как видите, у меня есть 2 зависимости: a IDAOи a.ILogger

И это мой тестовый класс, я использую xUnit для тестирования и Moq для создания макета и заглушки, я могу DAOлегко имитировать , но с помощью ILoggerя не знаю, что делать, я просто передаю null и закомментирую вызов для входа в контроллер при запуске теста. Есть ли способ протестировать, но как-то сохранить регистратор?

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
Вы можете использовать макет как заглушку, как предлагает Илья, если вы на самом деле не пытаетесь проверить, что был вызван сам метод ведения журнала. В этом случае насмешка над регистратором не работает, и вы можете попробовать несколько разных подходов. Я написал небольшую статью, в которой показаны различные подходы. В статье есть полный репозиторий GitHub с каждым из вариантов . В конце концов, я рекомендую использовать ваш собственный адаптер, а не работать напрямую с типом ILogger <T>, если вам нужно
ssmith

Как упоминал @ssmith, есть некоторые проблемы с проверкой фактических вызовов ILogger. У него есть несколько хороших предложений в своем блоге, и я пришел со своим решением, которое, кажется, решает большинство проблем в ответе ниже .
Илья Черномордик

Ответы:


142

Просто смейтесь над ним, как и над любой другой зависимостью:

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

Для использования вам, вероятно, потребуется установить Microsoft.Extensions.Logging.Abstractionsпакет ILogger<T>.

Более того, вы можете создать настоящий логгер:

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

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

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

5
для входа в окно вывода отладки вызовите AddDebug () на фабрике: var factory = serviceProvider.GetService <ILoggerFactory> () .AddDebug ();
spottedmahn

3
Я нашел подход «настоящего регистратора» более эффективным!
DanielV 04

1
Настоящая часть регистратора также отлично подходит для тестирования LogConfiguration и LogLevel в определенных сценариях.
Мартин Лоттеринг

Такой подход позволит только заглушку, но не проверку звонков. Я пришел со своим решением, которое, кажется, решает большинство проблем с проверкой в ответе ниже .
Илья Черномордик

102

На самом деле, я нашел Microsoft.Extensions.Logging.Abstractions.NullLogger<>идеальное решение. Установите пакет Microsoft.Extensions.Logging.Abstractions, затем следуйте примеру, чтобы настроить и использовать его:

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

и модульный тест

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

Кажется, это работает только для .NET Core 2.0, а не для .NET Core 1.1.
Thorkil Værge

3
@adospace, ваш комментарий гораздо полезнее, чем ответ
johnny 5

Вы можете привести пример, как это будет работать? При модульном тестировании я бы хотел, чтобы журналы отображались в окне вывода, я не уверен, что это так.
J86

@adospace Это должно быть в startup.cs?
raklos 08

1
@raklos гул, нет, он должен использоваться в методе запуска внутри теста, где
создается

32

Используйте настраиваемый регистратор, который использует ITestOutputHelper(из xunit) для захвата вывода и журналов. Ниже приведен небольшой образец, который записывает только на stateвыход.

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

Используйте его в своих юнит-тестах, например

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
Здравствуй. эта работа отлично подходит для меня. теперь, как я могу проверить или просмотреть информацию в моем журнале
Малик Сайфулла

Я запускаю модульные тесты прямо из VS. у меня нет консоли для этого
малик сайфулла

1
@maliksaifullah я использую resharper. позвольте мне проверить это с vs
Jehof

1
@maliksaifullah TestExplorer VS предоставляет ссылку для открытия результатов теста. выберите свой тест в TestExplorer и внизу есть ссылка
Jehof

1
Это здорово, спасибо! Пара предложений: 1) это не должно быть общим, поскольку параметр типа не используется. Внедрение just ILoggerсделает его более удобным для использования. 2) Объект BeginScopeне должен возвращать себя, поскольку это означает, что любые проверенные методы, которые начинают и заканчивают область действия во время выполнения, будут уничтожать регистратор. Вместо этого создайте частный «манекен» вложенный класс , который реализует IDisposableи возвращает экземпляр , который ( а затем удалить IDisposableиз XunitLogger).
Tobias J

27

Для .net core 3 ответов, использующих Moq

К счастью, stakx предоставил хороший обходной путь . Поэтому я публикую его в надежде, что это поможет сэкономить время другим (потребовалось время, чтобы разобраться во всем):

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

Ты спас мне день .. Спасибо.
KiddoDeveloper

15

Добавляя мои 2 цента, это вспомогательный метод расширения, обычно помещаемый в статический вспомогательный класс:

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

Затем вы используете его так:

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

//Act

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

И, конечно, вы можете легко расширить его, чтобы высмеять любые ожидания (например, ожидания, сообщения и т. Д.)


Это очень элегантное решение.
MichaelDotKnox

Я согласен, это был очень хороший ответ. Я не понимаю, почему у него не так много голосов
Фарзад

1
Fab. Вот версия для необщего ILogger: gist.github.com/timabell/d71ae82c6f3eaa5df26b147f9d3842eb
Тим Абелл,

Можно ли создать макет для проверки строки, переданной в LogWarning? Например:It.Is<string>(s => s.Equals("A parameter is empty!"))
Серхат

Это очень помогает. Единственное, чего мне не хватает, это как я могу настроить обратный вызов на макете, который записывает в вывод XUnit? Никогда не перезвонил мне.
flipdoubt

6

Это легко, поскольку другие ответы предлагают пройти имитацию ILogger, но внезапно становится намного более проблематичным проверить, действительно ли вызовы были сделаны в регистратор. Причина в том, что большинство вызовов на самом деле не принадлежат самому ILoggerинтерфейсу.

Таким образом, большинство вызовов - это методы расширения, которые вызывают единственный Logметод интерфейса. Причина, по-видимому, в том, что гораздо проще реализовать интерфейс, если у вас есть только одна, а не много перегрузок, которые сводятся к одному и тому же методу.

Недостатком, конечно же, является то, что внезапно становится намного труднее проверить, что вызов был сделан, поскольку вызов, который вы должны проверить, сильно отличается от того, который вы сделали. Есть несколько разных подходов для решения этой проблемы, и я обнаружил, что собственные методы расширения для имитации фреймворка упростят написание.

Вот пример метода, с которым я работал 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>>());
    }

}

И вот как это можно использовать:

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

Выглядит точно так, как если бы вы использовали метод напрямую, уловка здесь в том, что наш метод расширения получает приоритет, потому что он «ближе» в пространствах имен, чем исходный, поэтому он будет использоваться вместо него.

К сожалению, это не дает 100% того, что мы хотим, а именно сообщения об ошибках не будут такими хорошими, поскольку мы проверяем не непосредственно строку, а лямбду, которая включает строку, но 95% лучше, чем ничего :) Дополнительно этот подход сделает тестовый код

PS Для Moq можно использовать подход написания методы расширения для , Mock<ILogger<T>>что делает Verifyдля достижения аналогичных результатов.

PPS Это больше не работает в .Net Core 3, проверьте эту ветку для более подробной информации: https://github.com/nsubstitute/NSubstitute/issues/597#issuecomment-573742574


Почему вы проверяете вызовы регистратора? Они не являются частью бизнес-логики. Если случилось что-то плохое, я бы предпочел проверить фактическое поведение программы (например, вызов обработчика ошибок или выдачу исключения), чем регистрацию сообщения.
Илья Чумаков

1
Что ж, я думаю, это тоже очень важно проверить, по крайней мере, в некоторых случаях. Я слишком много раз видел, что программа завершается с ошибкой без предупреждения, поэтому я думаю, что имеет смысл проверить, что регистрация происходила, когда произошло исключение, например. И это не похоже на «либо или», а скорее тестирует как фактическое поведение программы, так и ведение журнала.
Илья Черномордик

5

Уже упоминалось, что вы можете имитировать его, как любой другой интерфейс.

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

Все идет нормально.

Приятно то, что вы можете использовать его Moqдля проверки того, что определенные вызовы были выполнены . Например, здесь я проверяю, что журнал был вызван с конкретным 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>>()));

При использовании Verifyсуть заключается в том, чтобы делать это против реального Logметода ILoogerинтерфейса, а не методов расширения.


5

Основываясь еще на работе @ ivan-samygin и @stakx, вот методы расширения, которые также могут соответствовать исключению и всем значениям журнала (KeyValuePairs).

Они работают (на моей машине;)) с .Net Core 3, Moq 4.13.0 и 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

Простое создание манекена ILoggerне очень ценно для модульного тестирования. Вы также должны убедиться, что были сделаны вызовы регистрации. Вы можете ввести имитацию ILoggerс помощью Moq, но проверка вызова может быть немного сложной. В этой статье подробно рассказывается о проверке с помощью Moq.

Вот очень простой пример из статьи:

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

Он проверяет, было ли зарегистрировано информационное сообщение. Но если мы хотим проверить более сложную информацию о сообщении, такую ​​как шаблон сообщения и названные свойства, это становится более сложной задачей:

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

Я уверен, что вы могли бы сделать то же самое с другими фреймворками для фиксации, но ILoggerинтерфейс гарантирует, что это сложно.


1
Я согласен с мнением, и, как вы говорите, может быть немного сложно придумать выражение. У меня часто возникала та же проблема, поэтому недавно я собрал Moq.Contrib.ExpressionBuilders.Logging, чтобы обеспечить плавный интерфейс, который делает его намного более приятным.
rgvlee

1

Если еще актуально. Простой способ вывода журнала в тестах для .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

Я попытался издеваться над этим интерфейсом Logger с помощью NSubstitute (и потерпел неудачу, потому что Arg.Any<T>()запрашивает параметр типа, который я не могу предоставить), но в итоге создал тестовый журнал (аналогично ответу @ jehof) следующим образом:

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

Вы можете легко получить доступ ко всем зарегистрированным сообщениям и подтвердить все значимые параметры, предоставленные с ними.

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.