Как выполнить модульное тестирование с помощью ILogger в ASP.NET Core
Это мой контроллер:
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 зависимости, IDAO
и ILogger
И это мой тестовый класс, я использую 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());
}
}
18 ответов
Просто издевайтесь над ней так же, как и над любой другой зависимостью:
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>();
На самом деле я нашел Microsoft.Extensions.Logging.Abstractions.NullLogger<>, который выглядит как идеальное решение.
Для.net core 3 ответов, использующих Moq
- /questions/31974773/kak-vyipolnit-modulnoe-testirovanie-s-pomoschyu-ilogger-v-aspnet-core/31974787#31974787
- /questions/31974773/kak-vyipolnit-modulnoe-testirovanie-s-pomoschyu-ilogger-v-aspnet-core/31974791#31974791
больше не работают из-за изменения, описанного в проблеме TState в ILogger.Log раньше был объектом, теперь FormattedLogValues
К счастью, 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);
Используйте собственный регистратор, который использует 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
}
}
Добавляя мои 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());
И, конечно, вы можете легко расширить его, чтобы высмеять любые ожидания (например, ожидания, сообщения и т. Д.)
Самое простое решение — использоватьNullLogger
. Это частьMicrosoft.Extensions.Logging.Abstractions
.
Не нужно возиться с заводами и прочими ненужными постройками. Просто добавь:
ILogger<BlogController> logger = new NullLogger<BlogController>();
Если еще актуально. Простой способ вывода журнала в тестах для.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();
}
Это легко, так как другие ответы предлагают пройти макет 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
добиться аналогичных результатов.
Основываясь еще на работе @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));
}
Уже упоминалось, что вы можете издеваться над ним, как любой другой интерфейс.
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
интерфейс, а не методы расширения.
Я создал пакет Moq.ILogger, чтобы упростить тестирование расширений ILogger.
Фактически вы можете использовать что-то вроде следующего, что ближе к вашему реальному коду.
loggerMock.VerifyLog(c => c.LogInformation(
"Index page say hello",
It.IsAny<object[]>());
Новые тесты не только легче писать, но и бесплатное обслуживание.
Репо можно найти здесь, а также есть пакет nuget (Install-Package ILogger.Moq
).
Я объяснил это также на реальном примере в своем блоге.
Короче говоря, если у вас есть следующий код:
public class PaymentsProcessor
{
private readonly IOrdersRepository _ordersRepository;
private readonly IPaymentService _paymentService;
private readonly ILogger<PaymentsProcessor> _logger;
public PaymentsProcessor(IOrdersRepository ordersRepository,
IPaymentService paymentService,
ILogger<PaymentsProcessor> logger)
{
_ordersRepository = ordersRepository;
_paymentService = paymentService;
_logger = logger;
}
public async Task ProcessOutstandingOrders()
{
var outstandingOrders = await _ordersRepository.GetOutstandingOrders();
foreach (var order in outstandingOrders)
{
try
{
var paymentTransaction = await _paymentService.CompletePayment(order);
_logger.LogInformation("Order with {orderReference} was paid {at} by {customerEmail}, having {transactionId}",
order.OrderReference,
paymentTransaction.CreateOn,
order.CustomerEmail,
paymentTransaction.TransactionId);
}
catch (Exception e)
{
_logger.LogWarning(e, "An exception occurred while completing the payment for {orderReference}",
order.OrderReference);
}
}
_logger.LogInformation("A batch of {0} outstanding orders was completed", outstandingOrders.Count);
}
}
Затем вы можете написать несколько тестов, например
[Fact]
public async Task Processing_outstanding_orders_logs_batch_size()
{
// Arrange
var ordersRepositoryMock = new Mock<IOrdersRepository>();
ordersRepositoryMock.Setup(c => c.GetOutstandingOrders())
.ReturnsAsync(GenerateOutstandingOrders(100));
var paymentServiceMock = new Mock<IPaymentService>();
paymentServiceMock
.Setup(c => c.CompletePayment(It.IsAny<Order>()))
.ReturnsAsync((Order order) => new PaymentTransaction
{
TransactionId = $"TRX-{order.OrderReference}"
});
var loggerMock = new Mock<ILogger<PaymentsProcessor>>();
var sut = new PaymentsProcessor(ordersRepositoryMock.Object, paymentServiceMock.Object, loggerMock.Object);
// Act
await sut.ProcessOutstandingOrders();
// Assert
loggerMock.VerifyLog(c => c.LogInformation("A batch of {0} outstanding orders was completed", 100));
}
[Fact]
public async Task Processing_outstanding_orders_logs_order_and_transaction_data_for_each_completed_payment()
{
// Arrange
var ordersRepositoryMock = new Mock<IOrdersRepository>();
ordersRepositoryMock.Setup(c => c.GetOutstandingOrders())
.ReturnsAsync(GenerateOutstandingOrders(100));
var paymentServiceMock = new Mock<IPaymentService>();
paymentServiceMock
.Setup(c => c.CompletePayment(It.IsAny<Order>()))
.ReturnsAsync((Order order) => new PaymentTransaction
{
TransactionId = $"TRX-{order.OrderReference}"
});
var loggerMock = new Mock<ILogger<PaymentsProcessor>>();
var sut = new PaymentsProcessor(ordersRepositoryMock.Object, paymentServiceMock.Object, loggerMock.Object);
// Act
await sut.ProcessOutstandingOrders();
// Assert
loggerMock.VerifyLog(logger => logger.LogInformation("Order with {orderReference} was paid {at} by {customerEmail}, having {transactionId}",
It.Is<string>(orderReference => orderReference.StartsWith("Reference")),
It.IsAny<DateTime>(),
It.Is<string>(customerEmail => customerEmail.Contains("@")),
It.Is<string>(transactionId => transactionId.StartsWith("TRX"))),
Times.Exactly(100));
}
[Fact]
public async Task Processing_outstanding_orders_logs_a_warning_when_payment_fails()
{
// Arrange
var ordersRepositoryMock = new Mock<IOrdersRepository>();
ordersRepositoryMock.Setup(c => c.GetOutstandingOrders())
.ReturnsAsync(GenerateOutstandingOrders(2));
var paymentServiceMock = new Mock<IPaymentService>();
paymentServiceMock
.SetupSequence(c => c.CompletePayment(It.IsAny<Order>()))
.ReturnsAsync(new PaymentTransaction
{
TransactionId = "TRX-1",
CreateOn = DateTime.Now.AddMinutes(-new Random().Next(100)),
})
.Throws(new Exception("Payment exception"));
var loggerMock = new Mock<ILogger<PaymentsProcessor>>();
var sut = new PaymentsProcessor(ordersRepositoryMock.Object, paymentServiceMock.Object, loggerMock.Object);
// Act
await sut.ProcessOutstandingOrders();
// Assert
loggerMock.VerifyLog(c => c.LogWarning(
It.Is<Exception>(paymentException => paymentException.Message.Contains("Payment exception")),
"*exception*Reference 2"));
}
Я пытался издеваться над этим интерфейсом 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;
}
}
}
Вы можете легко получить доступ ко всем зарегистрированным сообщениям и подтвердить все значимые параметры, предоставленные с ними.
И при использовании StructureMap:
var c = new Container(_ =>
{
_.For(typeof(ILogger<>)).Use(typeof(NullLogger<>));
});
Docs:
Просто создать манекен 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
интерфейс гарантирует, что это сложно.
Вы можете использовать библиотеку PosInformatique.Logging.Assertions , которая позволяет легко имитироватьILogger
интерфейс с использованием свободного стиля.
С этой библиотекой ваш модульный тест должен выглядеть так просто (не забудьте вызвать методVerifyLogs()
метод в конце, чтобы убедиться, что нет пропущенныхLog()
звонки.):
public class BlogControllerTest
{
[Fact]
public void Index_ReturnAViewResult_WithAListOfBlog()
{
var mockRepo = new Mock<IDAO<Blog>>();
mockRepo.Setup(repo => repo.GetMany(null)).Returns(GetListBlog());
var logger = new LoggerMock<BlogController>();
logger.SetupSequence()
.LogInformation("Index page say hello");
var controller = new BlogController(logger.Object, 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());
logger.VerifyLogs(); // To check no missing Log() calls.
}
}
@Махмуд Ханафи
Я обновил ваш ответ, чтобы он работал с текущим состоянием.
static class MockLogHelper
{
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<It.IsAnyType>(), It.IsAny<Exception>(), (Func<It.IsAnyType, Exception, string>)It.IsAny<object>()));
//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<It.IsAnyType>(), It.IsAny<Exception>(), (Func<It.IsAnyType, Exception, string>)It.IsAny<object>());
//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);
}
}
Используйте Telerik Just Mock, чтобы создать имитацию регистратора:
using Telerik.JustMock;
...
context = new XDbContext(Mock.Create<ILogger<XDbContext>>());
Используйте NullLogger - минималистичный регистратор, который ничего не делает.
public interface ILoggingClass
{
public void LogCritical(Exception exception);
}
public class LoggingClass : ILoggingClass
{
private readonly ILogger<LoggingClass> logger;
public LoggingClass(ILogger<LoggingClass> logger) =>
this.logger = logger;
public void LogCritical(Exception exception) =>
this.logger.LogCritical(exception, exception.Message);
}
и при использовании метода испытаний,
Регистратор ILogger = новый NullLogger();LoggingClass loggingClass = новый LoggingClass(регистратор);
и передайте loggingClass сервису для тестирования.