Основанная на Тестировании Разработка. Пример на языке PERL
Прежде чем написать строку кода, программист должен знать зачем она нужна, иначе эта строка не будет делать того, что имел ввиду программист. Как проверить, что код делает именно то, что от него ожидается? Просто - выразить ожидания в виде тестов.
Например, используя входящий в поставку Perl 5.8 модуль Test::Simple, можно написать такой тест для функции factorial:
using Test::Simple tests => 1;
ok(factorial(5) == 120);
Суть Разработки, Управляемой Тестами (РУТ) в том, чтобы ещё до того как будет написан код, сформулировать требования к нему в виде тестов. После этого написать самый простой код, удовлетворяющий этим тестам. Такой подход позволяет получить набор тестов, проверяющий всю функциональность разрабатываемого кода. Не менее важно и то, что такой подход заставляет разработчика чётко выражать свои мысли в тестах, а затем и в коде, что улучшает дизайн и удобочитаемость программы.
Пример
Не буду сильно углубляться в теорию, перейду к практике.
Простая задача:
пусть нужно напечатать положительное целое число, отделяя каждый 3-й порядок запятой. Например, число 40678907654 должно быть напечатано как 40,678,907,654
Я буду писать тесты при помощи модуля Test::More. Этот модуль предоставляет обширный набор тестирующих функций и входит в базовую поставку Perl-а, начиная с версии 5.8.
use Test::More tests => 1; # plan for 1 test
запускаю и немедленно получаю:
perl -w TDD_TPR_ru.pod
1..1
# No tests run!
При подключении модуля я указал, что далее должен быть один тест, самого теста не было, о чём модуль тут же сообщил. Я сделал это намеренно, теперь я уверен, что модуль Test::More у меня работает.
Самый простой тест, который я могу придумать:
is (readable(1), “1″);п
по понятной причине не компилируется - функции readable просто нет. Этот тест, как раз и говорит, что необходима функция readable:
sub readable {}
Теперь тест компилируется, но не проходит:
1..1
not ok 1
# Failed test (TDD_TPR_ru.pod at line 128)
# got: undef
# expected: ‘1′
# Looks like you failed 1 tests of 1.
Самый простой способ пройти этот тест:
sub readable {
return 1;
}
Тест проходит! Но это не совсем решение исходной задачи, что доказывает следующий тест
is (readable(13), “13″);
который не проходит. Всё что нужно поправить - возвратить аргумент:
sub readable {
return shift;
}
теперь я уверен, что функция работает правильно для всех чисел меньших 1000, поэтому следующий тест:
is (readable(1000), “1,000″);
Конечно этот тест не проходит, readable(1000) возвращает 1000. Следующее изменение удовлетворяет тесту:
sub readable {
my $number = shift;
return “1,000″ if $number == 1000;
return $number;
}
Но это не решает проблемы, а лишь убирает симптом. Во первых, важно не только число 1000, а все числа большие 999. Переработаем условие:
sub readable {
my $number = shift;
return “1,000″ if $number > 999;
return $number;
}
Во-вторых, нужно возвращать не 1,000, а само число, у которого последние 3 цифры отделены запятой. Этого можно легко добиться при помощи регулярного выражения:
sub readable {
my $number = shift;
$number =~ s/(d{3})$/,$1/ if $number > 999;
return $number;
}
Работает!
Такой “обман”, когда реализуется самый простой код, который обычно просто возвращает ожидаемое тестом значение, является абсолютно нормальным приёмом разработки. Теперь, можно смело перерабатывать код, делая его более общим и правильным: тест просигналит, если в изменения вкрадётся ошибка.
Проверяю на всякий случай числом побольше:
is (readable(12000), “12,000″);
Всё равно работает, но мне не нравится if, я уверен, что можно обойтись без него:
sub readable {
my $number = shift;
$number =~ s/(d{3})$/,$1/;
return $number;
}
Вроде бы можно, но дополнительная проверка
is (readable(999), “999″);
обнаруживает ошибку :
# got: ‘,999′
# expected: ‘999′
Выходит, что я зря убрал if? Нет, ошибку можно исправить, изменив регулярное выражение.
sub readable {
my $number = shift;
$number =~ s/(d) # одна цифра
(d{3})$ # перед тремя последними
/$1,$2/x; # отделяется запятой
return $number;
}
Хорошо, но более длинное число не будет правильно преобразовано:
is(readable(13153295), “13,153,295″);
Действительно:
# got: ‘13153,295′
# expected: ‘13,153,295′
Эти повторяющиеся запятые наводят на мысль о цикле. Например, таком:
sub readable {
my $number = shift;
my $matched;
do {
$matched = $number =~ s{
(d+) # одна или более цифр
(d{3}) # перед тремя
(,|$) # перед запятой или концом
}{$1,$2$3}x;# отделяются запятой
} while($matched);
return $number;
}
Код страшноват, и довольно сильно изменился с прошлого шага, но тест прошёл, проверяем на действительно большом числе
is (readable(40_678_907_654), “40,678,907,654″);
И с этим тестом код справляется, похоже на то, что теперь он свою задачу решает. Тем не менее, функция получилась громоздкой и некрасивой и требует переработки.
Ok, одна из причин, по которой код плохо выглядит - редко используемый цикл do {} while(). Perl достаточно мощен, чтоб выразить тоже самое короче:
sub readable {
my $number = shift;
1 while $number =~ s{
(d+) # одна или более цифр
(d{3}) # перед тремя
(,|$) # перед запятой или концом
}{$1,$2$3}x; # отделяются запятой
return $number;
}
Теперь осталось поработать над самим регулярным выражением. Для удобства я хочу его выделить его в отдельную процедуру separate3digits. Эта процедура должна отделять от своего аргумента следующие 3 цифры, и, когда это невозможно, возвращать 0. Теперь точно ясно, что должна делать эта процедура, я попробую написать её заново, делая более крупные шаги.
$val = ‘13′; is (separate3digits($val), 0);
я использую здесь дополнительную переменную $val потому, что separate3digits может изменять свой аргумент.
самый простой код, удовлетворяющий тесту, просто возвращает 0:
sub separate3digits {
return 0;
}
чтобы пройти следующий тест:
$val = ‘1314′; ok (separate3digits($val)); is ($val, ‘1,314′);
можно применить регулярное выражение с заменой, отделяющее от последовательности цифр последние три:
sub separate3digits {
return $_[0] =~ s/(d+)(d{3})$/$1,$2/;
}
Работает! Следующий тест:
$val = ‘1314,365′; ok (separate3digits($val)); is ($val, ‘1,314,365′);
нужно заканчивать поиск образца на запятой:
s/(d+)(d{3})(,|$)/$1,$2$3/;
В результате, я пришёл к тому же выражению замены что и раньше, но теперь оно проверенно и документировано тестами. Теперь readable можно написать так:
sub readable {
my $number = shift;
1 while separate3digits($number);
return $number;
}
Этот код меня удовлетворяет значительно больше, и я считаю, что задача теперь решена полностью. Самолюбие удовлетворено, но интересно, как эту задачу решают другие. Решение из руководства по Perl (perlop):
1 while s/(d)(ddd)(?!d)/$1,$2/g;
Ну хорошо, они не использовали $3, но может это действительно необязательно? Убираю:
s/(d+)(d{3})(,|$)/$1,$2/;
Нет, один тест ломается:
# got: ‘1,314365′
# expected: ‘1,314,365′
так как запятая, на которой должен заканчивается поиск, не попадает в результат. Ага, но ведь можно заканчивать поиск на первой не-цифре:
s/(d+)(d{3})/$1,$2/;
Теперь опять все тесты проходят! Хорошо, (ddd), на мой взгляд, более понятно чем (d{3}). Окончательная версия separate3digits выглядит так:
sub separate3digits {
return $_[0] =~ s/(d+)(ddd)/$1,$2/;
}
Все тесты по прежнему проходят, следовательно процедура делает свою работу правильно. Теперь, кстати, видно, что в версии из perlop модификатор /g - лишний. Единственное оставшееся отличие этой версии - явное указание не-цифры, но я не вижу в этом преимущества.
Организация тестов
Я писал код для этой статьи в том же файле, что и тесты. Такая организация удобна для написания статьи, но неудобна при создании реальных программ, состоящих из многих модулей, которые используют функциональность друг-друга. В этом случае, разумно разделить код и тесты: пусть разрабатываемый код находится в модуле formatting.pm:
use strict;
use warnings;
sub separate3digits {
return $_[0] =~ s/(d+)(ddd)/$1,$2/;
}
sub readable {
my $number = shift;
1 while separate3digits($number);
return $number;
}
Тогда тесты можно поместить в файл с таким же названием, но с расширением “.t”, formatting.t:
package formatting; # теперь тесты могут пользоваться всеми функциями из тестируемого модуля
use Test::More tests => 12;
use strict;
use formatting;
is (readable(1), “1″);
is (readable(13), “13″);
is (readable(1000), “1,000″);
is (readable(12000), “12,000″);
is (readable(999), “999″);
is (readable(13153295), “13,153,295″);
is (readable(40_678_907_654), “40,678,907,654″);
my $val;
$val = ‘13′; ok (!separate3digits($val));
$val = ‘1314′; ok (separate3digits($val)); is ($val, ‘1,314′);
$val = ‘1314,365′; ok (separate3digits($val)); is ($val, ‘1,314,365′);
Возможны и более сложные способы организации, например, когда тесты помещаются в отдельную директорию, но пока количество модулей невелико, я использую простое разграничение по имени файла. Теперь, чтобы протестировать все модули, достаточно команды
perl -w -M”Test::Harness” -e “runtests glob(’*.t’)”
Test::Harness - это ещё один модуль поддержки тестирования, входящий в поставку Perl. Он позволяет запустить сразу много тестовых модулей и сформировать отчёт:
formatting….ok
All tests successful.
Files=1, Tests=12, 2 wallclock secs ( 0.00 cusr + 0.00 csys = 0.00 CPU)
Выводы
Итак, долго ли, коротко ли, но я пришёл к удовлетворительному решению задачи. Какими правилами я руководствовался?
Правило “Сломанного теста”: не писать новый код, пока нет теста, который не проходит.
Избегать некрасивого кода.
То есть допустимо переписывать уже проверенный код, чтобы добиться лучшей производительности, удобочитаемости и т.д., но есть некоторый предел, когда следующее изменение внесёт новую функциональность, и тогда нужен новый тест, который проверит эту функциональность.
Второе правило довольно трудно формализовать, достаточно хорошая классификация некрасивостей кода приведена в книге Мартина Фаулера “Refactoring”. Способность отличать некрасивый код от красивого приходит с опытом и, начиная с некоторого момента, не требует пояснений. Способность писать красивый код приходит гораздо позже.
Кент Бек использует вместо этого правило Избегать дублирования(Eliminate duplication), но трактует понятие дублирования очень широко.
Прежде чем написать хоть какой-то код, нужно придумать, как этот код можно проверить. Если никак не придумывается - значит с кодом (ещё не написанным) что-то не в порядке.
Весь процесс разработки состоял в повторении следующих действий:
Поломать - написать небольшой тест, который не работает, или даже не компилируется.
Починить - быстро написать простейший код, который удовлетворит тесту.
Улучшить - переработать код, если в результате выполнения предыдущего шага, возникло дублирование или код получился неудобочитаемым.
Как начать разработку, каким должен быть первый тест?
Я пользуюсь следующим правилом: максимально упростить исходную задачу, сохранив, тем не менее, её смысл. Обычно достаточно выбрать максимально простой частный случай такой задачи. Написать тест, что будет первым шагом цикла Поломать/Починить/Улучшить.
Следующий тест выбирается по тем же принципам: нужно выбрать минимальное добавление к уже удовлетворённым требованиям, которое приблизит решение к окончательному. Написать тест для этого требования. И так далее, пока не будет достигнуто окончательное решение.
Дополнительные правила, которыми я пользуюсь в разработке:
Запускать тесты как можно чаще
Код “…должен быть настолько простым, насколько возможно, но не проще” (А. Эйнштейн)
Код и тесты должны передавать своё назначение как можно точнее.
Не тестировать все варианты, тестировать достаточно вариантов, чтоб быть уверенным, что код будет работать правильно и в остальных возможных вариантах.
Что делает Разработку, Управляемую Тестами такой привлекательной для меня? То, что в результате я получаю набор тестов, проверяющих весь написанный код? Нет, хотя это очень удобно и помогает при дальнейшей поддержке программы. То, что я не боюсь изменять код, так как тесты позволяют проверить не ошибочное ли это изменение? Тоже не это. В конце-концов, основное, что требуется от кода, чтобы он делал то, что надо, чтоб он решал поставленную задачу. А для этого задачу нужно понимать, чем полнее - тем лучше. Пока я обдумываю новый тест, я глубже вникаю в задачу, научаюсь видеть частные случаи, “неожиданное” поведение. ОТР помогает немедленно проверять возникающие догадки о том, как лучше всего решать задачу.
thalion.kiev.ua
Май
14,
2008
— Рубрика: Практика
Метки: код, поломать, починить, улучшить
