Об использовании модульных тестов и TDD

20 марта 2012

На протяжении последних нескольких месяцев я практиковал TDD. Теперь я понимаю — эта техника (не путать технику и методологию!) работает. Ее легко использовать и она эффективно решает вполне конкретные проблемы. Эта заметка адресована программистам и, в меньшей степени, руководителям проектов.

Можете дальше не читать, если…

Работать с вашим кодом легко и приятно? Вы не боитесь производить рефакторинг, точно зная, что нигде ничего не взорвется? Самые серьезные баги в релиз-версии вашего проекта можно охарактеризовать, как «мелкие недочеты»? Все задачи выполняются в заранее оговоренные сроки?

Если вы ответили «да» на все эти вопросы — поздравляю, вам очень повезло с работой и эта заметка не для вас. В противном случае у вас проблемы и нужно что-то менять.

Самая большая глупость — это делать тоже самое и надеяться на другой результат // Альберт Эйнштейн

Скорее всего, в вашей команде не пишут модульные тесты или пишут, но неправильно или невовремя.

Что такое модульные тесты?

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

  • Модульное тестирование происходит по принципу белого ящика, его задача — показать, что все модули или классы корректно работают по отдельности;
  • Интеграционное тестирование проверяет, что те же модули или классы правильно взаимодействуют друг с другом, притом используется уже принцип черного ящика;
  • Системное тестирование проверяет, что весь продукт соответствует исходным требованиям, сюда относятся альфа- и бета-тестирование;

Некоторые другие виды тестирования — юзабилити тестирование, нагрузочное тестирование, тестирование безопасности, тестирование совместимости. Несмотря на то, что далее в этой заметке речь пойдет только о модульных тестах, другие виды тестирования также важны. Примеры модульных тестов вы можете найти в исходниках к заметкам Эллиптическая криптография на практике, Реализация хэш-таблиц, почти как в Perl, Очереди заданий и пулы процессов в Erlang, а также Пример использования Common Test, EUnit и Meck.

Модульное тестирование позволяет выявить большое количество ошибок, но не все. Чтобы убедиться, что ваше приложение корректно работает как с MySQL, так и с PostgreSQL, необходимо тестирование совместимости. Чтобы убедиться, что парсер логов достаточно быстро обрабатывает большие файлы, необходимо нагрузочное тестирование.

Помимо тестирования есть и другие эффективные способы обнаружения ошибок. Например, парное программирование, code review, статический анализ кода и другие, но это — тема для отдельной заметки.

Что такое TDD?

Test-driven development или разработка через тестирование — это техника программирования, в соответствии с которой написание кода происходит по следующему алгоритму:

  1. Напишите тест. Да, написание тестов происходит перед написанием любого, даже самого простого, кода. Например, если вы хотите объявить новую функцию, напишите тест, который просто ее вызывает и проверяет результат.
  2. Убедитесь, что тесты не проходят. В примере с функцией тест даже не будет компилироваться, потому что функция еще не объявлена.
  3. Напишите код. Это должен быть наиболее простой код, обеспечивающий срабатывание теста, в нашем примере — объявление функции, возвращающей константу.
  4. Убедитесь, что тесты проходят. Теперь, когда код написан, все тесты, включая последний написанный, должны компилироваться и проходить. Если это не так, следует вернуться к шагу 3.
  5. Произведите рефакторинг и вернитесь к шагу 1. Бояться нечего, потому что весь код покрыт тестами. Откладывать рефакторинг на потом не стоит. Потом вы будете хуже помнить код и вообще вам будет некогда.

Чем меньше времени нужно на выполнение одной такой мини-итерации, тем лучше. В идеале это время не должно превышать пары минут. Обратите внимание, что в рамках TDD пишутся только модульные тесты, хотя никто не запрещает вам написать пару интеграционных или нагрузочных тестов вне контекста этой техники. Дальше я буду говорить о модульных тестах, подразумевая, что они были созданы во время разработки через тестирование.

Да это же не будет работать, потому что…

Я знаю, о чем вы сейчас думаете. Впервые услышав о TDD, я тоже был настроен очень скептически. Это сколько же лишнего времени нужно потратить и сколько дополнительного кода написать! Но попробовав TDD на практике, я убедился, что это один из тех случаев, когда нельзя полагаться на «это же очевидно».

Вот некоторые популярные заблуждения в отношении TDD:

  1. Написание тестов отнимает время. В действительности написание тестов экономит время. Допустим, вы пишите сайтик. Как убедиться, что новая фича работает и не привела к появлениям новых багов? Не отвалилось ли что-то в результате рефакторинга? Если автоматических тестов нет, нужно вручную потыкать во все ссылочки и отправить все формочки, на что уйдет много времени. А если у нас есть тесты, достаточно запустить их и увидеть результат через несколько секунд.
  2. Но ведь приложение работает с СУБД, сетью, файлами… Эта проблема элементарно решается с помощью mock-объектов. Вы без труда найдете много готовых классов для вашего языка программирования. В качестве примера заслуживает внимания Perl-модуль DBD::Mock.
  3. Это работает, только если писать проект с нуля. Ничто не мешает писать новый код для данного проекта по TDD. Если у вас есть старый, проверенный временем код, который просто работает, зачем покрывать его тестами? Если же с кодом постоянно возникают проблемы, вам все равно придется его переписать, а новый код в соответствии с TDD будет покрыт тестами.
  4. Лучше мы напишем код, а потом тесты. Это не работает. Во-первых, потом у вас не будет времени. Во-вторых, даже если потом вы напишите какие-то тесты, они будут намного хуже покрывать код, чем тесты, написанные во время разработки через тестирование. В-третьих, если при написании кода не беспокоиться о том, как вы будите его тестировать, потом вы просто не сможете написать для него тесты.

Работая по TDD, вы:

  1. Получаете наиболее полные тесты. С помощью Devel::Cover я выяснил, что степень покрытия тестами кода, написанного по TDD (96.5% строк кода было выполнено хотя бы один раз), существенно превышает степень покрытия кода, тесты для которого писались в конце разработки (70.4% строк кода).
  2. Сразу выявляете большую часть ошибок. Откладывая поиск и исправление ошибок на потом, вы существенно рискуете сорвать сроки. Вам кажется, что разработка идет полным ходом и на исправление ошибок понадобится несколько дней. Горький опыт показывает, что эти ожидания никогда не оправдываются.
  3. Не боитесь делать рефакторинг. В любой момент времени весь код покрыт тестами. Если в результате рефакторинга что-то отвалится, вы сразу узнаете об этом и либо устраните проблему, либо сделаете git reset. Вы ничем не рискуете.
  4. Быстро выявляете проблемы при деплое. Недавно я выкатывал в бой один сайтик, написанный на Perl. Его разработка велась под FreeBSD, но боевой сервер работал под CentOS. В результате прогона тестов на боевом сервере выяснилось, что модуль File::fgets работает не совсем так, как ожидалось. Я потратил минут пять на переписывание кода так, чтобы он не использовал этот модуль. Если бы у меня не было тестов, проблема выявилась бы только через несколько дней. Страшно подумать, сколько времени мне понадобилось бы на ее устранение!
  5. Получаете более продуманный код. Я имел дело как с «обычными» проектами, так и с проектами, написанными по TDD. В первом случае код часто напоминает спагетти и даже непонятно, как написать для него тесты. Во втором случае код намного более структурирован, классы делают простые вещи и имеют понятный интерфейс, их легко использовать повторно.
  6. Имеете документацию в виде тестов. Обычно программисты не очень любят писать документацию, зато с написанием кода проблем не возникает. Модульные тесты представляют собой замечательно описание интерфейсов классов и примеры их использования.
  7. И не только! Решили собрать проект другим компилятором (или, например, перейти с Python 2 на Python 3)? Прогоняем тесты и смотрим, какие участки кода перестали работать. Добрый человек прислал вам pull request? Мерджим, прогоняем тесты и тут же узнаем — сломалось что-нибудь или нет. В общем, применений у автоматических тестов уйма.

Важно понимать, что в реальном мире можно немного отклонятся от TDD. Можно провести аналогию с правилами дорожного движения. Обычно мы ездим по правилам, но давайте будем честны — все водители иногда их нарушают. Порой можно написать немного кода, а потом тест для него. Некоторые тривиальные вещи, пожалуй, можно не тестировать. Однако следует отметить, что как-то я покрыл тестами несколько «тривиальных» регулярных выражений и нашел в них с десяток мелких ошибок. Будьте внимательны.

Какими должны быть тесты

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

Представим, что у вас есть программа, содержащая 64 оператора if. Если вы стремитесь к 100%-му покрытию кода, вам нужно написать не менее 264 тестов, чтобы обеспечить все возможные комбинации выполнения и не выполнения условий в операторах if. Притом некоторые проверки могут быть для случаев, которые в теории никогда не должны произойти. Таким образом, 100%-ое покрытие кода практически неосуществимо.

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

Чем дольше выполняются тесты, тем реже вы будете их запускать, поэтому хорошие тесты должны работать быстро. Если писать тесты так, чтобы они не зависели друг от друга, их можно будет запускать параллельно (ключ -j утилиты prove), что ускорит их выполнение. Кроме того, независимые тесты можно запускать в произвольном порядке (ключ -s), что иногда позволяет найти пару лишних ошибок.

В связи с вышесказанным, утверждение, что нужно стремиться к 100%-му покрытию кода — это не более, чем еще одно заблуждение о модульном тестировании. В действительности следует стремиться к написанию как можно меньшего количества тестов, чтобы при этом они решали наши проблемы.

Опыт показывает, что написание лишь нескольких простых тестов позволяет обнаружить очень большое количество ошибок. Если увеличить количество тестов еще немного, эффект будет слабее, но также вполне ощутим. В какой-то момент написание новых тестов становится неэффективным. Я стараюсь писать тесты так, чтобы не менее 95% всех строк кода выполнялось хотя бы один раз. Этой степени покрытия довольно легко достичь и при этом код можно смело рефакторить не опасаясь, что что-то взорвется.

Если вы пишите сайтик на Mojolicious, то можете определить степень покрытия кода следующим образом:

HARNESS_PERL_SWITCHES=-MDevel::Cover prove -l -r ./t
cover # создаем отчет
cover -delete # удаляем отчет

Следует отметить, что при написании небольших (в пределах нескольких сотен строк кода) и простых программ, не имеет особого смысла покрывать их тестами.

У нас действительно нет времени!

Если относительно небольшую задачу действительно нужно сделать быстро (беспочвенное обещание руководства сделать к такому-то числу — это недостаточная причина!), при этом вы осознаете риски и уверены, что задачу нельзя распараллелить между несколькими программистами, тогда:

  • Заранее выделите время на рефакторинг и написание тестов. Это должно быть не только на словах — должен быть таск и у него должен быть исполнитель. После выполнения срочных задач программисты обычно на пару дней «выбывают из строя», так что не скупитесь на время для рефакторинга.
  • Не ломайте уже написанные тесты. Если какие-то тесты перестают работать, лучше закомментируйте их и напишите TODO-комментарий с информацией о том, как починить тест.
  • Напишите хоть какой-то минимум тестов. Как уже отмечалось, даже несколько простых тестов могут быть очень эффективными.

Если задачу можно выполнить вовремя, спокойно поработав над ней в выходные, лучше пойти этим путем, чем вкалывать по 12 часов в сутки. Следует также отметить, что срочность в 99% случаев оказывается надуманной.

Языки программирования и модульные тесты

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

Дело в том, что Haskell — компилируемый язык со строгой статической типизацией, программы на котором легко переносятся между различными ОС и архитектурами процессора. Если программа на Haskell компилируется, то она почти наверняка делает то, что должна. Кроме того, рефакторинг программ на Haskell — нечастое явление. Perl — интерпретируемый язык с нестрогой динамической типизацией. Вы можете сделать опечатку в имени метода и программа будет нормально работать, пока дело не дойдет до выполнения строки с опечаткой.

Язык C++ занимает промежуточное положение между Haskell и Perl. Это компилируемый язык, но типизация в нем, вопреки распространенному заблуждению, не является строгой:

char c = 1;
int i = c;

Кроме того, у этого языка не так хорошо кроссплатформенностью, как у Haskell. Например, при переходе с i386 на amd64 меняются sizeof(long) и sizeof(char*).

Если вы пишите на Haskell или языке с подобными свойствами, то тесты вам нужны только для участков кода с очень сложной логикой. Если вы пишите на С++, C#, Java, Perl, Python, PHP или Ruby, то в ваших интересах позаботиться о написании хороших модульных тестов. Споры о том, на что следует тратить время — на написание тестов или на то, чтобы просто заставить программу компилироваться, ведутся по сей день.

Метки: , .


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