На понимание факта, что юнит тесты это не только инструмент борьбы с регрессией в коде, но также и отличная инвестиция в качественную архитектуру меня натолкнул топик, посвященный модульному тестированию в одном англоязычном .net сообществе. Автора топика звали Джонни и он описывал свой первый (и последний) день в компании, занимавшейся разработкой программного обеспечения для предприятий финансового сектора. Джонни претендовал на вакансию разработчика модульных тестов и был расстроен низким качеством кода, который ему вменялось тестировать. Он сравнил увиденный им код со свалкой, набитой объектами, бесконтрольно создающими друг друга в любых непригодных для этого местах. Также он писал, что ему так и не удалось найти в репозитории абстрактные типы данных, код состоял исключительно из туго переплетенных в один клубок реализаций, перекрестно вызывающих друг друга. Джонни, понимая всю бесполезность применения практики модульного тестирования в этой компании, обрисовал ситуацию нанявшему его менеджеру и, отказавшись от дальнейшего сотрудничества, дал напоследок ценный, с его точки зрения, совет. Он посоветовал отправить команду разработчиков на курсы, где бы их смогли научить правильно инстанцировать объекты и пользоваться преимуществами абстрактных типов данных. Я не знаю, последовал ли менеджер совету (думаю, что нет), но если вам интересно, что имел в виду Джонни и как использование практик модульного тестирования может повлиять на качество вашей архитектуры, добро пожаловать под кат, будем разбираться вместе.
Изоляция зависимостей — основа модульного тестирования
Модульным или юнит тестом называется тест, проверяющий функционал модуля в изоляции от его зависимостей. Под изоляцией зависимостей понимается подмена реальных объектов, с которыми взаимодействует тестируемый модуль, на заглушки, имитирующие корректное поведение своих прототипов. Такая подмена позволяет сосредоточиться на тестировании конкретного модуля, игнорируя возможность некорректного поведения его окружения. Из необходимости в рамках теста подменять зависимости вытекает интересное свойство. Разработчик, понимающий, что его код будет использоваться в том числе и в модульных тестах, вынужден разрабатывать, пользуясь всеми преимуществами абстракций, и рефакторить при первых признаках появления высокой связанности. В его коде начинают появляться фабрики и IoC контейнер, а на столе книга gof про паттерны.
Пример для наглядности
Давайте попытаемся представить, как мог бы выглядеть модуль отправки личных сообщения в системе, разработанной компанией, из которой сбежал Джонни. И как бы выглядел этот же модуль, если бы разработчики применяли модульное тестирование. Наш модуль должен уметь cохранять сообщение в базе данных и, если пользователь, которому было адресовано сообщение, находится в системе — отображать сообщение на его экране всплывающим уведомлением.
//Модуль отправки сообщений на языке C#. Версия 1.
public class MessagingService
{
public void SendMessage(Guid messageAuthorId, Guid messageRecieverId, string message)
{
//объект репозиторий сохраняет текст сообщения в базе данных
new MessagesRepository().SaveMessage(messageAuthorId, messageRecieverId, message);
//проверяем, находится ли пользователь онлайн
if (UsersService.IsUserOnline(messageRecieverId))
{
//отправляем всплывающее уведомление, вызвав метод статического объекта
NotificationsService.SendNotificationToUser(messageAuthorId, messageRecieverId, message);
}
}
}
Давайте посмотрим — какие зависимости есть у нашего модуля. В функции SendMessage вызываются статические методы объектов NotificationsService, UsersService и создается объект MessagesRepository, ответственный за работу с базой данных. В том, что наш модуль взаимодействует с другими объектами проблемы нет. Проблема в том, как построено это взаимодействие, а построено оно не удачно. Прямое обращение к методам сторонних объектов сделало наш модуль крепко связанным с конкретными реализациями. У такого взаимодействия есть много минусов, но для нас главное то, что модуль MessagingService потерял возможность быть протестированным в отрыве от реализаций объектов NotificationsService, UsersService и MessagesRepository. Мы действительно не можем в рамках модульного теста, подменить эти объекты на заглушки.
Теперь давайте посмотрим, как выглядел бы этот же модуль, если бы разработчик позаботился о его тестируемости.
//Модуль отправки сообщений на языке C#. Версия 2.
public class MessagingService: IMessagingService
{
private readonly IUserService _userService;
private readonly INotificationService _notificationService;
private readonly IMessagesRepository _messagesRepository;
public MessagingService(IUserService userService, INotificationService notificationService, IMessagesRepository messagesRepository)
{
_userService = userService;
_notificationService = notificationService;
_messagesRepository = messagesRepository;
}
public void AddMessage(Guid messageAuthorId, Guid messageRecieverId, string message)
{
//объект репозиторий сохраняет текст сообщения в базе данных
_messagesRepository.SaveMessage(messageAuthorId, messageRecieverId, message);
//проверяем, находится ли пользователь онлайн
if (_userService.IsUserOnline(messageRecieverId))
{
//отправляем всплывающее уведомление
_notificationService.SendNotificationToUser(messageAuthorId, messageRecieverId, message);
}
}
}
Эта версия уже намного лучше. Теперь взаимодействие между объектами строится не напрямую, а через интерфейсы. Мы больше не обращаемся к статическим классам и не инстанцируем объекты в методах с бизнес логикой. И, самое главное, все зависимости мы теперь можем подменить, передав в конструктор заглушки для теста. Таким образом, добиваясь тестируемости нашего кода, мы смогли параллельно улучшить архитектуру нашего приложения. Нам пришлось отказаться от прямого использования реализаций в пользу интерфейсов и мы перенесли инстанцирование на слой находящийся уровнем выше. А это именно то, чего хотел Джонни.
Пишем тест к модулю отправки сообщений
- факт однократного вызова метода IMessageRepository.SaveMessage
- факт однократного вызова метода INotificationsService.SendNotificationToUser(), в случае если метод IsUserOnline() стаба над объектом IUsersService вернул true
- отсутствие вызова метода INotificationsService.SendNotificationToUser(), в случае если метод IsUserOnline() стаба над объектом IUsersService вернул false
Выполнение этих трех условий гарантирует нам, что реализация метода SendMessage корректна и не содержит ошибок.
[TestMethod]
public void SendMessageFullTest()
{
//Arrange
//отправитель
Guid messageAuthorId = Guid.NewGuid();
//получатель, находящийся онлайн
Guid onlineRecieverId = Guid.NewGuid();
//получатель находящийся оффлайн
Guid offlineReciever = Guid.NewGuid();
//сообщение, посылаемое от отправителя получателю
string msg = "message";
// стаб для метода IsUserOnline интерфейса IUserService
Mock<IUserService> userServiceStub = new Mock<IUserService>(new MockBehavior());
userServiceStub.Setup(x => x.IsUserOnline(onlineRecieverId)).Returns(true);
userServiceStub.Setup(x => x.IsUserOnline(offlineReciever)).Returns(false);
//моки для INotificationService и IMessagesRepository
Mock<INotificationService> notificationsServiceMoq = new Mock<INotificationService>();
Mock<IMessagesRepository> repositoryMoq = new Mock<IMessagesRepository>();
//создаем модуль сообщений, передавая в качестве его зависимостей моки и стабы
var messagingService = new MessagingService(userServiceStub.Object, notificationsServiceMoq.Object,
repositoryMoq.Object);
//Act. Отправка сообщения пользователю находящемуся онлайн
messagingService.AddMessage(messageAuthorId, onlineRecieverId, msg);
//Assert
repositoryMoq.Verify(x => x.SaveMessage(messageAuthorId, onlineRecieverId, msg), Times.Once);
notificationsServiceMoq.Verify(x => x.SendNotificationToUser(messageAuthorId, onlineRecieverId, msg),
Times.Once);
//Сбрасываем счетчики вызовов
repositoryMoq.ResetCalls();
notificationsServiceMoq.ResetCalls();
//Act. Отправка сообщения пользователю находящемуся оффлайн
new MessagingService(userServiceStub.Object, notificationsServiceMoq.Object, repositoryMoq.Object)
.AddMessage(messageAuthorId, offlineReciever, msg);
//Assert
repositoryMoq.Verify(x => x.SaveMessage(messageAuthorId, offlineReciever, msg), Times.Once);
notificationsServiceMoq.Verify(x => x.SendNotificationToUser(messageAuthorId, offlineReciever, msg),
Times.Never);
}
Автор: steamru