
Введение
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-канал!
ссылка на оригинал статьи https://habr.com/ru/articles/885198/
Добавить комментарий