Як підключити тест з ILogger в ASP.NET Core


128

Це мій контролер:

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і aILogger

І це мій тестовий клас, я використовую xUnit для тестування, а Moq для створення макету та заглушки, я можу знущатися DAOлегко, але з ILoggerне знаю, що робити, я просто передаю нульове значення і коментую виклик, щоб увійти в контролер при запуску тесту. Чи є спосіб перевірити, але все-таки тримати реєстратор якось?

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. У його блозі є кілька хороших пропозицій, і я прийшов зі своїм рішенням, яке, здається, вирішило більшість проблем у відповіді нижче .
Ілля Чорномордик

Відповіді:


140

Просто знущайтеся з неї, як і з будь-якої іншої залежності:

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

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

1
@raklos hum, ні, його не слід використовувати в методі запуску всередині тесту, де ServiceCollection є екземпляром
adospace

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
Привіт. ця робота для мене прекрасна. тепер, як я можу перевірити або переглянути інформацію мого журналу
malik saifullah

Я веду одиничні тестові приклади безпосередньо від VS. у мене немає консолі для цього
malik saifullah

1
@maliksaifullah im за допомогою resharper. дозвольте мені перевірити це з vs
Jehof

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

1
Це чудово, дякую! Пара пропозицій: 1) це не повинно бути загальним, оскільки параметр типу не використовується. Впровадження просто 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

Я згоден, ця відповідь була дуже хорошою. Я не розумію , чому це не так вже й багато голосів
Farzad

1
Fab. Ось версія для загального ILogger: gist.github.com/timabell/d71ae82c6f3eaa5df26b147f9d3842eb
Tim Abell

Чи можна було б створити макет, щоб перевірити рядок, який ми пройшли в 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, ось методи розширення, які також можуть відповідати значенням Exception та всім журнальним значенням (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.