Как стать автором
Обновить
Dodo Engineering
О том, как разработчики строят IT в Dodo

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

Уровень сложностиСредний
Время на прочтение9 мин
Количество просмотров3.3K

Введение

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
57.5% Знал, но не обо всех23
42.5% Не знал17
Проголосовали 40 пользователей. Воздержался 1 пользователь.
Теги:
Хабы:
Всего голосов 12: ↑11 и ↓1+11
Комментарии2

Публикации

Информация

Сайт
dodoengineering.ru
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия