В наших с вами интернетах недавно поднялся нешуточный шум по поводу того, жил ли TDD сейчас, нужен ли он, и в каком виде. Все началось со статьи Дэвида Хансона «TDD is dead. Long living testing», после которой последовали статьи многих авторов и живые обсуждения этой проблемы включая hangout вместе с Дэвидом, Кентом Беком и Мартином Фаулером (кстати, очередной hangout будет завтра, 16 мая).
Но немногие знают, что за несколько дней до этого все тот же Мартин Фаулер постарался дать определение модульного теста (bliki:UnitTest), перевод которого представлен ниже. А после перевода идут кое-какие мои мысли по этому поводу.
—
В мире разработки ПО очень часто говорят о модульных тестах, и я знаком с этим понятием на протяжении всей своей карьеры программиста. Однако, как и многие другие термины из мира разработки ПО, этот термин определен весьма плохо, и я часто сталкиваюсь с замешательством, когда разработчики думают, что он имеет более строгое определение, чем это есть на самом деле.
И хотя я очень часто пользовался модульными тестами, моя окончательная приверженность ими возникла, когда я начал работать с Кентом Беком и пользоваться семейством инструментов тестирования xUnit. (Мне даже иногда кажется, что более подходящим термином для этого вида тестирования будет «xunit testing».) Модульное тестирование также стало важной активностью в Экстремальном Программировании (XP – Extreme Programming), и быстро переросло в разработку через тестирование (TDD – Test-Driven Development).
Роль модульных тестов в XP с самого начала вызывала определенное беспокойство. Я четко помню обсуждение в группе usenet, в которой эксперт по тестированию ругал сторонников XP за неправильное использование термина «модульный тест». Мы попросили дать его определение, на что он ответил что-то вроде: «в самом начале моего учебного курса по тестированию я рассматриваю 24 разных определения модульного теста».
Несмотря на разногласия, в некоторых аспектах наши точки зрения сходились. Во-первых, существует представление, что модульные тесты являются низкоуровневыми и концентрируются лишь на небольшой части программной системы. Во-вторых, сегодня модульные тесты обычно пишутся разработчиками с использованием их обычных инструментов, к которым добавляется некоторый фреймворк для тестирования(*). В-третьих, ожидается, что модульные тесты будут существенно быстрее других видов тестов.
Но были и различия во взглядах. Существует разные точки зрения на то, что считать модулем. В объектно-ориентированном дизайне модулем принято считать класс, в процедурном и функциональом подходах модулем может считаться функция. На самом же деле, это ситуативное понятие: команда решает, что разумно считать модулем для понимания системы или ее тестирования. И хотя я начинаю с представления, что класс является модулем, я часто начинаю рассматривать набор тесно связанных классов, как единый модуль. Реже я могу рассматривать подмножество методов класса в качестве модуля. На сам деле, не имеет особого значения, как вы определите это понятие.
Изоляция
Более важным различием в подходах является вопрос: должен ли тестируемый модуль быть отделен от взаимодействующих объектов? Предположим вы тестируете метод вычисления цены класса заказа. Метод вычисления цены вызывает некоторые методы классов продукта и заказчика. Если вы следуете принципу изоляции взаимодействующих объектов, вы не захотите здесь использовать реальные классы продуктов и заказчиков, поскольку ошибки в классе заказчика приведут к падению тестов класса заказа. Вместо этого вы воспользуетесь подделками (Test Doubles) всех взаимодействующих объектов.
Но не все разработчики используют изоляцию. На самом деле, когда xunit-тестирование началось в 90-х мы не пытались изолировать тестируемый класс, если только коммуникация с другими объектами не была крайне неудобной (как, например, взаимодействие с удаленной системой проверки кредитных карт). У нас не возникало сложностей понять реальную причину сбоя, даже если при этом падали соседние тесты. Поэтому, с практической точки зреня, мы не считали отсутствие изоляции проблемой.
Хотя именно отсутствие изоляции в нашем определении «модульного теста» было причиной его критики. Я считаю, определение «модульного теста» подходящим, поскольку он тестируют поведение одного модуля. Мы пишем тест, предполагая, что все, кроме данного модуля работает корректно.
Когда xunit тестирование стало набирать популярность в 2000-е, идея изоляции вернулась с новой силой, по крайней мере, для некоторых. Мы видели подъем Мок Объектов (Mock Object) и фреймворков для поддержки мокинга. В результате появились две школы xunit тестировщиков, которые я называю классической школой и школой мокистов (mockists). Приверженцы классической школы не заморачиваются с изоляцией, как это делают мокисты. Я знаю и уважаю xunit-тестировщиков обоих школ (хотя сам отношусь к классической школе).
Даже представители классической школы (включая меня) при наличии сложных взаимодействий используют подделки (test doubles). Подделки являются бесценными для устранения неопределенности поведения при работе с удаленными сервисами. Некоторые представители классической школы считают, что любое взаимодействие с внешними ресурсами, такими как базы данных или файловая система, должны использовать подделки. Частично это мнение опирается на риск неопределенного поведения, частично на проблемы со скоростью. И хотя я считаю, что это полезная рекомендация, я не рассматриваю ее как абсолютное правило. Если обращение к ресурсу является стабильным и достаточно быстрым для вас, тогда нет причин, почему его нельзя использовать из модульных тестов.
Скорость
Существуют несколько общих свойств юнит тестов: маленькая область действия (small scope), они пишутся разработчиками, и они быстро исполняются – что дает возможность запускать их часто во время разработки. Действительно, это одно из ключевых свойств самотестируемого кода (Self-Testing Code). В этом случае программист может запускать модульные тесты после любого изменения в коде. Я могу запускать юнит тесты несколько раз в минуту, каждый раз, когда у меня появляется необходимость в компиляции кода. Это полезно, поскольку если я случайно что-то сломаю, то я хочу сразу же узнать об этом. Если я сломал что-то своими последними изменениями, то намного проще сразу же найти эту ошибку, поскольку мне не придется искать ее очень далеко.
ПРИМЕЧАНИЕ переводчика
Кент Бек развил идею запуска тестов при компиляции (а иногда даже без нее) и предложил идею непрерывного тестирования (Continuous Testing). Примеры таких инструментов: Mighty-Moose и NCrunch для .NET, JUnit Max для Java.
Когда вы запускаете тесты настолько часто, то вы не можете запускать их все. Обычно вам нужно запускать лишь те тесты, которые работают с кодом над которым вы сейчас работаете. В этом случае вы жертвуете глубиной тестирования в угоду длительности запуска тестов. Я называю этот набор тестов «набором компиляции» (compile suite), поскольку я запускаю их каждый раз при компиляции, даже на таких интерпретируемых языках как Ruby.
Если вы используете непрерывную интеграцию (Continuous Integration) вы должны запускать тесты, как один из ее шагов. Этот набор тестов, которые я называю «набором фиксации» (commit suite), должен включать все юнит тесты. Он также может включать в себя некоторые приемочные тесты (Broad-Stack Tests или End-to-End Tests). Как разработчик вы должны прогонять этот набор тестов несколько раз в день, конечно же, до фиксации своих изменений в системе контроля версий, а также в любое другое время, когда у вас есть такая возможность – во время перерыва или митинга. Чем быстрее выполняется набор тестов фиксации, тем чаще вы cможете их запускать (**).
У разных людей разные стандарты для скорости исполнения юнит-тестов и их наборов. Так, для Дэвида Хансона (David Heinemeier Hansson) достаточно, чтобы набор компиляции (compile suite) исполнялся несколько секунд, а набор фиксации (commit suite) – несколько минут. Гарри Бернхардт (Gary Bernhardt) считает это слишком медленным и настаивает, чтобы набор компиляции исполнялся около 300мс, а Дэн Бодарт (Dan Bodart) не хочет ждать исполнения набора фиксации дольше нескольких секунд.
Я не думаю, что есть единственный правильный ответ на этот вопрос. Лично я не видел разницы, когда набор компиляции исполняется долю секунды или пару секунд. Мне нравится правило Кента Бека, что набор фиксации не должен исполняться дольше 10 минут. Главная мысль здесь в том, что ваш набор тестов должен исполняться достаточно быстро, чтобы не отбить у вас охоту запускать его достаточно часто. А «достаточно часто» означает, что когда тесты найдут баг, вам придется перекопать небольшой объем кода и найти его довольно быстро.
Примечания
(*) Я говорю «сегодня», поскольку это изменилось именно благодаря XP. В спорах начала нового столетия, сторонники XP подвергались серьезной критике, поскольку общепринятая точка зрения гласила, что программисты не должны тестировать собственный код. В некоторых компания были специализированные «юнит-тестеры», единственной задачей которых было написание модульных тестов для кода разработчиков. Причина такой точки зрения заключалась в следующем: люди обладают «концептуальной слепотой» при тестировании своего кода; программисты являются плохими тестировщиками, поэтому полезно иметь некую форму противостояния между программистами и тестировщиками. Точка зрения сторонников XP заключалась в том, что программисты могут научиться быть хорошими тестировщиками, как минимум на уровне отдельного «модуля», а если привлечь дополнительную группу для написания тестов, то обратная связь, обеспечиваемая тестами, будет невероятно медленной. Инструменты XUnit играли в этом очень важную роль, поскольку они были разработаны специально, для минимизации накладных расходов при написании тестов.
(**) Если у вас есть полезные тесты, длительность исполнения которых превосходит длительность запуска тестов фиксации, то вам нужно построить «конвейер развертывания» (Deployment Pipeline) и поместить эти тесты в более поздние этапы конвейера.
—
В этой статье Мартин осознанно не касается вопросов порядка написания кода и тестов, вместо этого он старается лишь дать определение модульного теста и показать существование разных точек зрения на само понятие модуля, на необходимость изоляции и скорость исполнения.
Сам я тоже довольно часто сталкивался с мнением, что модульный тест должен тестировать класс в полной изоляции от остального мира. Например, именно этот подход описывает Роберт Мартин в своей книге «Принципы, паттерны и методики гибкой разработки» и именно его я критиковал в статье «Критический взгляд на принцип инверсии зависимостей».
В моем понимании нет совершенно ничего плохого в использовании конкретных классов в модульных тестах, если их поведение является детерминированным и быстрым. Выделение лишних зависимостей может подрывать инкапсуляцию класса и в итоге снижать простоту понимания и сопровождения системы. Любые стабильные зависимости могут и должны использоваться напрямую, а выделяться должны лишь «изменчивые» зависимости, чье поведение не является детерминированным.
Оказывается, я являюсь сторонником классической школы модульного тестирования, и считаю, что нужно использовать реальные классы, если они не обращаются к недерминированным внешним ресурсам. Проблема с обилием моков в моем понимании заключается в том, что полученные тесты становятся слишком зависимыми на тестовое окружение, что делает их хрупкими, а обилие интерфейсов и косвенности ухудшает «понимаемость» системы добавляя гибкость, не нужную в 99% случаев.
Конечно, есть и другие точки зрения на использование моков. Так, Стив Фриман и Нэт Прайс в своей книге «Growing Object-Oriented Software Guided by Tests» придерживаются другой точки зрения. Но при этом они очень тщательно следят за простой тестов и не допускают ситуации, когда на каждую строку теста приходит 5 строк инициализации моков.
Абсолютно нормально придерживаться любому из двух лагерей: классиков или моккистов. Главное, чтобы ваш выбор был осознанным, а ваши тесты упрощали развитие и сопровождение, а не мешали этому.
Автор: SergeyT