Pull to refresh

Почему использование юнит тестов это отличная инвестиция в качественную архитектуру

IT systems testing *Programming *C# *
На понимание факта, что юнит тесты это не только инструмент борьбы с регрессией в коде, но также и отличная инвестиция в качественную архитектуру меня натолкнул топик, посвященный модульному тестированию в одном англоязычном .net сообществе. Автора топика звали Джонни и он описывал свой первый (и последний) день в компании, занимавшейся разработкой программного обеспечения для предприятий финансового сектора. Джонни претендовал на вакансию разработчика модульных тестов и был расстроен низким качеством кода, который ему вменялось тестировать. Он сравнил увиденный им код со свалкой, набитой объектами, бесконтрольно создающими друг друга в любых непригодных для этого местах. Также он писал, что ему так и не удалось найти в репозитории абстрактные типы данных, код состоял исключительно из туго переплетенных в один клубок реализаций, перекрестно вызывающих друг друга. Джонни, понимая всю бесполезность применения практики модульного тестирования в этой компании, обрисовал ситуацию нанявшему его менеджеру и, отказавшись от дальнейшего сотрудничества, дал напоследок ценный, с его точки зрения, совет. Он посоветовал отправить команду разработчиков на курсы, где бы их смогли научить правильно инстанцировать объекты и пользоваться преимуществами абстрактных типов данных. Я не знаю, последовал ли менеджер совету (думаю, что нет), но если вам интересно, что имел в виду Джонни и как использование практик модульного тестирования может повлиять на качество вашей архитектуры, добро пожаловать под кат, будем разбираться вместе.

Изоляция зависимостей — основа модульного тестирования

Модульным или юнит тестом называется тест, проверяющий функционал модуля в изоляции от его зависимостей. Под изоляцией зависимостей понимается подмена реальных объектов, с которыми взаимодействует тестируемый модуль, на заглушки, имитирующие корректное поведение своих прототипов. Такая подмена позволяет сосредоточиться на тестировании конкретного модуля, игнорируя возможность некорректного поведения его окружения. Из необходимости в рамках теста подменять зависимости вытекает интересное свойство. Разработчик, понимающий, что его код будет использоваться в том числе и в модульных тестах, вынужден разрабатывать, пользуясь всеми преимуществами абстракций, и рефакторить при первых признаках появления высокой связанности.

Пример для наглядности

Давайте попытаемся представить, как мог бы выглядеть модуль отправки личных сообщения в системе, разработанной компанией, из которой сбежал Джонни. И как бы выглядел этот же модуль, если бы разработчики применяли модульное тестирование. Наш модуль должен уметь 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 корректна и не содержит ошибок.

Тесты
Тест реализован с помощью изоляционного фреймворка Moq
[TestMethod]
public void AddMessage_MessageAdded_SavedOnce()
{
    //Arrange
    //отправитель
    Guid messageAuthorId = Guid.NewGuid();
    //получатель, находящийся онлайн
    Guid recieverId = Guid.NewGuid();
    //сообщение, посылаемое от отправителя получателю
    string msg = "message";
    // стаб для метода IsUserOnline интерфейса IUserService
    Mock<IUserService> userServiceStub = new Mock<IUserService>(new MockBehavior());
    userServiceStub.Setup(x => x.IsUserOnline(It.IsAny<Guid>())).Returns(true);
    //моки для 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, recieverId, msg);

    //Assert
    repositoryMoq.Verify(x => x.SaveMessage(messageAuthorId, recieverId, msg), Times.Once);
   
}

[TestMethod]
public void AddMessage_MessageSendedToOffnlineUser_NotificationDoesntRecieved()
{
    //Arrange
    //отправитель
    Guid messageAuthorId = Guid.NewGuid();
    //получатель находящийся оффлайн
    Guid offlineReciever = Guid.NewGuid();
    //сообщение, посылаемое от отправителя получателю
    string msg = "message";
    // стаб для метода IsUserOnline интерфейса IUserService
    Mock<IUserService> userServiceStub = new Mock<IUserService>(new MockBehavior());
    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, offlineReciever, msg);

    //Assert
    notificationsServiceMoq.Verify(x => x.SendNotificationToUser(messageAuthorId, offlineReciever, msg),
                                    Times.Never);
}

[TestMethod]
public void AddMessage_MessageSendedToOnlineUser_NotificationRecieved()
{
    //Arrange
    //отправитель
    Guid messageAuthorId = Guid.NewGuid();
    //получатель, находящийся онлайн
    Guid onlineRecieverId = Guid.NewGuid();
    //сообщение, посылаемое от отправителя получателю
    string msg = "message";
    // стаб для метода IsUserOnline интерфейса IUserService
    Mock<IUserService> userServiceStub = new Mock<IUserService>(new MockBehavior());
    userServiceStub.Setup(x => x.IsUserOnline(onlineRecieverId)).Returns(true);
    //моки для 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
    notificationsServiceMoq.Verify(x => x.SendNotificationToUser(messageAuthorId, onlineRecieverId, msg),
                                    Times.Once);
}



Поиск идеальной архитектуры — бесполезное занятие

Юнит тесты — это отличная проверка архитектуры на низкую связанность между модулями. Но следует помнить, что проектирование сложных технических систем — это всегда поиск компромисса. Идеальной архитектуры не бывает, учесть все возможные сценарии развития приложения при проектировании невозможно. Качество архитектуры зависит от множества параметров, часто друг друга взаимоисключающих. Любую проблему дизайна можно решить путём введения дополнительного уровня абстракции, кроме проблемы слишком большого количества уровней абстракций. Не стоит рассматривать как догму, что взаимодействие между объектами должно быть построено только на основе абстракций, главное чтобы выбор, совершенный вами, был осознанным и вы понимали, что код, допускающий взаимодействие между реализациями, становится менее гибким и, как следствие, теряет возможность быть протестированным модульными тестами.
Tags:
Hubs:
Total votes 41: ↑31 and ↓10 +21
Views 21K
Comments 105
Comments Comments 105