Moq: пара фич для упрощения тестов, о которых знают не все

от автора

Введение

Moq — самый популярный фреймворк для создания объектов-двойников или «моков». Если вы писали unit-тесты в .NET, вероятно, вы использовали Moq. Казалось бы, это простой и легковесный фреймворк. Что ещё можно про него рассказать?

Однако в тестах других разработчиков я постоянно вижу одни и те же фрагменты кода, которые можно улучшить. Кажется, не все знают о некоторых фичах Moq. 

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

Дисклеймер: в примерах я использую тесты на основе поведения, а не состояния. На них гораздо проще объяснить возможности Moq. Обсуждение школ юнит-тестирования мы вынесем за скобки.

Capture.In — перехват переданных аргументов

В методе Setup() вместо It.IsAny<T>() можно передать конструкцию Capture.In(collection). Тогда все переданные в метод аргументы будут сохранены в коллекции collection:

var savedOrders = new List<Order>(); mock.Setup(x => x.SaveOrder(Capture.In(savedOrders)));  // act...  var savedOrder = savedOrders.Single();

Это позволяет:

  • получить доступ к аргументам метода для более удобной работы с ними;

  • понимать, в каком порядке были сделаны вызовы метода.

Также у Capture.In есть параметр predicate, с помощью которого можно захватывать только нужные вызовы. Это будет аналогично использованию It.Is<T> вместо It.IsAny<T>.

Примеры

Допустим, у нас есть метод создания заказа, в котором нужно:

  • cначала списать додокоины со счёта клиента, если он что-то за них купил;

  • начислить кешбэк за заказ;

  • отправить событие о принятии заказа через outbox.

Тестируемый код
public interface IOutboxRepository {   Task Save(IDbConnection connection, IEvent @event, CancellationToken cancellationToken); }  public interface IAccountService {   Task ExecuteOperation(Guid accountId, decimal amount, CancellationToken cancellationToken); }  public class OrderService {   // ...      public async Task SaveOrder(Order order, CancellationToken ct)   {     if (order.CoinsSpent > 0)     {       await accountService.ExecuteOperation(         order.ClientId, -order.CoinsSpent, ct);     }          if (order.CoinsRewarded > 0)     {       await accountService.ExecuteOperation(         order.ClientId, order.CoinsRewarded, ct);     }          await using var connection = await OpenConnection();          var orderAcceptedEvent = new OrderAcceptedEvent(order.Id, order.Type);     await _outboxRepository.Save(connection, orderAcceptedEvent, ct);   } }

Пример: проверка порядка вызовов

Мы хотим проверить, что коины списываются до того, как начисляются, а методы IAccountService.ExecuteOperation были вызваны в нужном порядке. 

Verify тут не подойдёт — он может проверить только сам факт вызова методов, но не их порядок. Обычно порядок вызовов проверяется с помощью Callback:

[Test] public async Task SaveOrder_ShouldExecuteOperationsInCorrectOrder_WithCallback() {   // arrange   var order = Given.Order(coinsRewarded: 5, coinsSpent: 10);    var operations = new List<decimal>();   _accountService     .Setup(x => x.ExecuteOperation(       order.ClientId, It.IsAny<decimal>(), It.IsAny<CancellationToken>()))     .Callback((Guid _, decimal amount, CancellationToken _) =>       operations.Add(amount));    // act   await _orderService.SaveOrder(order, default);    // assert   Assert.That(operations, Is.EqualTo([-10, 5])); }

Но у Callback есть свои недостатки:

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

  • ухудшается читаемость теста из-за параметров (Guid _, decimal amount, CancellationToken _). Чем их в методе больше, тем хуже читается тест.

Давайте посмотрим, как изменится тест после использования Capture.In:

[Test] public async Task SaveOrder_ShouldExecuteOperationsInCorrectOrder_WithCapture() {   // arrange   var order = Given.Order(coinsRewarded: 5, coinsSpent: 10);    var operations = new List<decimal>();   _accountService     .Setup(x => x.ExecuteOperation(       order.ClientId, Capture.In(operations), It.IsAny<CancellationToken>()));    // act   await _orderService.SaveOrder(order, default);    // assert   Assert.That(operations, Is.EqualTo([-10, 5])); }

С помощью Capture.In получилось избавиться от Callback. Тест стал читабельнее и надёжнее за счёт проверки на этапе компиляции.

Пример: сложная проверка аргументов

Проверка объектов — ещё один сценарий, в котором Capture может улучшить тесты. Допустим, мы хотим убедиться, что было отправлено событие с правильными данными. Обычно я встречаю стандартный подход с Verify:

[Test] public async Task SaveOrder_ShouldSendCorrectEvent_WithVerify() {   // arrange   var order = Given.Order();    // act   await _orderService.SaveOrder(order, default);    // assert   _outboxRepository.Verify(x => x.Save(       It.IsAny<IDbConnection>(),       It.Is<OrderAcceptedEvent>(e => e.EventType == "OrderAcceptedEvent"                                      && e.OrderId == order.Id                                      && e.OrderType == order.Type),       It.IsAny<CancellationToken>()),     Times.Once); }

Выглядит вполне обычно. Но как тест c Verify сообщит нам о том, что сломалось, когда он упадёт? Например, если условие e.OrderId == order.Id нарушится, сообщение будет таким:

Прикладываю скрин, чтобы показать громоздкость и бесполезность ошибки

Прикладываю скрин, чтобы показать громоздкость и бесполезность ошибки

Где именно ошибка? Куда смотреть? Какое именно условие нарушено? В проверке с Verify это непонятно, придётся дебажить.

Вот бы как-то получить переданный объект Order, чтобы спокойно провалидировать его. В этом нам поможет Capture. Он позволяет получить больше деталей и упростить отладку:

[Test] public async Task SaveOrder_ShouldSendCorrectEvent_WithCapture() {   // arrange   var order = Given.Order();    var sentEvents = new List<OrderAcceptedEvent>();   _outboxRepository     .Setup(x => x.Save(       It.IsAny<IDbConnection>(), Capture.In(sentEvents), It.IsAny<CancellationToken>()));    // act   await _orderService.SaveOrder(order, default);    // assert   var sentEvent = sentEvents.Single();   Assert.That(sentEvent.EventType, Is.EqualTo("OrderAcceptedEvent"));   Assert.That(sentEvent.OrderId, Is.EqualTo(order.Id));   Assert.That(sentEvent.OrderType, Is.EqualTo(order.Type)); }

В этом тесте сообщение об ошибке будет очевидным:

Assert.That(sentEvent.OrderId, Is.EqualTo(order.Id))   Expected: c1b25b3d-468e-4aa5-9fa8-fc8e0d81c83e   But was:  7feadfe5-00ab-4684-966a-d7daac82bb25

Verifiable — проверка вызова только нужного Setup

Если после вызова .Setup(..) вызвать метод .Verifiable(), то эти вызовы будут помечены как verifiable. Потом с помощью метода .Verify() без параметров можно проверить только помеченные вызовы.

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

Пример

Рассмотрим сервис, который обрабатывает накопившиеся уведомления, отправляет их, и затем удаляет.

Код сервиса
public class NotificationService {   // ...      public async Task SendNotifications(CancellationToken cancellationToken)   {     var notifications = await _notificationRepository.GetNotifications(BatchSize);     foreach (var notification in notifications)     {       await SendNotification(notification, cancellationToken);     }   }      private async Task SendNotification(Notification notification, CancellationToken cancellationToken)   {     try     {       if (!ShouldSendNotification(notification))         return;              var success = await _sender.Send(         notification.Header,         notification.Message,         notification.Recipients,         cancellationToken);              if (success)       {         await _notificationRepository.DeleteNotification(notification.Id);       }     }     catch (Exception e)     {       // Логируем ошибку, но уведомление останется в БД для повторной обработки     }   } }

Мы хотим протестировать, что неотправленное уведомление остаётся в БД для отправки в будущем. Сэмулируем для этого exception и проверим, что notification не был удалён. Типичный тест будет выглядеть так:

[Test] public async Task SendNotifications_WhenException_ShouldKeepNotification_WithSetup() {   // arrange   var notification = new Notification(Guid.NewGuid(), "Header", "Message", ["test@gmail.com"]);   SetupNotificationsRepository([notification]);    _notificationSenderMock     .Setup(x => x.Send(       It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string[]>(), It.IsAny<CancellationToken>()))     .Throws(new Exception("Test exception"));    // act   await _notificationService.SendNotifications(default);    // assert   _notificationRepositoryMock.Verify(     x => x.DeleteNotification(notification.Id), Times.Never);   _notificationSenderMock.Verify(     x => x.Send(       It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string[]>(), It.IsAny<CancellationToken>())); }

К сожалению, это приводит к дублированию кода, делает тест менее читабельным и усложняет его поддержку. В секции assert приходится ещё раз проверять .Verify(x => x.Send(...), чтобы убедиться: метод с исключением действительно был вызван. Проверка нужна, поскольку тест может быть ложноположительным. Так бывает, если неверно засетапить тест — выбрать не ту перегрузку или не учесть логику, как, например, в ShouldSendNotification.

Вместо двух проверок мы можем упростить тест с помощью .Verifiable():

[Test] public async Task SendNotifications_WhenException_ShouldKeepNotification_WithSetup_WithVerify() {   // arrange   var notification = new Notification(Guid.NewGuid(), "Header", "Message", ["test@gmail.com"]);   SetupNotificationsRepository([notification]);    _notificationSenderMock     .Setup(x => x.Send(       It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string[]>(), It.IsAny<CancellationToken>()))     .Throws(new Exception("Test exception"))     .Verifiable();    // act   await _notificationService.SendNotifications(default);    // assert   _notificationRepositoryMock.Verify(     x => x.DeleteNotification(It.IsAny<Guid>()), Times.Never);   _notificationSenderMock.Verify(); }

При использовании Verifiable видно, что теперь секция assert стала проще и понятней. Эта разница будет более заметной, если настроек .Setup будет больше.

Mock.Of (Linq To Mocks) — компактное создание моков

Это более распространённая фича, но знают о ней не все. Суть в том, что Mock.Of позволяет описывать мок через декларативные выражения LINQ, делая код короче и чище.

Обычно мы создаём моки так:

var featureToggleServiceMock = new Mock<IFeatureToggleService>(); featureToggleServiceMock   .Setup(x => x.IsFeatureEnabled("Feature1", It.IsAny<int>()))   .Returns(true); var featureToggleService = featureToggleServiceMock.Object;

При использовании Linq to Mocks этот же код выглядит так:

var featureToggleService = Mock.Of<IFeatureToggleService>(   x => x.IsFeatureEnabled("Feature1", It.IsAny<int>()) == true &&        x.IsFeatureEnabled("Feature2", It.IsAny<int>()) == true);

Также поддерживаются вложенные моки:

var dbConnectionFactory = Mock.Of<IDbConnectionFactory>(   x => x.CreateConnection() == Task.FromResult(Mock.Of<IDbConnection>()));

Пример

Давайте посмотрим, как Mock.Of может улучшить тест на практике. Инициализация сервиса с несколькими зависимостями обычно выглядит так:

var settingsAccessorMock = new Mock<ISettingsAccessor>(); settingsAccessorMock    .Setup(x => x.GetSettings())    .Returns(new Settings("Value1", 10));  var dataProviderMock = new Mock<IDataProvider>(); dataProviderMock    .Setup(x => x.GetData(It.IsAny<string>()))    .ReturnsAsync("MyData");  var dataProviderFactoryMock = new Mock<IDataProviderFactory>(); dataProviderFactoryMock    .Setup(x => x.Create())    .ReturnsAsync(dataProviderMock.Object);  var featureToggleServiceMock = new Mock<IFeatureToggleService>(); featureToggleServiceMock    .Setup(x => x.IsFeatureEnabled("Feature1", It.IsAny<int>()))    .Returns(true); featureToggleServiceMock    .Setup(x => x.IsFeatureEnabled("Feature2", It.IsAny<int>()))    .Returns(true);  var service = new MyService(    new Mock<IProductRepository>().Object,    settingsAccessorMock.Object,    dataProviderFactoryMock.Object,    featureToggleServiceMock.Object );

После использования Mock.Of этот код будет выглядеть так:

var service = new MyService(   Mock.Of<IProductRepository>(),   Mock.Of<ISettingsAccessor>(x =>     x.GetSettings() == new Settings("Value1", 10)),   Mock.Of<IDataProviderFactory>(factory =>     factory.Create() == Task.FromResult(Mock.Of<IDataProvider>(provider =>       provider.GetData(It.IsAny<string>()) == Task.FromResult("MyData")))),   Mock.Of<IFeatureToggleService>(x =>     x.IsFeatureEnabled("Feature1", It.IsAny<int>()) == true &&     x.IsFeatureEnabled("Feature2", It.IsAny<int>()) == true) );

Так мы значительно уменьшили количество второстепенного кода, который нужен только для инициализации. Для понимания самого теста он не важен.

Заключение

Тесты — это тоже код. Важно держать их читабельными и поддерживаемыми. Используя такие фичи, как Capture.In, Verifiable, и Mock.Of, вы можете сократить объём тестового кода, повысить его читаемость и упростить сопровождение. 

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

Спасибо, что дочитали эту статью! Ставьте плюсики, если тема вам интересна, и делитесь материалом с друзьями. А чтобы постоянно быть в курсе последних новостей Dodo Engineering, подписывайтесь на наш Telegram-канал!

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

Знали ли вы о Capture.In, Verifiable и Mock.Of?

0% Знал0
56.25% Знал, но не обо всех18
43.75% Не знал14

Проголосовали 32 пользователя. Воздержался 1 пользователь.

ссылка на оригинал статьи https://habr.com/ru/articles/885198/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *