
Введение
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-канал!