Статьи

Давайте TDD простое приложение на PHP

В этом уроке я представлю полный пример простого приложения, созданного строго с использованием TDD в PHP. Я проведу вас через каждый шаг, по одному, объясняя решения, которые я принял, чтобы выполнить задачу. Пример тесно следует правилам TDD: написание тестов, написание кода, рефакторинг.


TDD — это методика тестирования в первую очередь для разработки и проектирования программного обеспечения. Он почти всегда используется в гибких командах, являясь одним из основных инструментов гибкой разработки программного обеспечения. TDD был впервые определен и представлен профессиональному сообществу Кент Беком в 2002 году. С тех пор он стал общепринятой и рекомендуемой техникой в ​​повседневном программировании.

TDD имеет три основных правила:

  1. Вам не разрешается писать какой-либо производственный код, если нет проверочного теста, который бы это оправдывал.
  2. Вам не разрешено писать больше модульных тестов, чем это строго необходимо для того, чтобы он не прошел. Не компилируется / работает не получается.
  3. Вам не разрешено писать больше производственного кода, чем это строго необходимо для успешного прохождения теста.

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

Чтобы установить PHPUnit, вы можете либо следовать предыдущему руководству в нашей сессии «TDD in PHP», либо использовать PEAR, как объяснено в официальной документации :

  • стать пользователем root или использовать sudo
  • убедитесь, что у вас установлена ​​последняя pear upgrade PEAR : pear upgrade PEAR
  • включить автоматическое обнаружение: pear config-set auto_discover 1
  • установить PHPUnit: pear install pear.phpunit.de/PHPUnit

Более подробную информацию и инструкции по установке дополнительных модулей PHPUnit можно найти в официальной документации .

Некоторые дистрибутивы Linux предлагают phpunit в виде предварительно скомпилированного пакета, хотя я всегда рекомендую установку через PEAR, поскольку он гарантирует, что самая последняя и самая последняя версия установлена ​​и используется.

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

  • Перейти к настройке NetBeans (Инструменты / Параметры)
  • Выбрать PHP / Unit Testing
  • Убедитесь, что запись «PHPUnit Script» указывает на допустимый исполняемый файл PHPUnit. Если этого не произойдет, NetBeans сообщит вам об этом, поэтому, если вы не видите никаких красных уведомлений на странице, вам пора. Если нет, найдите исполняемый файл PHPUnit в вашей системе и введите его путь в поле ввода. Для систем Linux этот путь обычно является / usr / bin / phpunit .

Если вы не используете IDE с поддержкой модульного тестирования, вы всегда можете запустить тест непосредственно из консоли:

1
2
cd /my/applications/test/folder
phpunit

Нашей команде поручено реализовать функцию «перенос слов».

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

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


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

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

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

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

Хорошо, это подытожит поведение системы, но это слишком сложно для любого теста. Например, что делать, когда одно слово длиннее, чем количество символов в строке? Хммм … это похоже на крайний случай; мы не можем заменить пробел новой строкой, поскольку у нас нет пробелов в этой строке. Надо насильно завернуть слово, эффективно разделив его на две части.

Эти идеи должны быть достаточно ясными, чтобы мы могли начать программировать. Нам понадобится проект и класс. Давайте назовем это Wrapper .


Давайте создадим наш проект. Должна быть основная папка для исходных классов и папка Tests/ , естественно, для тестов.

Первый файл, который мы создадим, — это тест в папке Tests . Все наши будущие тесты будут содержаться в этой папке, поэтому я не буду указывать это явно в этом руководстве. Назовите тестовый класс чем-то наглядным, но простым. WrapperTest пока WrapperTest ; наш первый тест выглядит примерно так:

1
2
3
4
5
6
7
8
9
require_once dirname(__FILE__) .
 
class WrapperTest extends PHPUnit_Framework_TestCase {
 
    function testCanCreateAWrapper() {
        $wrapper = new Wrapper();
    }
 
}

Помнить! Нам не разрешено писать какой-либо производственный код перед неудачным тестом — даже объявление класса! Вот почему я написал первый простой тест выше, называется canCreateAWrapper . Некоторые считают этот шаг бесполезным, но я считаю, что это хорошая возможность подумать о классе, который мы собираемся создать. Нужен ли нам класс? Как мы должны это назвать? Это должно быть статичным?

Когда вы запустите тест, описанный выше, вы получите сообщение о фатальной ошибке, например:

1
PHP Fatal error: require_once(): Failed opening required ‘/path/to/WordWrapPHP/Tests/../Wrapper.php’ (include_path=’.:/usr/share/php5:/usr/share/php’) in /path/to/WordWrapPHP/Tests/WrapperTest.php on line 3

Хлоп! Мы должны что-то с этим сделать. Создайте пустой класс Wrapper в главной папке проекта.

1
class Wrapper {}

Вот и все. Если вы запустите тест снова, он пройдет. Поздравляем с первым тестом!


Итак, наш проект настроен и запущен; Теперь нам нужно подумать о нашем первом реальном тесте.

Что будет самым простым … самым глупым … самым базовым тестом, который сделает наш текущий рабочий код неудачным? Что ж, первое, что приходит на ум, это « Дайте ему достаточно короткое слово и ожидайте, что результат не изменится ». Это звучит выполнимо; давайте напишем тест.

01
02
03
04
05
06
07
08
09
10
require_once dirname(__FILE__) .
 
class WrapperTest extends PHPUnit_Framework_TestCase {
 
    function testDoesNotWrapAShorterThanMaxCharsWord() {
        $wrapper = new Wrapper();
        assertEquals(‘word’, $wrapper->wrap(‘word’, 5));
    }
 
}

Это выглядит довольно сложно. Что означает «MaxChars» в названии функции? Что означает 5 в методе wrap ?

Я думаю, что-то здесь не совсем так. Разве нет более простого теста, который мы можем запустить? Да, конечно, есть! Что если мы завернем … ничего — пустую строку? Это звучит неплохо. Удалите сложный тест, описанный выше, и вместо этого добавьте наш новый, более простой, показанный ниже:

01
02
03
04
05
06
07
08
09
10
require_once dirname(__FILE__) .
 
class WrapperTest extends PHPUnit_Framework_TestCase {
 
    function testItShouldWrapAnEmptyString() {
        $wrapper = new Wrapper();
        $this->assertEquals(», $wrapper->wrap(»));
    }
 
}

Это намного лучше. Название теста легко понять, у нас нет магических строк или цифр, и, самое главное, ЭТО НЕ СДЕЛАНО!

1
Fatal error: Call to undefined method Wrapper::wrap() in …

Как вы можете заметить, я удалил наш самый первый тест. Бесполезно явно проверять, можно ли инициализировать объект, когда это нужно другим тестам. Это нормально. Со временем вы обнаружите, что удаление тестов является обычным делом. Тесты, особенно юнит-тесты, должны выполняться быстро — очень быстро … и часто — очень часто. Учитывая это, важно исключить избыточность в тестах. Представьте, что вы запускаете тысячи тестов каждый раз, когда сохраняете проект. Для их запуска должно пройти не более пары минут. Так что не пугайтесь, если нужно удалить тест.

Возвращаясь к нашему производственному коду, давайте пройдем этот тест:

1
2
3
4
5
6
7
class Wrapper {
 
    function wrap($text) {
        return;
    }
 
}

Выше мы добавили абсолютно не больше кода, чем необходимо для прохождения теста.


Теперь для следующего неудачного теста:

1
2
3
4
function testItDoesNotWrapAShortEnoughWord() {
       $wrapper = new Wrapper();
       $this->assertEquals(‘word’, $wrapper->wrap(‘word’, 5));
   }

Сообщение об ошибке:

1
Failed asserting that null matches expected ‘word’.

И код, который делает это передать:

1
2
3
function wrap($text) {
       return $text;
   }

Вот это да! Это было легко, не так ли?

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

Во-первых, давайте удалим дублирование инициализации объекта-оболочки. Мы можем сделать это только один раз в setUp() и использовать его для обоих тестов.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
class WrapperTest extends PHPUnit_Framework_TestCase {
 
    private $wrapper;
 
    function setUp() {
        $this->wrapper = new Wrapper();
    }
 
    function testItShouldWrapAnEmptyString() {
        $this->assertEquals(», $this->wrapper->wrap(»));
    }
 
    function testItDoesNotWrapAShortEnoughWord() {
        $this->assertEquals(‘word’, $this->wrapper->wrap(‘word’, 5));
    }
 
}

Метод setup будет запускаться перед каждым новым тестом.

Далее, во втором тесте есть несколько неоднозначных битов. Что такое слово? Что такое «5»? Давайте проясним, чтобы следующему программисту, который читает эти тесты, не приходилось догадываться.

Никогда не забывайте, что ваши тесты также являются самой современной документацией для вашего кода.

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

1
2
3
4
5
function testItDoesNotWrapAShortEnoughWord() {
       $textToBeParsed = ‘word’;
       $maxLineLength = 5;
       $this->assertEquals($textToBeParsed, $this->wrapper->wrap($textToBeParsed, $maxLineLength));
   }

Теперь прочитайте это утверждение еще раз. Разве это не читается лучше? Конечно, это так. Не бойтесь длинных имен переменных для ваших тестов; автозаполнение это твой друг! Лучше быть как можно более описательным.

Теперь для следующего неудачного теста:

1
2
3
4
5
function testItWrapsAWordLongerThanLineLength() {
       $textToBeParsed = ‘alongword’;
       $maxLineLength = 5;
       $this->assertEquals(«along\nword», $this->wrapper->wrap($textToBeParsed, $maxLineLength));
   }

И код, который делает это передать:

1
2
3
4
5
function wrap($text, $lineLength) {
       if (strlen($text) > $lineLength)
           return substr ($text, 0, $lineLength) .
       return $text;
   }

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

У нас есть два варианта решения этой проблемы:

  • изменить код — сделать второй параметр необязательным
  • изменить первый тест — и заставить его вызывать код с параметром

Если вы выберете первый вариант, сделав параметр необязательным, это приведет к небольшой проблеме с текущим кодом. Необязательный параметр также инициализируется значением по умолчанию. Что может быть такое значение? Ноль может показаться логичным, но это будет означать написание кода только для рассмотрения этого особого случая. Установка очень большого числа, чтобы первое утверждение if не приводило к истине, может быть другим решением. Но что это за число? Это 10? Это 10000? Это 10000000? Мы не можем действительно сказать.

Учитывая все это, я просто изменю первый тест:

1
2
3
function testItShouldWrapAnEmptyString() {
       $this->assertEquals(», $this->wrapper->wrap(», 0));
   }

Опять все зелено. Теперь мы можем перейти к следующему тесту. Давайте удостоверимся, что, если у нас будет очень длинное слово, оно будет заключено в несколько строк.

1
2
3
4
5
function testItWrapsAWordSeveralTimesIfItsTooLong() {
       $textToBeParsed = ‘averyverylongword’;
       $maxLineLength = 5;
       $this->assertEquals(«avery\nveryl\nongwo\nrd», $this->wrapper->wrap($textToBeParsed, $maxLineLength));
   }

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

1
2
3
4
5
6
7
8
9
Failed asserting that two strings are equal.
— Expected
+++ Actual
@@ @@
 ‘avery
-veryl
-ongwo
-rd’
+verylongword’

Можете ли вы почувствовать запах идущей петли? Ну, подумай еще раз. Является ли цикл while простейшим кодом, который позволил бы пройти тест?

Согласно «Приоритетам трансформации» (Роберт К. Мартин), это не так. Рекурсия всегда проще, чем цикл, и она гораздо более тестируема.

1
2
3
4
5
function wrap($text, $lineLength) {
       if (strlen($text) > $lineLength)
           return substr ($text, 0, $lineLength) .
       return $text;
   }

Вы можете даже заметить изменение? Это было просто. Все, что мы сделали, вместо того, чтобы соединять с остальной частью строки, мы объединяем с возвращаемым значением вызова себя с остальной частью строки. Отлично!


Следующий самый простой тест? Как насчет двух слов, которые можно переносить, когда в конце строки есть пробел.

1
2
3
4
5
function testItWrapsTwoWordsWhenSpaceAtTheEndOfLine() {
       $textToBeParsed = ‘word word’;
       $maxLineLength = 5;
       $this->assertEquals(«word\nword», $this->wrapper->wrap($textToBeParsed, $maxLineLength));
   }

Это хорошо вписывается. Однако на этот раз решение может стать немного сложнее.

Сначала вы можете обратиться к str_replace() чтобы избавиться от пробела и вставить новую строку. Не; эта дорога ведет в тупик.

Вторым наиболее очевидным выбором будет утверждение if . Что-то вроде этого:

1
2
3
4
5
6
7
function wrap($text, $lineLength) {
       if (strpos($text,’ ‘) == $lineLength)
           return substr ($text, 0, strpos($text, ‘ ‘)) .
       if (strlen($text) > $lineLength)
           return substr ($text, 0, $lineLength) .
       return $text;
   }

Однако это входит в бесконечный цикл, который приведет к ошибкам тестов.

1
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted

На этот раз нам нужно подумать! Проблема в том, что наш первый тест имеет текст с нулевой длиной. Кроме того, strpos() возвращает false, когда не может найти строку. Сравнивать ложь с нулем … есть? Это true Это плохо для нас, потому что цикл станет бесконечным. Решение? Давайте изменим первое условие. Вместо того, чтобы искать пробел и сравнивать его положение с длиной строки, давайте вместо этого попытаемся непосредственно взять символ в положении, указанном длиной строки. Мы будем делать substr() всего один символ, начиная с правильного места в тексте.

1
2
3
4
5
6
7
function wrap($text, $lineLength) {
       if (substr($text, $lineLength — 1, 1) == ‘ ‘)
           return substr ($text, 0, strpos($text, ‘ ‘)) .
       if (strlen($text) > $lineLength)
           return substr ($text, 0, $lineLength) .
       return $text;
   }

Но что, если пробел не в конце строки?

1
2
3
4
5
function testItWrapsTwoWordsWhenLineEndIsAfterFirstWord() {
       $textToBeParsed = ‘word word’;
       $maxLineLength = 7;
       $this->assertEquals(«word\nword», $this->wrapper->wrap($textToBeParsed, $maxLineLength));
   }

Хм … мы должны пересмотреть наши условия снова. Я думаю, что нам, в конце концов, понадобится поиск позиции космического персонажа.

1
2
3
4
5
6
7
8
function wrap($text, $lineLength) {
       if (strlen($text) > $lineLength) {
           if (strpos(substr($text, 0, $lineLength), ‘ ‘) != 0)
               return substr ($text, 0, strpos($text, ‘ ‘)) .
           return substr ($text, 0, $lineLength) .
       }
       return $text;
   }

Вот это да! Это на самом деле работает. Мы переместили первое условие во второе, чтобы избежать бесконечного цикла, и добавили поиск пробела. Тем не менее, это выглядит довольно некрасиво. Вложенные условия? Тьфу. Пришло время для рефакторинга.

1
2
3
4
5
6
7
function wrap($text, $lineLength) {
       if (strlen($text) <= $lineLength)
           return $text;
       if (strpos(substr($text, 0, $lineLength), ‘ ‘) != 0)
           return substr ($text, 0, strpos($text, ‘ ‘)) .
       return substr ($text, 0, $lineLength) .
   }

Это лучше, лучше.


Ничего плохого не может случиться в результате написания теста.

Следующий простейший тест — три слова, заключенные в три строки. Но этот тест проходит. Должны ли вы написать тест, когда вы знаете, что он пройдет? Большую часть времени нет. Но, если у вас есть сомнения, или вы можете представить очевидные изменения в коде, которые приведут к провалу нового теста, а остальные пройдут, напишите это! Ничего плохого не может случиться в результате написания теста. Также учтите, что ваши тесты — это ваша документация. Если ваш тест представляет собой неотъемлемую часть вашей логики, напишите его!

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

Сейчас — три слова в двух строках, причем строка заканчивается в последнем слове; Теперь это не удается.

1
2
3
4
5
function testItWraps3WordsOn2Lines() {
       $textToBeParsed = ‘word word word’;
       $maxLineLength = 12;
       $this->assertEquals(«word word\nword», $this->wrapper->wrap($textToBeParsed, $maxLineLength));
   }

Я почти ожидал, что это сработает. Когда мы расследуем ошибку, мы получаем:

1
2
3
4
5
6
7
8
Failed asserting that two strings are equal.
— Expected
+++ Actual
@@ @@
-‘word word
-word’
+’word
+word word’

Ага. Мы должны заключить в крайнее правое место в строке.

1
2
3
4
5
6
7
function wrap($text, $lineLength) {
       if (strlen($text) <= $lineLength)
           return $text;
       if (strpos(substr($text, 0, $lineLength), ‘ ‘) != 0)
           return substr ($text, 0, strrpos($text, ‘ ‘)) .
       return substr ($text, 0, $lineLength) .
   }

Просто замените strpos() на strrpos() внутри второго оператора if .


Все становится сложнее. Довольно сложно найти провальный тест … или любой другой тест, который еще не был написан.

Это свидетельствует о том, что мы довольно близки к окончательному решению. Но, эй, я только что подумал о тесте, который провалится!

1
2
3
4
5
function testItWraps2WordsOn3Lines() {
       $textToBeParsed = ‘word word’;
       $maxLineLength = 3;
       $this->assertEquals(«wor\nd\nwor\nd», $this->wrapper->wrap($textToBeParsed, $maxLineLength));
   }

Но я был неправ. Это проходит. Хм … Мы закончили? Подождите! Как насчет этого?

1
2
3
4
5
function testItWraps2WordsAtBoundry() {
       $textToBeParsed = ‘word word’;
       $maxLineLength = 4;
       $this->assertEquals(«word\nword», $this->wrapper->wrap($textToBeParsed, $maxLineLength));
   }

Это не удается! Отлично. Когда длина строки равна длине слова, мы хотим, чтобы вторая строка не начиналась с пробела.

1
2
3
4
5
6
7
8
Failed asserting that two strings are equal.
— Expected
+++ Actual
@@ @@
 ‘word
-word’
+ wor
+d’

Есть несколько решений. Мы могли бы ввести другой оператор if чтобы проверить начальное пространство. Это будет соответствовать остальным условиям, которые мы создали. Но разве нет более простого решения? Что если мы просто trim() текст?

1
2
3
4
5
6
7
8
function wrap($text, $lineLength) {
       $text = trim($text);
       if (strlen($text) <= $lineLength)
           return $text;
       if (strpos(substr($text, 0, $lineLength), ‘ ‘) != 0)
           return substr ($text, 0, strrpos($text, ‘ ‘)) .
       return substr ($text, 0, $lineLength) .
   }

Вот и мы.


На данный момент я не могу придумать ни одного неудачного теста для написания. Мы должны быть сделаны! Теперь мы использовали TDD для создания простого, но полезного алгоритма из шести строк.

Несколько слов о том, чтобы остановиться и «сделать». Если вы используете TDD, вы заставляете себя думать о всевозможных ситуациях. Затем вы пишете тесты для этих ситуаций и в процессе начинаете понимать проблему намного лучше. Обычно этот процесс приводит к глубокому знанию алгоритма. Если вы не можете придумать какие-либо другие неудачные тесты, значит ли это, что ваш алгоритм идеален? Не обязательно, если нет заранее определенного набора правил. TDD не гарантирует безошибочный код; это просто помогает вам написать лучший код, который можно лучше понять и изменить.

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


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

Спасибо за прочтение!