Если вы уже устали читать статьи о том, как применять модульное тестирование в новых приложениях, в то время как ваша жизнь в основном занята расширением и улучшением унаследованного кода – изложу вам план, который (наконец-то) поможет воспользоваться возможностями автоматизированного тестирования при работе с имеющимися приложениями. Это проще, чем кажется, особенно, если вы переложите на Visual Studio и JustMock всю тяжелую работу.

Люблю читать статьи о том, как приступить к автоматизированному модульному тестированию, потому что они полностью оторваны от реальности. Во всех таких статьях предполагается, что вы выстраиваете некое свежее приложение с чистого листа, но такого – будем честны – практически не бывает. Все мы знаем, что от 70% до 90% времени разработчика тратится на улучшение, расширение, модификацию и (иногда) исправление приложений, которые уже работают в продакшене. А я еще я добавлю, что никто не захочет вам платить за обвязку модульными тестами таких «уже существующих/унаследованных» приложений.

Но, поскольку большая часть вашего времени тратится на внесение изменений в такие унаследованные приложения, вы можете сделать хотя бы две вещи:

  1. Применять модульное тестирование на тех участках приложения, которое вы меняете

  2. Использовать имитационный инструмент (Telerik JustMock, например), чтобы заполнять те места, с которыми вы вынуждены работать в одиночку

Такая стратегия совершенно логично, поскольку, в конце концов, та часть приложения, которую вы не трогали, как работала, так и работает (предположительно). Опасная зона – та, которую стоит покрывать модульными тестами – именно та, в которую вы вносите изменения.

Спойлер: Понадобится некоторый рефакторинг.

Кейс об унаследованном приложении

В качестве кейса разберем приложение, в котором есть страница для расчета стоимости доставки товара. Предположим, что это приложение ASP.NET MVC, но то, что мы с ним проделаем, в равной степени успешно сработает и с WebForms, и с десктопным приложением. (Вот стартовый код для данного проекта, до того, как мы внесем в него какие-либо изменения.)

На этой странице пользователь выбирает товар для доставки, затем выбирает, в каком количестве его доставить, затем выбирает уровень срочности (Высокий, Средний, Низкий). Затем пользователь щелкает на странице кнопку «Submit» (Отправить) – и данные отправляются на обработку.

Следовательно, где-то в кишках этого приложения ShippingManager существует метод, обрабатывающий пользовательские данные: объект Product (у которого есть вес, высота, ширина и особые инструкции по доставке, например, «Не кантовать»), количество экземпляров к доставке и срочность доставки. Затем данный метод вызывает метод CalcShipCost, который на основании всей вышеприведенной информации рассчитывает стоимость доставки. Излишне говорить, что метод CalcShipCost также использует несколько глобальных переменных, объявляемых на уровне класса (они называются полями).

Этот метод выглядит примерно так:

[HttpPost]
public ActionResult ShipProduct(IProduct prod, int qty, ShippingUrgency urg)
{
   //…какой-то код…
   **decimal shipCost = CalcShipCost(prod, qty, urg);**
   //…еще код…

   ShipAcceptModel model = new ShipAcceptModel();
   model.ShipCost = shipCost;
   return View(model);
}

Вот в чем проблема: в компании написали метод CalcShippingCost, когда этот вариант доставки был безальтернативным. Теперь компания хочет сохранить этот метод доставки, но «расширить» набор опций, чтобы пользователь мог выбирать и другие методы (FedEx, UPS, USPS, еще какие-нибудь).

Я вижу тут как минимум три задания (два из которых я придумал себе сам):

  1. Усовершенствовать приложение, так, чтобы в нем поддерживались новые методы доставки.

  2. Делать работу так, чтобы сократились затраты на поддержку.

  3. Делать работу так, чтобы в ходе нее поддерживалось автоматизированное тестирование, и мне не приходилось вручную выполнять регрессионные тесты и доказывать: всякий раз, когда я вношу изменения – все работает. Рефакторинг для облегчения поддержки (и тестирования) кода.

Рефакторинг для облегчения поддержки (и тестирования) кода

Можно было бы подумать, что на данном этапе простейший способ расширить код – это поместить в CalcShipCosts оператор выбора (switch), проверяющий каждый вариант доставки, а затем делающий что надо. В таком случае, согласитесь, никакого модульного тестирования у вас не получится.

Ведь у вас, конечно же, есть приложение, и всякий раз, когда вы пытаетесь добавить в него метод доставки или изменить такой метод – возникают существенные издержки. При каждом таком изменении вам приходится:

  • Переписать метод CalcShipCosts.

  • Вручную выполнить регрессионное тестирование для каждого сценария доставки, чтобы убедиться, что все эти сценарии по-прежнему работают.

Если вы внедрите паттерн Стратегия, то у вас получится приложение, которое сравнительно недорого поддерживать. При Стратегии вы выделяете различные процессы расчета доставки, так, чтобы их можно было изменять независимо – как друг от друга, так и от всего приложения (этот паттерн также позволит вам добавлять новые методы доставки без необходимости переписывать как уже имеющиеся способы доставки, так и само приложение).

Если вы воспользуетесь некоторыми фокусами из Visual Studio, которые я собираюсь далее вам показать, то рефакторинг не займет много времени (пожалуй, и часа хватит), и вы сможете выполнить его, не нарушая работу остальной части приложения. А еще у вас получится приложение, в котором можно будет обойтись полностью без ручного регрессионного тестирования – но это так, вишенка на торте.  

Рефакторинг под паттерн Стратегия означает, что изначально мой пересмотренный метод ShipProduct будет выглядеть вот так (а со временем значительно упростится):

[HttpPost]
public ActionResult ShipProduct(IProduct prod, int qty, ShippingUrgency urg, ShippingMethod meth)
{
   IShippingStrategy sStrat = new OriginalShip();
   switch(meth)
   {
       case ShippingMethod.USPS:
                    sStrat = new USPSShip();
                     break;
       case ShippingMethod.FedEx:
                    sStrat = new FedexShip();
                     break;
      //…другие способы доставки…
   }
   decimal shipCost = CalcShipCost(prod, qty, urg, sStrat);

   ShipAcceptModel model = new ShipAcceptModel();
   model.ShipCost = shipCost;
   return View(model);
}

Причем, я просто ввел код в таком виде, как показано здесь, проигнорировав все красные волнистые линии, сгенерированные Visual Studio – конечно, она же никогда не слышала об этих новых классах. Каждый из новых классов (FedExShip, UPSShip, др.) будет содержать код, уникальный для конкретного способа доставки (назову их «стратегическими классами»). Чтобы гарантировать взаимозаменяемость всех моих стратегических классов, также изобрету интерфейс IShipping, который должны будут реализовывать все мои стратегические классы.

Следующий шаг - прикажу Visual Studio сгенерировать для меня мои интерфейсы и классы. Наведу курсор на ссылку, указывающую на IShippingStrategy, щелкну по умной вкладке, которая возникнет перед именем класса и выберу их открывшегося меню вариант “Generate Interface ‘IShippingStrategy’ in a new file” (Сгенерировать экземпляр ‘IShippingStrategy’ в новом файле). Проделаю то же самое с каждым из моих стратегических классов – и вот у меня появилось несколько новых файлов.

Создать перечисление ShippingMethod почти так же легко: щелкаю в моем коде по ShippingMethod и выбираю “Generate new type.” («Сгенерировать новый тип»).

В результате всплывает диалоговое окно, в котором я указываю: хочу, чтобы этот новый тип был публичным перечислением, созданным в новом файле. Щелкаю OK – и, опять же, у меня получился новый файл.

К сожалению, Visual Studio положила эти новые файлы в папку «Controllers» (Контроллеры). В Обозревателе Решений (Solution Explorer) перетащу эти файлы туда, где хочу их видеть, а именно, в папку «Models» (Модели). Делая это, изменю область видимости каждого класса с внутренней на публичную и обновлю их пространства имен с учетом их нового местоположения. Также заполню мое новое перечисление нужными значениями (USPS, FedEx, т.д.).

Наконец, мой метод CalcShipCost пока не приспособлен для приема нового параметра IShippingStrategy, который я ввел. Чтобы это исправить, наведу курсор на вызов к CalcShipCost, щелкну по умному тегу и выберу “Add parameter to …” («Добавить параметр к…»). Visual Studio исправит мой метод CalcShipCost так, чтобы он принимал новый параметр.

Генерирую мой первый объект Стратегия.

Я не избавляюсь от метода CalcShipCost по двум причинам. Во-первых, в этом методе есть некоторый код, который не зависит от способа доставки. Во-вторых, компания собирается и далее использовать код в методе, зависящем от актуального способа доставки. Таким образом, я вырежу из имеющегося CalcShipCost ту часть кода, которая зависит от актуального метода доставки и перенесу этот код в мой новый класс OriginalShip (внутрь него я положу метод, который назову CalcMethodCost).

Точнее: я собираюсь заставить Visual Studio сделать все это за меня.

Исходный метод CalcShipCost выглядит примерно так, как показано ниже (возможно, после некоторой перетасовки кода – которую мне придется делать, даже если я ограничусь решением «просто вставить switch-блок»):

private decimal CalcShipCost(IProduct prod, int qty, ShippingUrgency urg, IShippingStrategy sStrat)
{
   decimal extraCharges = 0;
   bool areExtraCharges = false;

   // код, который, как я понимаю, будет иным, если мы выберем какой-то другой способ доставки 
  decimal shippingCost = 0;
  if (prod.weight > 100)
  {
     shippingCost += 100 * SalesTax;
  }
  if (qty < 50) { ….more code …}
 switch (urg)
 {
     …various case statements
 }
  //… еще очень много такого кода

  // код, который, как я понимаю, не зависит от того, как именно мы доставляем товар 
  if (areExtraCharges)
  {
     shippingCost += extraCharges;
  }
  //…еще очень много такого кода…

  return shippingCost;
}

Кстати, если вы присматривались к этому коду достаточно внимательно – и вас заинтересовало, откуда взялся SalesTax – это как раз одно из тех полей, что я упоминал выше, которые объявляются на уровне класса и используются в приложении повсюду.  

Мне не составляет труда создать мой первый объект «стратегия», если я воспользуюсь Visual Studio и сделаю это в два этапа. Сначала я выделю в моем исходном методе CalcShipCost тот код, который хочу перенести в мой новый метод. Щелкну по выделенному правой кнопкой мыши и выберу из всплывающего меню «Quick Actions and Refactorings» (Быстрые действия и рефакторинг).

Так отображается меню, в котором я выберу “Extract Method.” (Извлечь метод).

Когда вы щелкаете в меню по варианту Extract Method, Visual Studio отображает диалоговое окно, извлекает тот код, что я выделил, и заменяет его на вызов нового метода, который называется (хитро!) NewMethod. Я переименовываю NewMethod в CalcMethodCost в моем оригинальном коде и нажимаю в диалоговом окне кнопку «Apply» (Применить). Visual Studio магически создает для меня новый метод.

Щелкаю по вызову моего нового метода и нажимаю F12, чтобы перейти к методу. Оказавшись в нем, я вырезаю метод из приложения ShippingManager и вставляю его в мой класс OriginalShip: вот у меня и есть первый класс-стратегия. Удаляю добавленный к методу модификатор static и меняю область видимости метода с приватной на публичную.

Наконец, вернувшись в CalcShipCost, я вызываю мой новый метод из параметра IShippingStrategy:

private decimal CalcShipCost(IProduct prod, int qty, ShippingUrgency urg, IShippingStrategy sStrat)
{
   decimal extraCharges = 0;
   bool areExtraCharges = false;

   **shippingCost = sStrat.CalcMethodCost(prod, qty, urg);**

   if (areExtraCharges)
   {
      shippingCost += extraCharges;
   }
   //…еще код…

   return shippingCost;
}

А вот и мой первый объект-стратегия:

public class OriginalShip: IShippingStrategy
{
   public decimal CalcMethodCost(IProduct prod, int qty, ShippingUrgency urg)
   {
      //…код с расчетом стоимости доставки, извлеченный из исходного метода …
   }
}

Далее могу выстроить мой интерфейс: копирую первую строку из моего метода CalcMethodCost, вставляю ее в IShippingStrategy, удаляю публичную область видимости и ставлю в конце точку с запятой:

в IShippingStrategy, удаляю публичную область видимости и ставлю в конце точку с запятой:
// Интерфейс объекта «стратегия»
public interface IShippingStrategy
{
   decimal CalcMethodCost(IProduct prod, int qty, ShippingUrgency urg);
}

Затем делаю так, чтобы Visual Studio выполнила сборку и обнаружила, что мой новый класс OriginalShip не знает, откуда берется SalesTax (или, если уж на то пошло, откуда берутся все остальные поля из ShippingManager). Самое простое решение в данном случае – добавить SalesTax в качестве еще одного значения, передаваемого вызову к CalcMethodCost и вновь воспользоваться возможностью “Add parameter to …”. К сожалению, так я просто обновлю мой интерфейс, поэтому придется добавлять параметр к CalcMethodCost в OriginalShip самостоятельно.

Теперь, когда мой интерфейс IShippingStrategy определен, прикажу Visual Studio реализовать этот интерфейс в других моих классах стратегий: щелкну по имени интерфейса в каждом из этих классов, щелкну по умной вкладке, которая появится, и выберу возможность “Implement Interface.” (Реализовать интерфейс). Теперь мой код компилируется, во всех моих объектах-стратегиях есть метод CalcMethodCost, рефакторинг почти закончен.

Рефакторинг под фабрики

Однако, поскольку дело пошло так быстро (пожалуй, на всю работу у меня ушло менее часа), я решил внести в метод ShipProduct еще одно изменение. Пока еще код, создающий объект-стратегию, сидит в моем исходном методе, и поэтому я ограничен в возможностях добавления новых методов доставки. Решаю реализовать здесь паттерн «фабричный метод» — поставлю блок выбора, который позволяет внести в метод доставки нужный объект-стратегию, и сделаю для этой цели свой отдельный класс.

Сейчас будет “наша песня хороша, начинай сначала”: добавляю строку кода в ShippingManager, чтобы создать экземпляр моего фабричного класса, а затем приказываю Visual Studio создать этот класс. Выбираю блок switch и при помощи метода «Extract» (Извлечь) вставляю этот блок в метод. Нажав F12, чтобы переключиться на этот метод, я вырезаю его из ShippingManager и вставляю в мой новый класс. Как только метод таким образом переедет, я убираю в нем модификатор static и меняю область видимости моего метода с приватной на публичную. Класс я также делаю публичным, меняю его пространство имен и перетаскиваю класс в папку «Models» (Модели). Наконец, я добился того, чтобы оригинальный код в ShippingManager использовал новый класс, когда вызывает переработанный метод.

Таким образом, оригинальный метод приобретает следующий вид:

[HttpPost]
public ActionResult ShipProduct(IProduct prod, int qty, ShippingUrgency urg, ShippingMethod meth)
{
   //… еще код
   ShippingMethodFactory smf = new ShippingMethodFactory();
   IShippingStrategy sStrat = smf.GetShippingMethod(meth);
   decimal shipCost = CalcShipCost(prod, qty, urg, meth, sStrat);
   //… еще код
   return View(model);
}

Мой класс ShippingMethodFactory весьма прост и выглядит вот так:

public class ShippingMethodFactory
{
   public IShippingStrategy GetShippingMethod(ShippingMethod meth)
   {
      IShippingStrategy sStrat = new OriginalShip();
      switch (meth)
      {
        //… методы, которые должны возвращать правильный объект-стратегию …
      }
      return sStrat;
   }
}

На этот дополнительный факторинг уходит еще примерно пять минут. Теперь, если компания добавит какой-нибудь новый способ доставки, я смогу переписать мой фабричный метод, чтобы он возвращал объект-стратегию, связанный с данным способом доставки, а мой метод CalcShipCost оставлю в покое.

Есть и другие преимущества: оказывается, чтобы объект-стратегия работал правильно, ему нужен некоторый конфигурационный код, и этот код я также могу поместить в мой фабричный метод. Если я так сделаю, то, как только разработчику понадобится объект-стратегия, соответствующий способу доставки, достаточно будет вызвать GetShippingMethod – и не сомневаться, что получишь в ответ готовый к использованию объект.

Окончательный рефакторинг

В данный момент я осознаю, что, если сделаю еще один небольшой рефакторинг и перенесу вызов CalcShipCost в свой собственный класс, то у меня получится автономный калькулятор доставки. Это также позволит мне делать модульное тестирование на уровне этого нового калькулятора – тест на уровне компонента. Считаю, что этим стоит заняться.

Опять же, задействую Visual Studio, чтобы она переписала мой исходный метод ShipProduct из следующего состояния:

состояния:
ShippingMethodFactory smf = new ShippingMethodFactory();
IShippingStrategy sStrat = smf.GetShippingMethod(meth);
decimal shipCost = CalcShipCost(prod, qty, urg, meth, sStrat);

в следующее:

ShippingCostCalculator scc = new ShippingCostCalculator();
decimal shipCost = scc.CalcShipping(prod, qty, urg, meth, SalesTax);

Мой новый класс примет вид:

public class ShippingCostCalculator
{
  public decimal CalcShipping(IProduct prod, int qty, ShippingUrgency urg, ShippingMethod meth, decimal salesTax)
  {
      ShippingMethodFactory smf = new ShippingMethodFactory();
      IShippingStrategy sStrat = smf.GetShippingMethod(meth);
      return CalcShipCost(prod, qty, urg, sStrat, salesTax);
  }
  private decimal CalcShipCost(IProduct prod, int qty, ShippingUrgency urg, decimal salesTax )
  {
     //…скопировано из приложения…
}

Так я смогу писать компонентные тесты для моего нового калькулятора, и эти тесты позволят доказать, что весь мой код в совокупности работает и выдает именно такие результаты, каких ожидает бизнес.

Здесь находится код в том виде, какой он принимает после всех этапов рефакторинга.

Оглядываясь назад

Можно обойтись и меньшей работой, чем я выполнил здесь. Возможно, вы решите, что фабричный класс вам не нужен. Решить, что, если ваши модульные тесты работают, то можно переходить непосредственно к интеграции и не заморачиваться написанием компонентных тестов (только отмечу, что, если какому-нибудь другому приложению когда-нибудь потребуется рассчитать расходы на доставку товара, то можно, всего лишь обновив некоторые пространства имен, вынести ShippingCostCalculator в его собственный библиотечный класс и предоставить к нему общий доступ.)

Тем не менее, сколько бы работы вы ни сделали, после переработки дизайн улучшится. Мы перешли от монолитного приложения для доставки к приложению с набором классов, соответствующих принципу единственной ответственности. Это класс расчета расходов (ShippingCostCalculator), фабричный класс (ShippingMethodFactory) и по одному классу со стратегией доставки на каждый способ доставки, поддерживаемый нашим бизнесом (FedExShip, UPSShip, etc.).

Итак, потратив на рефакторинг час-полтора, я сделал новый дизайн, который удобно поддерживать и – по счастливой случайности – поддающийся модульному тестированию.

Комментарии (2)


  1. ptsytovich
    16.06.2022 10:48
    +1

    А где, собственно, модульное тестирование? В статье прекрасно показан рефакторинг с использованием паттернов "стратегия" и "фабрика", но про модульное тестирование не слова, только в конце статьи. По идее надо писать тесты отдельно каждую стратегию, ну и далее интеграционные тесты под проверку контроллера. Хочется продолжения статьи.


  1. try1975
    16.06.2022 15:38

    Такое впечатление, что в издательском доме наконец-то прочитали пару книг, из тех что сами и продают. А потом применили прочитанное))