Часть 3: Разработка через тестирование, TDD (Тестирование ПО)
Оглавление
Блочное тестирование уже укоренилось в качестве полезной практики работы с кодом. Протестированный код дает разработчикам уверенность в том, что результат отвечает намерению. Методика разработки, управляемой тестами - это следующий шаг, заключающийся в том, что тесты пишутся раньше, чем код.
Мизерные затраты на написание программ, тестирующих другие программы позволяют нам применять этот способ верификации на всех этапах тестирования: блочное, функциональное, комплексное и приемочное.
У строгого применения TDD есть и другие достоинства - их так много, что обычно всех они расшифровывают акроним TDD как test-driven design (проектирование, управляемое тестами). TDD заставляет по-другому подходить к кодированию. Вместо того, чтобы писать массивный кусок кода, а потом тесты для него, TDD заставляет продумать весь процесс тестирования еще до написания первой строчки.
TDD и блочные тесты
Рассмотрим на примере те преимущества, которые приносит TDD. Для этого нам понадобится не тривиальная задача, чтобы не работать на корзину, но и не слишком сложная, чтобы не погрязнуть в деталях. Прекрасный вариант - поиск совершенных чисел. Совершенным называется натуральное число, равное сумме собственных делителей (то есть всех делителей, отличных от самого числа). Например 6 - совершенное число, так как сумма его собственных делителей (1, 2, 3) равна 6. Напишем на php небольшую программу, которая будет отыскивать совершенные числа.
Следующий код был написан без применения TDD - полагаясь на простую логику и мелкие математические оптимизации.
function isPerfect($number)
{
// Получить делители
$factors = [];
$factors[] = 1;
$factors[] = $number;
for ($i = 2; $i < sqrt($number) + 1; $i++) // примечание [1]
{
if ($number % $i == 0)
{
$factors[] = $i;
if (intdiv($number, $i) != $i) // примечание [2}
{
$factors[] = $number / $i;
}
}
}
// Вычислить сумму делителей
$sum = 0;
foreach ($factors as $i)
{
$sum += $i;
}
// Проверить, является ли число совершенным
return $sum - $number == $number;
}
$number = 0;
fscanf(STDIN, "%d\n", $number);
if (isPerfect($number))
{
echo "{$number} is perfect number\n";
}
Примечания:
- Поскольку получать делители можно парами, нужно перебирать только числа, не превышающие квадратный корень из исходного числа.
Например если для числа 28 найден делитель 2, то сразу можно получить и симметричный делитель 14.
- Проверка intdiv($number, $i) != $i включена для того, чтобы не учитывать одно и то же число дважды.
Мы получаем делители парами, но что случится, если число - полный квадрат? Например для числа 16 делитель 4 следует включить в список только один раз.
Весь код - это один единственный метод, который возвращает true или false в зависимости от того, является ли переданное число совершенным. На первом шаге мы находим все делители. Поскольку 1 и само число являются делителями всегда, то добавляем их в список. Затем в цикле мы доходим до квадратного корня из числа. Эта мелкая оптимизация сделана потому что мы получаем делители парами, поэтому достаточно проверить числа не превышающие квадратный корень.
Возможно ли протестировать этот код? В данной реализации его можно протестировать лишь на каком-то заведомо известном наборе чисел. Сказать, что конкретно этот алгоритм выполняется корректно мы можем лишь благодаря возможности доказать его правильность математически, но не практически.
Так как же может (но не обязательно должен!) выглядеть код, который можно протестировать?
Чтобы ответить на этот вопрос мы применим методологию TDD и будем создавать дизайн проекта основываясь на тестах. В данном примере будет использоваться инструментарий PHPUnit, который создан для того, чтобы брать на себя все необходимые манипуляции по управлению и запуску тестовых сценариев, оставляя на совести разработчика лишь создание самих кейсов. Чуть позже мы подробно с ним познакомимся, а сейчас же нам потребуется от него лишь базовый функционал.
Создаем инфраструктуру
Перед тем, как приступить к написанию кода нам потребуется подготовить инфраструктуру для работы. Крайне рекомендуется делать все руками из консоли или файлового менеджера. Так вы сможете лучше понять, как устроена система изнутри.
- Создаем в папке проекта два каталога: tests и src.
- Создаем файл composer.json следующего содержания
{
"name": "geekbrains/phptesting",
"description": "PHPUnit, TDD and other",
"minimum-stability": "dev",
"license": "proprietary",
"authors": [
{
"name": "GeekBrains Student",
"email": "email@example.com"
}
],
"autoload": {
"psr-4": {
"PerfectNumberTDD\\": ["src/"]
}
},
"require-dev": {
"phpunit/phpunit": "5.7.4",
"squizlabs/php_codesniffer": "3.0.x-dev"
}
}
- Устанавливаем composer.phar возпользовавший инструкцией по адресу https://getcomposer.org/download/ (в вашей системе уже должен быть установлен и настроен интерпретатор php). В каталоге проекта должен появиться файл composer.phar.
- Запускаем из командного интерпретатора инструкцию php composer.phar install. Установка займет некоторое время. По окончанию в каталоге проекта появится папка vendor c необходимыми для работы компонентами.
- Создадим файл phpunit.xml следующего содержания
<!-- Обратите внимание, что схема должна соответствовать той версии phpunit, которую вы используете. Схема - это значение атрибута xsi:noNamespaceSchemaLocation -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/5.7/phpunit.xsd" bootstrap="vendor/autoload.php">
<!-- Указываем где размещаются тесты. Секций testsuites может быть более одной. Нужно это для того, чтобы иметь возможность тестировать различные аспекты системы. -->
<testsuites>
<testsuite name="Core functionality">
<directory>tests</directory>
</testsuite>
</testsuites>
<!-- whitelist для указания того, какие файлы будут проверяться на покрытие тестами -->
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">src</directory>
</whitelist>
</filter>
</phpunit>
- В каталоге tests создадим файл PerfectNumberTest.php и напишем в нем несколько строк кода
namespace PerfectNumberTDD;
use PHPUnit_Framework_TestCase;
/**
* Набор тестовых случаев для проверки класса PerfectNumber
*/
class PerfectNumberTest extends PHPUnit_Framework_TestCase
{
/*
* В этом классе мы будем писать тесты.
*/
}
- Для проверки того, что все правильно сделано следует выполнить в консоли (находясь в каталоге проекта команду php vendor/bin/phpunit -c phpunit.xml.
Мы увидим сообщение о том, что тесты не найдены. Все верно. Ведь мы не написали еще ни одного теста.
PHPUnit 5.7.4 by Sebastian Bergmann and contributors.
W 1 / 1 (100%)
Time: 23 ms, Memory: 4.00MB
There was 1 warning:
1) Warning
No tests found in class "PerfectNumberTDD\PerfectNumberTest".
WARNINGS!
Tests: 1, Assertions: 0, Warnings: 1.
- Теперь вся инфраструктура для создания проекта готова и можно приступать.
Разработка посредством TDD
Вначале нам стоит задуматься о том, какими свойствами должен обладать еще не написанный код. Во-первых это должен быть объект. Этот факт исходит из того, что наш код должен обладать некоторым набором методов. Такими как проверка является ли число делителем, добавления новых делителей в список. Во-вторых этот тип данных должен быть иммутабельным (immutable) или другими словами неизменяемым. Этот факт следует из того, что нельзя изменить список делителей числа - он всегда один и то же, а так же нельзя изменить для числа свойство, которое указывает совершенное оно или нет. Так же, как мы увидим дальше, свойство неизменяемости делает код более лаконичным.
Напишем первый тест, который покажет, что создался нужный экземпляр объекта (все тесты следует размещать в классе PerfectNumberTest).
/**
* Проверим, что создался нужный экземпляр
*/
public function testPerfectNumberCreation()
{
$p = new PerfectNumber(1);
$this->assertInstanceOf('\PerfectNumberTDD\PerfectNumber', $p);
}
Этот тест не делает ничего, кроме проверки на корректность создания объекта. И такие тесты на самом деле пишутся крайне редко.
Методы, которые начинаются со слова assert обозначают какую-либо проверку. В данном случае мы проверяем, что экземпляр класса $p действительно имеет тип базовый тип \PerfectNumberTDD\PerfectNumber (класс с именем PerfectNumber, который расположен в пространстве имен PerfectNumberTDD).
Попытаемся запустить данный тест. В консоли перейдем в папку с проектом и запустим команду
php vendor/bin/phpunit -c phpunit.xml
Мы увидим ошибку о том, что класс не найден (и это вполне ожидаемо, так как мы написали тест, но не класс).
PHPUnit 5.7.4 by Sebastian Bergmann and contributors.
E 1 / 1 (100%)
Time: 20 ms, Memory: 4.00MB
There was 1 error:
1) PerfectNumberTDD\PerfectNumberTest::testPerfectNumberCreation
Error: Class 'PerfectNumberTDD\PerfectNumber' not found
/tmp/prj/tests/PerfectNumberTest.php:17
ERRORS!
Tests: 1, Assertions: 0, Errors: 1.
И это одна из особенностей разработки с применением методологии TDD - разработчик сначала создает тесты (которые конечно же не проходят) и лишь затем создает код, который реализует проверяемую функциональность, а тесты начинают выполняться корректно.
Далее мы не будем подробно останавливаться на запуске тестов, а будем лишь обсуждать результат и писать код.
Очевидно, что нам требуется написать какой-то код, который реализует проверяемый кейс. Создадим в каталоге src файл PerfectNumber.php со следующим содержимым.
namespace PerfectNumberTDD;
class PerfectNumber
{
public function __construct($number)
{
}
}
И далее запустите тесты на исполнение. Как вы можете видеть, все тесты прошли успешно. А это значит, что первый из проверяемых аспектов успешно реализован.
Следующим тестом станет проверка, что единица всегда является делителем числа.
/**
* Проверяем, что делителями числа 1 является только единица.
*/
public function testFactorsFor1()
{
$expected = [1];
$p = new PerfectNumber(1);
$this->assertEquals($expected, $p->getFactors(), "", 0.0, 10, true);
}
Здесь появляются новые методы и функциональность - это метод, который вернет список делителей числа. Не стоит пока заострять внимание на огромном количестве аргументов у assertEquals - он лишь позволяет сравнивать массивы $expected и тот, что вернул getFactors() без учета порядка аргументов.
Иными словами, два массива [1, 2] и [2, 1] будут считаться одинаковыми, так как содержат одинаковый набор элементов.
Почему этот и предыдущий тест считаются полезными, ведь он совсем простой? Чаще всего эти тесты пишутся не для тестирования программы, а для тестирования инфраструктуры. Они позволяют выявить что-то пошло не так еще до начала тестирования сложных элементов кода.
Ведь нам требуется правильно настроить переменные окружения, поставить дополнительное по, настроить composer.
Некоторые разработчики называют подобные тесты тестами канарейки. Как и канарейка, которую берут в шахты, эти тесты погибают при первых признаках некорректного окружения.
Напишем код, который реализует проверяемый аспект.
namespace PerfectNumberTDD;
class PerfectNumber
{
private $number;
private $factors;
public function __construct($number)
{
$this->number = $number;
$this->factors = [];
$this->factors[] = 1;
}
public function getFactors()
{
return $this->factors;
}
}
Вспомним наш предыдущий вариант: делителем числа кроме единицы так же является и само число. Напишем тест, который заведомо не пройдет.
public function testFactorsContainNumber()
{
$p = new PerfectNumber(100);
$this->assertContains(100, $p->getFactors());
$this->assertContains(1, $p->getFactors());
}
Убедившись, что тест все же не прошел, расширим функционал класса, изменив конструктор.
public function __construct($number)
{
$this->number = $number;
$this->factors = [];
$this->factors[] = 1;
$this->factors[] = $number;
}
И как мы видим, тест testFactorsFor1 сломался. И немудрено: ведь массив factors не должен содержать дубликатов числа. Поправим это недоразумение.
public function __construct($number)
{
$this->number = $number;
$this->factors = [];
$this->factors[] = 1;
if (!in_array($number, $this->factors) && $number > 0) {
$this->factors[] = $number;
}
}
А теперь подумаем: при проверке всех делителей числа нам потребуется их добавлять в список делителей. Делать это постоянно обращаясь к массиву $this->factor неправильно. И нам потребуется метод добавления в список. Почему метод? Вспомним, что в php нет множеств, а список делителей числа должен быть множеством потому что каждое значение в нем должно повторяться ровно один раз. Также нам нельзя добавлять в список делителей ноль и отрицательные числа. А для этого нам потребуется придумать некоторый способ проверять на делимость. Напишем несколько тестов.
public function testZeroIsNotFactor()
{
// Не имеет значения, какое число мы будем использовать
$p = new PerfectNumber(42);
$this->assertFalse($p->isFactor(0));
}
public function testIsFactor()
{
$p1 = new PerfectNumber(10);
$this->assertTrue($p1->isFactor(1));
$p2 = new PerfectNumber(25);
$this->assertTrue($p2->isFactor(5));
$p3 = new PerfectNumber(25);
$this->assertFalse($p3->isFactor(6));
}
И реализуем соответствующий функционал.
public function isFactor($factor)
{
if ($factor > 0) {
return $this->number % $factor == 0;
} else {
return false;
}
}
Видим, что все хорошо. Теперь можно перейти непосредственно к разработке самого метода, который и будет добавлять числа в список делителей. Вспомним, что объект должен быть иммутабельным, а следовательно метод добавления в список должен быть помечен либо как private, либо как protected. Это автоматически накладывает некоторые ограничения на тестирование таких методов. Но благодаря механизму рефлексии в php мы все же сможем их протестировать. Нам для этого потребуется дополнительный код, который мы разместим в классе PerfectNumberTest.
public function invokeMethod(&$object, $methodName, array $parameters = array())
{
$reflection = new \ReflectionClass(get_class($object));
$method = $reflection->getMethod($methodName);
$method->setAccessible(true);
return $method->invokeArgs($object, $parameters);
}
Теперь мы сможем написать кейс, тестирующий добавление чисел в список делителей.
public function testAddFactors()
{
$p = new PerfectNumber(20);
$this->invokeMethod($p, 'addFactor', [2]);
$this->invokeMethod($p, 'addFactor', [4]);
$this->invokeMethod($p, 'addFactor', [5]);
$this->invokeMethod($p, 'addFactor', [10]);
$expected = [1, 2, 4, 5, 10, 20];
$this->assertEquals($expected, $p->getFactors(), "", 0.0, 10, true);
}
Тест провалился, а мы приступаем к написанию кода, который сделает его работающим. Заодно мы исправим конструктор таким образом, чтобы использовался новый метод addFactor().
public function __construct($number)
{
$this->number = $number;
$this->factors = [];
$this->addFactor(1);
$this->addFactor($number);
}
protected function addFactor($factor)
{
if ($this->isFactor($factor)) {
// Это не самая удачная строка кода.
// Она призвана оставить в массиве $this->factors только уникальные,
// отличные от нуля значения.
$this->factors = array_unique( // оставляем только уникальные значения
array_merge( // Объединение двух массивов
// отфильтрованняй массив делителей
array_filter([$factor, intdiv($this->number, $factor)]),
$this->factors // предыдущее содержимое массива делителей
)
);
}
}
Мы уже научили код добавлять делители в список делителей. Так почему бы нам не протестировать таким образом метод isPerfect, который скажет о том, является ли число совершенным?
public function testIsPerfectCreatedByHands()
{
$p = new PerfectNumber(6);
$this->invokeMethod($p, 'addFactor', [2]);
$this->invokeMethod($p, 'addFactor', [3]);
$this->invokeMethod($p, 'addFactor', [6]);
$this->assertTrue($p->isPerfect());
}
И сам метод.
public function isPerfect()
{
return $this->sumOfFactors() - $this->number == $this->number;
}
protected function sumOfFactors()
{
return array_reduce($this->factors, function ($carry, $item) {
return $carry + $item;
}, 0);
}
У нас появился метод sumOfFactors, но он достаточно тривиален. Поэтому не будем покрывать его тестами.
Теперь нужно протестировать лишь два аспекта: генерацию списка делителей и проверку на корректность на нескольких совершенных числах.
public function testFactorsFor6()
{
$expected = [1, 2, 3, 6];
$p = new PerfectNumber(6);
$this->assertEquals($expected, $p->getFactors(), "", 0.0, 10, true);
}
Реализуем аспект генерации списка делителей.
public function __construct($number)
{
$this->number = $number;
$this->factors = [];
$this->addFactor(1);
$this->addFactor($number);
$this->calculateFactors();
}
protected function calculateFactors()
{
for ($i = 2; $i < sqrt($this->number) + 1; $i++) {
$this->addFactor($i);
}
}
Почему мы генерируем список делителей в конструкторе? Потому что это одно из следствий требования иммутабельности.
Самый последний тест должен работать без каких-либо правок кода. Он покажет нам, что все работает корректно.
public function testIsPerfect()
{
$p = new PerfectNumber(6);
$this->assertTrue($p->isPerfect());
$p = new PerfectNumber(7);
$this->assertFalse($p->isPerfect());
}
Полный код проекта вы найдете в каталоге lesson_01/perfect_number_tdd.
Выводы
Сравнивая два варианта кода, легко заметить, что TDD-версия длиннее, но при этом разбита на множество мелких методов. Глядя на эти методы вы ясно представляете, что делается и какой ожидаемый результат.
По прошествию какого-то времени, когда потребуется внести изменения в код, вы сможете вносить изменения с уверенностью в том, что ничего не поломается. Если что-то и случится, то среди коротких методов всегда легко отыскать причину, а тесты всегда вам подскажут, где и что сломалось (вспомните момент, когда вы добавляли в список делителей само число). Если же ваш код - это множество длинных методов, то изолировать ошибку очень и очень сложно (вам придется долгими бессонными ночами сидеть с отладчиком, либо ставить var_dump в коде в разных местах). Запомните: если в код метода надо вставлять комментарии - значит это плохой метод и его можно сделать лучше, а стороннему разработчику потребуется очень много времени на то, чтобы вникнуть в суть работы этого кода.
Дизайн системы
Положительное влияние на общее качество дизайна системы - это еще одна неоспоримая особенность TDD. Предположим, что новым требованием руководства стало нахождение не только совершенных, но и чисел, у которых сумма делителей меньше или больше самого числа. В случае одной монолитной функции придется внести в код побочные эффекты, что крайне негативно сказывается на его качестве, либо разбивать один метод на множество мелких, которые будет чем-то напоминать tdd-версию.
А так достаточно будет просто добавить два метода.
public function isDeficient()
{
return $this->sumOfFactors() - $this->number > $this->number;
}
public function isAbundand()
{
return $this->sumOfFactors() - $this->number < $this->number;
}
Все есть. TDD-код часто состоит из таких элементов, использовать которые повторно очень и очень просто.
Подводя итоги. Преимущества TDD перед стандартным способом написания кода:
- Код создается с учетом потребителей, так как первым потребителем является ваш тест.
- Тесты для тривиальных случаев позволяют вовремя выяснить, что нарушена какая-либо критическая зависимость или инфраструктурный компонент.
- Важно тестировать граничные условия и особые случаи. Если какой-либо аспект сложно протестировать, то следует его привести к более простому виду (сложность уже говорит о неправильном проектировании). Если не удается упростить, то тесты должны быть как можно более тщательными. Сложный элемент нужно тестировать более тщательно.
- Тесты нужно всегда использовать как часть инфраструктуры сборки проекта. Так как самые болезненные и трудно уловимые ошибки чаще всего возникают при внесении изменений в совершенно другие участки кода. Прогон всех блочных тестов нужно осуществлять перед каждой сборкой проекта.
- Наличие блочных тестов позволяет проводить более агрессивное изменение кода. А также делает его более открытым для экспериментов вида “а что если”. Ведь любое объемное изменение всегда будет проверено.
Литература
- “Искусство тестирования программ” Гленфорд Майерс, Том Баджетт, Кори Сандлер, ISBN: 978-5-8459-1974-8
- “Продуктивный программист. Как сделать сложное простым, а невозможное - возможным” Нил Форд, ISBN: 978-5-93286-156-1
Исходный код
Оглавление
Категории: Разработка