Статьи

Разработка через тестирование: вы заражены тестом?

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

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

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

Не поймите меня неправильно … научиться правильно использовать TDD нелегко. Я начал невероятно неровное начало и почти сдался. Мои тесты ломались всякий раз, когда я вносил изменения в свой исходный код, и я не был уверен, как написать множество тестов вообще. Потребовалось много концентрации, и конечный результат был не таким элегантным, как меня ожидали. Я не был первым, и я не буду последним. Но, как и в случае, когда вы впервые чему-то учитесь, делать эти ошибки — это то, что вам часто нужно делать, чтобы понять, почему вы должны делать что-то определенным образом. Три года спустя я все еще учусь писать более качественные тесты, но тесты, которые я пишу, гораздо более гибкие и легко обслуживаемые.

Представляем TDD

Позвольте мне немного рассказать вам о моем опыте работы с TDD, о том, как он добавляет мне удовольствия от написания программного обеспечения, и как в результате улучшилось качество моего исходного кода.

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

Однако в действительности этот процесс никогда не был таким четким, как эти три этапа. Разработчики обычно тестируют небольшие части своего кода, когда заканчивают писать; мы называем эти небольшие части кода «единицами». Используете ли вы систему с точки зрения конечного пользователя или добавляете отладочный вывод в код и эмулируете конкретные сценарии, с которыми может столкнуться программное обеспечение, такое тестирование становится очень утомительным очень быстро. Лишь очень много раз кто-то может выполнить ручную проверку значений, прежде чем они устают повторять процесс и просто останавливаются. Традиционно, маловероятно, что разработчик вернется к тем аспектам системы, которые они уже написали, и снова проведет это ручное тестирование. В результате небольшие изменения здесь и там могут привести к скрытым ошибкам в других частях системы.

С другой стороны, разработка, основанная на тестировании, немного идет вразрез. Этот процесс больше похож на планирование, проектирование, тестирование, внедрение, рефакторинг, проектирование, тестирование, внедрение, рефакторинг … и т. Д., Причем гораздо меньше внимания уделяется этапу планирования по сравнению с традиционной разработкой. Шаг проектирования важен. Чтобы написать тест, разработчики должны знать, что они тестируют. Для этого они постоянно проектируют очень изолированные компоненты в системе. Это поощряет большую гибкость кода, поскольку легко тестируемый код и гибкость часто идут рука об руку.

TDD обычно начинается с модульного тестирования. Это не означает, что разработка через тестирование — это модульное тестирование — это не так. Поскольку я разрабатываю в основном на PHP, я выбрал SimpleTest , написанный Маркусом Бейкером . PHP предлагает другую популярную платформу модульных тестов под названием PHPUnit , но я выбрал SimpleTest, потому что он был более зрелым и имел более доступную поддержку (в основном из-за того, что автор является постоянным посетителем здесь на форумах SitePoint ). Почти все фреймворки для модульных тестов очень похожи JUnit — наиболее широко используемая среда модульного тестирования Java — вывела эту методологию в мейнстрим. Различные фреймворки, написанные на других языках программирования, впоследствии появились более или менее в соответствии с минимальным API JUnit. Мы называем эту группу фреймворков фреймворками xUnit.

Как мы тестируем систему, прежде чем внедрить ее? На первый взгляд философия кажется ошибочной, но как только концепция начинает проникать, она имеет смысл. Сами тесты написаны в коде и делают различные утверждения о поведении тестируемой системы (SUT). Написание теста выполняется в рамках модульного теста, такого как SimpleTest. Идея состоит в том, чтобы написать небольшой простой тест, который выражает поведенческие требования. Например, можно ожидать, что SUT будет возвращать отрицательное значение при передаче определенного набора аргументов. Среда модульного тестирования предоставляет средства для выполнения этих утверждений и предоставляет полезную обратную связь разработчику, если одно или несколько из этих утверждений не сработали.

В TDD разработчик напишет намеренно провальный тест (поскольку SUT еще не будет иметь реализации для этого требования), а затем продолжит писать наиболее очевидный и самый простой код для прохождения этого теста. Как только тест пройден, разработчик пишет следующий (возможно, неудачный) тест, реализует небольшой код для его прохождения и затем продолжает работу. Конечным результатом является то, что со временем у вас есть два больших набора исходного кода — один — сам тестовый код; другой — исходный код программного обеспечения. Весьма вероятно, что будет больше строк кода, обеспечивающих тестовое покрытие, чем будет фактический исходный код. Сохранение всего этого тестового кода в каталоге проекта имеет несколько преимуществ:

  • Выполнение тестов гарантирует, что заданные ими поведенческие требования все еще выполняются.
  • Они предоставляют поддержку разработчикам, которые хотят изменить программное обеспечение, не нарушая и не изменяя его существующее поведение.
  • Они предоставляют форму документации для разработчиков, которым необходимо понять, как работает программное обеспечение.

Все фреймворки xUnit предоставляют механизмы для группировки этих тестов в набор тестов и их автоматического запуска за один раз. Это делает процесс повторного тестирования программного обеспечения гораздо менее громоздким и более привлекательным, чем все это традиционное ручное тестирование.

TDD в действии

Давайте рассмотрим простой пример того, как можно использовать TDD с инфраструктурой модульного тестирования SimpleTest в PHP. Боюсь, вам придется использовать здесь свое воображение, потому что этот пример, очевидно, невероятно минимален по сравнению с крупномасштабными приложениями, в которых обычно используется TDD. Это выходит за рамки этой статьи, чтобы решить большую проблему. Эта статья не предназначена для того, чтобы быть новичком в TDD — для этого в Интернете есть множество других статей. Здесь мы возьмем пример базовой цепочки фильтров, которую мы хотели бы использовать для создания гиперссылок в тексте и для фильтрации непослушных слов. Вы можете скачать код для примера в этой статье вместе с тестами .

Наш интерфейс может выглядеть примерно так:

...   /**  * Performs a single filtering method on some input text.  */  interface TextFilter {  /** Process $text and return a filtered value */  public function filter($text);  }   /**  * Performs filtering on text using a series of filters.  */  class TextFilterChain {  /** Add a new filter to this chain */  public function addFilter(TextFilter $filter) {  }  /** Pass $text through all filters and return the filtered value */  public function filter($text) {  }  }   ... 

Нам AutoHyperlinkFilter и NaughtyWordFilter , поэтому мы выбираем один и создаем для него только скелетный код:

 ...   class NaughtyWordFilter implements TextFilter {  public function filter($text) {  }  }  ... 

Затем мы создаем тестовый пример. Контрольный пример — это отдельный класс. Как правило, у вас есть один контрольный пример для каждого конкретного класса в вашей системе. Как мы видели выше, «вещь», которую тестирует тестовый объект, часто называется SUT (тестируемой системой). Любой метод внутри класса, который начинается со слова «test», будет выполнен и о нем будет сообщено. Мы ожидаем, что набор непослушных слов будет заменен здесь:

 ...   class NaughtyWordFilterTest extends UnitTestCase {  public function testNaughtyWordsAreReplaced() {    $filter = $this->_createFilter(array('foo', 'bar'));    $this->assertEqual(      "smurf! There's no way I'm doing that smurf...",      $filter->filter("foo! There's no way I'm doing that bar...")      );  }   private function _createFilter($words = array()) {    return new NaughtyWordFilter($words);  }  }   ... 

Теперь мы запустим это:

 ...   $test = new NaughtyWordFilterTest();  $test->run(new TextReporter());   ... 

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

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

 NaughtyWordFilterTest.php  1) Equal expectation fails at character 0 with [smurf! There's no way I'm doing that smurf...] and [] at [/Users/chris/word_filter/tests/unit/NaughtyWordFilterTest.php line 11]  in testNaughtyWordsAreReplaced  in NaughtyWordFilterTest  FAILURES!!!  Test cases run: 1/1, Passes: 0, Failures: 1, Exceptions: 0 

Все, что мы делаем, это реализуем достаточно кода, чтобы пройти этот тест:

 ...   class NaughtyWordFilter implements TextFilter {  private $_badWords = array();   public function __construct($badWords = array()) {    $this->_badWords = $badWords;  }   public function filter($text) {    foreach ($this->_badWords as $badWord) {      $text = str_replace($badWord, 'smurf', $text);    }    return $text;  }  }   ... 

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

 NaughtyWordFilterTest.php  OK  Test cases run: 1/1, Passes: 1, Failures: 0, Exceptions: 0 

Теперь мы можем перейти к нашему следующему классу, AutoHyperlinkFilter . Вот код скелета:

 ...   class AutoHyperlinkFilter implements TextFilter {  public function filter($text) {  }  }   ... 

Мы пишем неудачный тест дальше. Мы ожидаем, что URL будут превращены в гиперссылки:

 ...   class AutoHyperlinkFilterTest extends UnitTestCase {   public function testURLsAreHyperlinked() {    $filter = $this->_createFilter();    $this->assertEqual(      'Go to my web site at <a href="http://site.com/">http://site.com/</a> and see!',      $filter->filter('Go to my web site at http://site.com/ and see!')      );  }   private function _createFilter() {    return new AutoHyperlinkFilter();  }   }   ... 

Реализация следующая:

 ...   class AutoHyperlinkFilter implements TextFilter {  public function filter($text) {    return preg_replace('~(http://S+)~i', '<a href="$1">$1</a>', $text);  }  }   ... 

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

Итак, почему я принял некоторые из этих небольших (настолько маленьких, что вы, возможно, даже не заметили) дизайнерских решений? Почему, например, я решил указать «непослушные слова», используя конструктор NaughtyWordFilter ? Ответ в том, что это похоже на самое чистое решение на уровне API. Это пришло инстинктивно. Это своего рода постоянный мыслительный процесс проектирования, через который разработчики создают чистый, гибкий, тестируемый код. Написание тестов побуждает вас тщательно продумывать интерфейс вашего кода, иначе вы не сможете протестировать его очень легко.

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

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

Давайте быстро напишем наш класс TextFilterChain . Поскольку этот класс имеет зависимости от экземпляров интерфейса TextFilter , он предоставляет прекрасную возможность для использования фиктивных объектов.

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

 ...  Mock::generate('TextFilter', 'MockTextFilter');   class TextFilterChainTest extends UnitTestCase {  private $_filterChain;   public function setUp() {    $this->_filterChain = new TextFilterChain();  }   public function testEachFilterIsInvoked() {    $filter1 = $this->_createMockFilter();    $filter2 = $this->_createMockFilter();     $filter1->expectOnce('filter');    $filter2->expectOnce('filter');     $this->_filterChain->addFilter($filter1);    $this->_filterChain->addFilter($filter2);     $this->_filterChain->filter('foo');  }   private function _createMockFilter() {    return new MockTextFilter();  }  }   ... 

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

 TextFilterChainTest.php  1) Expected call count for [filter] was [1] got [0] at [/Users/chris/word_filter/tests/unit/TextFilterChainTest.php line 20]  in testEachFilterIsInvoked  in TextFilterChainTest  2) Expected call count for [filter] was [1] got [0] at [/Users/chris/word_filter/tests/unit/TextFilterChainTest.php line 21]  in testEachFilterIsInvoked  in TextFilterChainTest  FAILURES!!!  Test cases run: 1/1, Passes: 0, Failures: 2, Exceptions: 0 

Тест показал, что если два фильтра добавляются в цепочку фильтров, цепочка фильтров должна вызывать метод filter() для них обоих. Этого не произошло, потому что мы еще не реализовали такую ​​функцию.

Реализация следует:

 ...   class TextFilterChain {  private $_filters = array();   public function addFilter(TextFilter $filter) {    $this->_filters[] = $filter;  }   public function filter($text) {    foreach ($this->_filters as $filter) {      $filter->filter($text);    }  }  }   ... 

Теперь тест проходит, и мы переходим к определению того, что еще должно произойти. Каждый фильтр должен содержать текст, который мы передаем:

 ...   public function testFilterInvocationReceivesTextInput() {    $filter = $this->_createMockFilter();     $filter->expectOnce('filter', array('foo'));     $this->_filterChain->addFilter($filter);     $this->_filterChain->filter('foo');  }   ... 

Этот конкретный тест уже пройден, поэтому мы идем дальше. Если один фильтр изменяет текст, следующий должен получить измененное значение:

 ...   public function testChangesAreChained() {    $filter1 = $this->_createMockFilter();    $filter2 = $this->_createMockFilter();     $filter1->expectOnce('filter', array('foo'));    $filter1->setReturnValue('filter', '***FOO***');    $filter2->expectOnce('filter', array('***FOO***'));     $this->_filterChain->addFilter($filter1);    $this->_filterChain->addFilter($filter2);     $this->_filterChain->filter('foo');  }   ... 

Тест не пройден, поэтому мы корректируем нашу реализацию, чтобы она прошла:

 ...   public function filter($text) {    foreach ($this->_filters as $filter) {      $text = $filter->filter($text);    }  }   ... 

Наконец, мы ожидаем, что отфильтрованное значение будет возвращено из цепочки:

 ...   public function testFilteredValueIsReturnedFromChain() {    $filter = $this->_createMockFilter();     $filter->expectOnce('filter', array('foo'));    $filter->setReturnValue('filter', '***FOO***');     $this->_filterChain->addFilter($filter);     $this->assertEqual('***FOO***', $this->_filterChain->filter('foo'));  }   ... 

Столкнувшись с неудачным тестом, мы корректируем наш код:

 ...   public function filter($text) {    foreach ($this->_filters as $filter) {      $text = $filter->filter($text);    }    return $text;  }   ... 

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

Получение теста заражено

В настоящее время я переписываю известный программный проект с открытым исходным кодом. Всего несколько недель назад я решил, что одно из моих ранних дизайнерских решений могло бы быть немного лучше, поэтому я приступил к массовому рефакторингу. Без тестов я бы никогда не осмелился попробовать это упражнение, учитывая его масштаб, и даже если бы я выдержал его, я бы не поверил, что скрытых ошибок не было. У меня было 77 тестовых случаев, охватывающих эту базу из нескольких тысяч строк кода, и комфорт, предлагаемый этими тестами, был, по меньшей мере, значительным. Первоначально тесты были неудачными из-за масштаба работы, которую я выполнял, но эти неудачные тесты действовали как руководство, пока я менял интерфейсы, перемещал код и переименовывал методы. В конце концов, один за другим, испытания начали проходить, пока, наконец, не прошли все 77 из них. Я был уверен, что огромные изменения, которые я только что развернул в своей кодовой базе, не сломались.

Вернемся к вопросу, который я сделал ранее в этой статье. TDD не всегда доставлял мне удовольствие. Фактически, мои первые несколько месяцев с TDD были адом: половину времени я исправлял тесты, которые не удавались, потому что я немного изменил некоторые детали реализации. Хотя система все еще работала, мои тесты сказали иначе. Мы называем эти тесты хрупкими. Я также провел много времени, вырывая свои волосы, задаваясь вопросом, как вообще начать тестировать определенную часть системы. Это слишком обычная история, и стыдно слышать, что разработчики решили не использовать TDD в результате этих начальных трудностей. Позвольте мне поделиться с вами некоторыми советами, которые я узнал на этом пути:

  • Напишите очень короткие, сжатые методы тестирования — хорошее практическое правило — это одно утверждение на метод тестирования, но это только практическое правило!
  • Никогда — и я не могу этого подчеркнуть — не проверяйте непубличные части системы. Даже если вы думаете, что они играют важную роль, они очень вероятно изменятся с рефакторингом. TDD фокусируется на поведении, а не на реализации.
  • Добавьте некоторую абстракцию между тестом и SUT. В частности, создайте SUT в setUp() вашей платформы xUnit, где это возможно, и / или создайте небольшие фабричные методы для создания SUT. Эти фабричные методы часто называют методами создания. Методы создания позволяют минимизировать количество мест, в которых вам нужно будет редактировать свои тесты, если вы измените способ инициализации SUT.
  • Если вы чувствуете, что повторяете себя, когда тестируете общий набор компонентов, подумайте, можете ли вы реорганизовать рефакторинг для предоставления абстрактного суперкласса, чтобы протестировать некоторые общие функции.
  • Выберите использование внедрения зависимостей при создании компонентов. Это значительно повышает удобство тестирования вашего кода — это позволяет вам получить больше контроля за счет использования фиктивных объектов.

Хотя эти решения стали очевидными для меня со временем, я должен признать, что они стали очевидными для многих других разработчиков много лет назад. У Джерарда Месароса есть целая книга на 800 страниц, посвященная этой предметной области. Тестовые шаблоны xUnit — рефакторинг тестового кода демонстрирует общие подходы, которые разработчики нашли для улучшения способа автоматизации тестов. Если у вас есть опыт тестирования, его стоит прочитать.

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

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