В дополнение к недавно упомянутой на Хабре статье о том, что полное 100%-е покрытие кода юнит-тестами почти всегда не является экономически выгодным, поскольку просто лень писать всю эту.… это требует неоправданных затрат рабочего времени и увеличивает расходы на поддержку кода, сегодня хотелось бы представить на суд общественности размышления по этому поводу Стива Сандерсона (Steve Sanderson), автора книг Pro ASP.NET MVC и Pro ASP.NET MVC V2.
Вот уже 3 года я пишу юнит-тесты и уже год как профессионально занимаюсь TDD. И всё это время я снова и снова замечаю такую вещь: для некоторых типов кода юнит-тесты пишутся легко и непринуждённо, значительно повышая качество кода, в то время как для других требуют кучу усилий, совершенно не помогают в устранении недостатков, а скорее наоборот — становятся лишь преградой при попытке рефакторинга или улучшения.
И это неудивительно, ведь практически все хорошо зарекомендовавшие себя подходы в разных областях жизни на самом деле эффективны лишь при определённых обстоятельствах, и лишаются своих преимуществ в других условиях. Так и здесь: многие разработчики согласятся, что не всегда в написании юнит-тестов есть толк.
Итак цели написания этой заметки:
Весь список преимуществ наличия юнит-тестов можно свести к двум основным. Они позволяют:
Но возникают вопросы: зачем нам лишняя система проектирования и проверки, разве сам по себе код не несёт информацию об устройстве и поведении приложения? и если тесты не предоставляют принципиально новой информации, как они подтверждают правильность спроектированной системы? как быть с «принципом неповторяемости» (DRY)?
Лично для меня, если при первой мысли о задаче код её реализации не становится очевидным, а значит для его написания придётся посидеть и пораскинуть мозгами, то дополнительная помощь (напр. в виде юнит-тестов), позволяющая удостовериться, что всё будет работать правильно, была бы очень кстати. Для примера, если Вы разрабатываете систему бизнес-правил или делаете разбор сложного иерархического выражения, сразу в уме не удастся проследить все возможные ветви поведения кода. В подобных случаях юнит-тесты были бы чрезвычайно ценными.
И наоборот: если код прост и очевиден, и при первом взгляде сразу ясно что он делает, то польза от преимуществ, которыми обладают юнит-тесты, сводится к нулю. К примеру, Вы пишете метод, который получает текущую дату и размер свободного места на диске, а затем передаёт эти данные другому методу. В этом случае Ваш код говорит сам за себя, дополнительным юнит-тестам просто нечего будет добавить.
Итак, польза от юнит-тестов прямо зависит от степени запутанности кода.
Среди факторов, влияющих на стоимость продукта, следующие:
Конечно, стоимость поддержки системы тестов можно уменьшить, следуя различным рекомендациям, но всё же она может оставаться сравнительно большой.
Согласно моим наблюдениям, общая стоимость юнит-тестирования определённого участка кода очень сильно коррелирует с количеством существующих в нём зависимостей от остальных частей кода. Вы спросите почему?
Первоначальное создание. Если метод не имеет зависимостей от других блоков и попросту работает как обычная функция, принимающая один параметр, юнит-тест будет представлять собой просто список соответствий между входными и выходными данными. Но если метод принимает много параметров и взаимодействует с многими внешними сервисами через свойства класса, Вам придётся делать кучу поддельных (mock) объектов. Но стоимось этой работы невелика по сравнению со следующим пунктом.
Поддержка. Установлено, что чем больше зависимостей существует в блоке кода, тем чаще этот код вынужден подвергаться изменению (именно так и определяется неустойчивость кода). И причина ясна: на протяжении заданного промежутка времени для каждой из этих зависимостей существует вероятность изменения её сигнатуры или поведения, что выльется в необходимость обновить код и соответствующие тесты.
Обратите внимание, что эти проблемы в той же мере относятся и к ситуации, когда вы используете IoC (DI), работая с чистыми интерфейсами.
Итак, стоимость юнит-тестов прямо зависит от количества зависимостей в участке кода.
На этой намеренно упрощённой диаграмме показано 4 типа кода:
В ASP.NET MVC логику приложения на первый взгляд проще всего поместить в контроллерах. И продолжая пихать бизнес-правила в контроллер последний становится слишком громоздким: он скапливает в себе сложную логику, и при этом остаётся достаточно затратным для тестирования по причине зависимости от многих объектов. Смешались в кучу кони, люди, вернее задачи разных слоёв приложения (т.н. антипаттерн «толстый контроллер»).
Во избежание подобной неразберихи, независимые части логики приложения должны быть выфакторены (извиняюсь за выражение) в классы уровня модели. Затем от оставшегося можно отделить части, всё ещё не согласующиеся с истинным предназначением чистого контроллера, и распихать их по ActionFilters, собственным ModelBinders и ActionResults.
Чем больше мы структурируем таким образом наши контроллеры, тем проще, чище, красивее они становятся, в конечном итоге вырождаясь в чистой воды координаторы, управляющие взаимодействиями между другими слоями приложения, и при этом, не имея никакой собственной дополнительной логики, вырастают в стройную систему. Иными словами, чем лучше структурированы контроллеры, тем сильнее они тяготеют к правому нижнему участку диаграммы, лишая всякого смысла их тестирование.
Предназначение контроллеров — быть всего лишь местом встречи разных API всевозможных сервисов. Код такого контроллера легко читабелен и связывает воедино множество зависимостей. Я пришёл к выводу, что, с точки зрения экономической выгоды, моя работа значительно эффективнее, если вместо юнит-тестирования контроллеров освободившееся время уделять рефакторингу и написанию интеграционных тестов.
На случай, если кто-то мог неверно истолковать мои слова: на самом деле я не против юнит-тестирования или TDD. Основные мои тезисы таковы:
Всё вышесказанно является лишь описанием моих наблюдений, которые вполне могут не совпадать с Вашими.
Оригинал статьи
Пожалуй, осталось лишь вспомнить слова Скотта Беллвера (Scott Bellware): «TDD is not about testing, it's all about design».
Вступление
Вот уже 3 года я пишу юнит-тесты и уже год как профессионально занимаюсь TDD. И всё это время я снова и снова замечаю такую вещь: для некоторых типов кода юнит-тесты пишутся легко и непринуждённо, значительно повышая качество кода, в то время как для других требуют кучу усилий, совершенно не помогают в устранении недостатков, а скорее наоборот — становятся лишь преградой при попытке рефакторинга или улучшения.
И это неудивительно, ведь практически все хорошо зарекомендовавшие себя подходы в разных областях жизни на самом деле эффективны лишь при определённых обстоятельствах, и лишаются своих преимуществ в других условиях. Так и здесь: многие разработчики согласятся, что не всегда в написании юнит-тестов есть толк.
Итак цели написания этой заметки:
- понять, что же на самом деле определяет ценность юнит-тестов для данной конкретной части кода;
- показать несостоятельность распространённого мнения о необходимости 100%-го покрытия и обязательном написании тестов перед началом реализации каждого функционального блока.
Преимущества тестов
Весь список преимуществ наличия юнит-тестов можно свести к двум основным. Они позволяют:
- проектировать код непосредственно при его написании;
- удостовериться, что реализация действительно работает так, как было задумано.
Но возникают вопросы: зачем нам лишняя система проектирования и проверки, разве сам по себе код не несёт информацию об устройстве и поведении приложения? и если тесты не предоставляют принципиально новой информации, как они подтверждают правильность спроектированной системы? как быть с «принципом неповторяемости» (DRY)?
Лично для меня, если при первой мысли о задаче код её реализации не становится очевидным, а значит для его написания придётся посидеть и пораскинуть мозгами, то дополнительная помощь (напр. в виде юнит-тестов), позволяющая удостовериться, что всё будет работать правильно, была бы очень кстати. Для примера, если Вы разрабатываете систему бизнес-правил или делаете разбор сложного иерархического выражения, сразу в уме не удастся проследить все возможные ветви поведения кода. В подобных случаях юнит-тесты были бы чрезвычайно ценными.
И наоборот: если код прост и очевиден, и при первом взгляде сразу ясно что он делает, то польза от преимуществ, которыми обладают юнит-тесты, сводится к нулю. К примеру, Вы пишете метод, который получает текущую дату и размер свободного места на диске, а затем передаёт эти данные другому методу. В этом случае Ваш код говорит сам за себя, дополнительным юнит-тестам просто нечего будет добавить.
Итак, польза от юнит-тестов прямо зависит от степени запутанности кода.
Цена тестирования
Среди факторов, влияющих на стоимость продукта, следующие:
- время, потраченное на написание тестов;
- время, потраченное на исправление и доработку тестов после рефакторинга кода или внесения в него других изменений;
- боязнь делать какие-нибудь усовершенствования в коде по причине ожидания, что из-за них некоторые тесты пофейлятся и их все придётся переписывать.
Конечно, стоимость поддержки системы тестов можно уменьшить, следуя различным рекомендациям, но всё же она может оставаться сравнительно большой.
Согласно моим наблюдениям, общая стоимость юнит-тестирования определённого участка кода очень сильно коррелирует с количеством существующих в нём зависимостей от остальных частей кода. Вы спросите почему?
Первоначальное создание. Если метод не имеет зависимостей от других блоков и попросту работает как обычная функция, принимающая один параметр, юнит-тест будет представлять собой просто список соответствий между входными и выходными данными. Но если метод принимает много параметров и взаимодействует с многими внешними сервисами через свойства класса, Вам придётся делать кучу поддельных (mock) объектов. Но стоимось этой работы невелика по сравнению со следующим пунктом.
Поддержка. Установлено, что чем больше зависимостей существует в блоке кода, тем чаще этот код вынужден подвергаться изменению (именно так и определяется неустойчивость кода). И причина ясна: на протяжении заданного промежутка времени для каждой из этих зависимостей существует вероятность изменения её сигнатуры или поведения, что выльется в необходимость обновить код и соответствующие тесты.
Обратите внимание, что эти проблемы в той же мере относятся и к ситуации, когда вы используете IoC (DI), работая с чистыми интерфейсами.
Итак, стоимость юнит-тестов прямо зависит от количества зависимостей в участке кода.
Графическое представление стоимости и преимуществ тестов
На этой намеренно упрощённой диаграмме показано 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».