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
100% Знал, но не обо всех1
0% Не знал0
Проголосовал 1 пользователь. Воздержавшихся нет.
Источник: https://habr.com/ru/companies/dododev/articles/885198/


Интересные статьи

Интересные статьи

Straight Skeleton 2D является очень красивым алгоритмом в компьютерной графике. На основе простых составляющих можно создавать разнообразные геометрические формы и придавать им фантастический вид в ди...
Всем привет! Меня зовут Артем Первухин, я — продакшн-директор performance-агентства Кинетика. В компании мы уже более 15 лет помогаем компаниям успешно продвигаться в сети.Но иногда я с SEO-командой с...
Появившиеся в 2006 году сервисы Google по работе с текстовыми документами (Google Docs) и таблицами (Google Sheets), дополненные 6 лет спустя возможностями работы с вирту...
У некоторых бизнес-тренеров в области е-коммерса и консультантов по увеличению интернет-продаж на многие вопросы часто можно слышать универсальную отмазку — «надо тестировать» или другую (чтобы не...
Тема статьи навеяна результатами наблюдений за методикой создания шаблонов различными разработчиками, чьи проекты попадали мне на поддержку. Порой разобраться в, казалось бы, такой простой сущности ка...