Избирательное юнит-тестирование или ещё раз о тонких контроллерах

Автор оригинала: Steve Sanderson
  • Перевод
В дополнение к недавно упомянутой на Хабре статье о том, что полное 100%-е покрытие кода юнит-тестами почти всегда не является экономически выгодным, поскольку просто лень писать всю эту.… это требует неоправданных затрат рабочего времени и увеличивает расходы на поддержку кода, сегодня хотелось бы представить на суд общественности размышления по этому поводу Стива Сандерсона (Steve Sanderson), автора книг Pro ASP.NET MVC и Pro ASP.NET MVC V2.

Вступление

Вот уже 3 года я пишу юнит-тесты и уже год как профессионально занимаюсь TDD. И всё это время я снова и снова замечаю такую вещь: для некоторых типов кода юнит-тесты пишутся легко и непринуждённо, значительно повышая качество кода, в то время как для других требуют кучу усилий, совершенно не помогают в устранении недостатков, а скорее наоборот — становятся лишь преградой при попытке рефакторинга или улучшения.

И это неудивительно, ведь практически все хорошо зарекомендовавшие себя подходы в разных областях жизни на самом деле эффективны лишь при определённых обстоятельствах, и лишаются своих преимуществ в других условиях. Так и здесь: многие разработчики согласятся, что не всегда в написании юнит-тестов есть толк.

Итак цели написания этой заметки:
  1. понять, что же на самом деле определяет ценность юнит-тестов для данной конкретной части кода;
  2. показать несостоятельность распространённого мнения о необходимости 100%-го покрытия и обязательном написании тестов перед началом реализации каждого функционального блока.

Преимущества тестов

Весь список преимуществ наличия юнит-тестов можно свести к двум основным. Они позволяют:
  1. проектировать код непосредственно при его написании;
  2. удостовериться, что реализация действительно работает так, как было задумано.

Но возникают вопросы: зачем нам лишняя система проектирования и проверки, разве сам по себе код не несёт информацию об устройстве и поведении приложения? и если тесты не предоставляют принципиально новой информации, как они подтверждают правильность спроектированной системы? как быть с «принципом неповторяемости» (DRY)?

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

И наоборот: если код прост и очевиден, и при первом взгляде сразу ясно что он делает, то польза от преимуществ, которыми обладают юнит-тесты, сводится к нулю. К примеру, Вы пишете метод, который получает текущую дату и размер свободного места на диске, а затем передаёт эти данные другому методу. В этом случае Ваш код говорит сам за себя, дополнительным юнит-тестам просто нечего будет добавить.

Итак, польза от юнит-тестов прямо зависит от степени запутанности кода.

Цена тестирования

Среди факторов, влияющих на стоимость продукта, следующие:
  1. время, потраченное на написание тестов;
  2. время, потраченное на исправление и доработку тестов после рефакторинга кода или внесения в него других изменений;
  3. боязнь делать какие-нибудь усовершенствования в коде по причине ожидания, что из-за них некоторые тесты пофейлятся и их все придётся переписывать.


Конечно, стоимость поддержки системы тестов можно уменьшить, следуя различным рекомендациям, но всё же она может оставаться сравнительно большой.

Согласно моим наблюдениям, общая стоимость юнит-тестирования определённого участка кода очень сильно коррелирует с количеством существующих в нём зависимостей от остальных частей кода. Вы спросите почему?

Первоначальное создание. Если метод не имеет зависимостей от других блоков и попросту работает как обычная функция, принимающая один параметр, юнит-тест будет представлять собой просто список соответствий между входными и выходными данными. Но если метод принимает много параметров и взаимодействует с многими внешними сервисами через свойства класса, Вам придётся делать кучу поддельных (mock) объектов. Но стоимось этой работы невелика по сравнению со следующим пунктом.

Поддержка. Установлено, что чем больше зависимостей существует в блоке кода, тем чаще этот код вынужден подвергаться изменению (именно так и определяется неустойчивость кода). И причина ясна: на протяжении заданного промежутка времени для каждой из этих зависимостей существует вероятность изменения её сигнатуры или поведения, что выльется в необходимость обновить код и соответствующие тесты.

Обратите внимание, что эти проблемы в той же мере относятся и к ситуации, когда вы используете IoC (DI), работая с чистыми интерфейсами.

Итак, стоимость юнит-тестов прямо зависит от количества зависимостей в участке кода.

Графическое представление стоимости и преимуществ тестов

image

На этой намеренно упрощённой диаграмме показано 4 типа кода:
  • Сложный код с небольшим количеством зависимостей (участок слева вверху). Обычно это самодостаточные алгоритмы, описывающие бизнес-правила или реализующие разбор выражений. Код этого типа дешёв и прост в тестировании, а поэтому наиболее предпочтителен для юнит-тестирования.
  • Простой код с кучей зависимостей (участок справа внизу). Этот участок подписан как «Координатор», потому что код такого типа предназначен для связывания и организации взаимодействия между другими блоками кода. Такой код невыгодно тестировать: написать тесты будет дорого, а практической пользы — мизер. Рабочее время можно потратить куда более эффективно.
  • Сложный код с большим количеством зависимостей (участок справа вверху). Писать тесты для такого кода достаточно дорого, а не писать слишком рискованно. Как правило, выходом может стать его разделение на две части: кусок, вобравший в себя сложную логику (алгоритм), и кусок, сосредоточивший в себе внешние зависимости (координатор).
  • Обычный заурядный код, имеющий немного зависимостей (участок слева внизу). О коде этого типа можно не беспокоиться. С точки зрения экономической выгоды, не имеет значения будет он тестироваться или же нет.

Наконец практика. Так что там насчёт ASP.NET MVC?

В ASP.NET MVC логику приложения на первый взгляд проще всего поместить в контроллерах. И продолжая пихать бизнес-правила в контроллер последний становится слишком громоздким: он скапливает в себе сложную логику, и при этом остаётся достаточно затратным для тестирования по причине зависимости от многих объектов. Смешались в кучу кони, люди, вернее задачи разных слоёв приложения (т.н. антипаттерн «толстый контроллер»).

Во избежание подобной неразберихи, независимые части логики приложения должны быть выфакторены (извиняюсь за выражение) в классы уровня модели. Затем от оставшегося можно отделить части, всё ещё не согласующиеся с истинным предназначением чистого контроллера, и распихать их по ActionFilters, собственным ModelBinders и ActionResults.

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

Предназначение контроллеров — быть всего лишь местом встречи разных API всевозможных сервисов. Код такого контроллера легко читабелен и связывает воедино множество зависимостей. Я пришёл к выводу, что, с точки зрения экономической выгоды, моя работа значительно эффективнее, если вместо юнит-тестирования контроллеров освободившееся время уделять рефакторингу и написанию интеграционных тестов.

Заключение

На случай, если кто-то мог неверно истолковать мои слова: на самом деле я не против юнит-тестирования или TDD. Основные мои тезисы таковы:
  • судя по собственному опыту, продуктивность моей работы на протяжении долгого периода при использовании TDD выше только для для тех типов кода, для которых оно (т.е. TDD) экономически выгодно, — для сложного кода с небольшим количеством зависимостей (алгоритмы или самодостаточная бизнес-логика);
  • иногда я намеренно разделяю код на алгоритмическую и координаторную части, так что первая может быть достаточно просто подвержена юнит-тестированию, а вторая становится настолько ясной и понятной, что не нуждается в юнит-тестах; типичный пример — изъятие бизнес-логики из контроллеров;
  • я всё больше осознаю практическую ценность интеграционных тестов; для веб-приложений это обычно предполагает использование каких-нибудь инструментов для автоматизации браузеров (типа Selenium RC или WatiN); естественно, это не отменяет юнит-тестирование, но я бы предпочел потратить час на написание интеграционного теста, чтобы удостовериться, что вся система работает слаженно, чем убить этот час на написание юнит-тестов для простого кода, поведение которого для меня очевидно с первого взгляда, и который всё равно скорее всего будет изменён, как только поменяются лежащие в его основе API.

Всё вышесказанно является лишь описанием моих наблюдений, которые вполне могут не совпадать с Вашими.

Оригинал статьи

Пожалуй, осталось лишь вспомнить слова Скотта Беллвера (Scott Bellware): «TDD is not about testing, it's all about design».
Поделиться публикацией

Похожие публикации

Комментарии 11

    +3
    ведь практически все хорошо зарекомендовавшие себя подходы в разных областях жизни на самом деле эффективны лишь при определённых обстоятельствах, и лишаются своих преимуществ в других условиях

    Золотые слова.
      0
      Я вообще считаю, что систему нужно тестировать тестами только на высоком уровне, и там где 1 тест убивает сразу мнооого зайцев.
      Поддерживать 200-500 тестов то еще удовольствие…
        +3
        Во-первых писать тесты нужно всех уровней — модульные, функциональные, сценарные. Оставив только последние вы оставите много кода без покрытия. Да и сообщение о зафейленых тестах будут не информативные. Лучше находить ошибки на уровне «маленьких» тестов.

        >Поддерживать 200-500 тестов то еще удовольствие…

        Значит работаете с нетестируемым кодом. Юнит-тесты должны быть очень легкими и тестировать только интерфейс модулей. Если тест повторяет логику кода, то это либо плохой код, либо плохой тест.
          +2
          зато сообщение будет менее информативно. Более детальные тесты позволят сразу найти то, что сломалось
          –1
          Крайне не согласен с автором статьи. Он рассуждает с позиций что юнит-тесты нужны для проверки написанного кода.
          А они на самом деле нужны для проверки изменённого кода, чтобы убедиться что после изменений он работает как раньше. Это их главная и основная цель. А автор напрочь игнорит данный факт и расписывает исключительно этап производства продукта.
            0
            Почему игнорит? Просто это не озвучено явно. Например, про проблемы тестирования сильно связанных модулей (верхняя правая картинка) — на мой взгляд, очень актуально для написания тестов. Особенно с учетом того, что интерфейсы и реализация таких модулей, по наблюдениям, меняются очень часто. Получается — для того, чтобы поддерживать тесты в идеальном состоянии, придется постоянно вносить в них изменения, поскольку изменяется не только реализация, но и интерфейсы взаимодействующих кусочков. То есть, мы хотим поменять что-то в интерфейсе программы, и у нас есть тест, который мы также вынуждены изменять (а изменение теста при изменении интерфейса равносильно написанию теста с нуля). Таким образом, написание тестов более оправдано в местах, где интерфейс является стабильным, а необходимо поддерживать в основном стабильность реализации.
            +3
            Автор, спасибо за статью, но почему статья в разделе ".NET"? Я никак с ним не связан, но рад, что прочел статью.
              –1
              Решил поместил в .Net, потому что затрагивает ASP.NET MVC.
              0
              Про толстые контроллеры очень все верно сказано. У меня уже не первый случай, когда приходится сталкиваться с их появлением. Причем в некоторых случаях даже не знаешь, как подступиться к такому монстру, с чего начать рефакторинг.
                0
                Вынести бизнес логику из контроллера в модель или в слой бизнес-логики (если придерживаетесь POCO модели).
                –5
                Сорри, но…

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

                Самое читаемое