Тестирование защищенных/частных методов, отчеты о покрытии и индекс CRAP

В третьей части моего обучающего руководства по тестированию я объясню, что такое покрытие кода и почему 100%-е покрытие кода не является обязательной целью.

Но сначала поговорим о тестировании ваших частных/защищенных методов!

Тестирование приватных/защищённых методов

Если вы загляните во вторую часть этого руководства, то заметите, что мы инстанцируем класс, который хотим протестировать, через обычный запрос new. У вас может возникнуть вопрос о том, как тестровать защищенные или приватные методы, если к ним невозможно получить доступ напрямую через инстанцированный объект ($url->someProtectedMethod()).

Обычно ответ на этот вопрос звучит так: «Защищенные/приватные методы не тестируются напрямую». Если что-то закрытое доступно только в рамках класса, мы предполагаем, что с ним будут взаимодействовать общедоступные (открытые) методы вашего класса (его API), и в результате получится, что вы тестируете эти методы опосредованно.

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

А что если вы хотите протестировать разные сценарии для определенного метода и у вас нет возможности действовать через общедоступные методы?

Я объясню этот процесс!

Класс User

Создайте новый файл по адресу ./phpUnitTutorial/User.php и вставьте следующее:

Сразу объясню: класс User это не желательный класс. Использования md5() для паролей следует избегать всеми возможными средствами! Вообще-то, это довольно плохой класс в целом. Теперь, когда я это вам сказал, вам будет несложно понять пример, который я вам здесь привожу

<?php
namespace phpUnitTutorial;
class User
{
    const MIN_PASS_LENGTH = 4;
    private $user = array();
    public function __construct(array $user)
    {
        $this->user = $user;
    }
    public function getUser()
    {
        return $this->user;
    }
    public function setPassword($password)
    {
        if (strlen($password) < self::MIN_PASS_LENGTH) {
            return false;
        }
        $this->user['password'] = $this->cryptPassword($password);
        return true;
    }
    private function cryptPassword($password)
    {
        return md5($password);
    }
}

Наш тест будет инстанцировать класс User посредством $user = new User($details);.Вы можете получить доступ к методу ::setPassword(), однако не можете вызвать ::cryptPassword() — но в данном случае вам это и не нужно! Тот факт, что ваш публичный метод взаимодействует с приватным методом, уже говорит о том, что этот метод тестируется, по крайней мере с этим конкретным кодом!

Итак, как создать тест для этого метода? И конструктор, и методы ::setPassword() требуют параметр. Как вы скоро убедитесь, PHPUnit не требует ничего особенного для работы с параметрами метода.

Создание теста

Создайте пустой тест по адресу ./phpUnitTutorial/Test/UserTest.php и настройте скелет:

<?php
namespace phpUnitTutorial\Test;
class UserTest extends \PHPUnit_Framework_TestCase
{
    //
}

Выполненение комплекта тестов завершится ошибкой:

PHPUnit 3.7.14 by Sebastian Bergmann.
Configuration read from /home/developer/phpunit/phpunit.xml
.......F
Time: 21 ms, Memory: 3.25Mb
There was 1 failure:
1) Warning
No tests found in class "phpUnitTutorial\Test\UserTest".

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

Прежде чем идти дальше, мы должны определить, что мы хотим протестировать. Поскольку класс phpUnitTutorial\User является очень простым, мы без труда можем выделить два сценария:

  1. ::setPassword() возвращает true, когда пароль задан, и
  2. ::getUser() возвращает массив пользователя, который содержит новый пароль, который мы потом сравним с ожидаемым результатом.

Начнем с того, что ::setPassword() возвращает значение true. Создайте пустой метод:

public function testSetPasswordReturnsTrueWhenPasswordSuccessfullySet()
{
    //
}

Конструктор phpUnitTutorial\User ожидает параметр, поэтому задайте его прежде чем инстанцировать объект:

<?php
namespace phpUnitTutorial\Test;
use phpUnitTutorial\User;
class UserTest extends \PHPUnit_Framework_TestCase
{
    public function testSetPasswordReturnsTrueWhenPasswordSuccessfullySet()
    {
        $details = array();
        $user = new User($details);
    }
}

Обратите внимание на то, что я добавил оператор use. Теперь мы зададим параметр, необходимый для метода ::setPassword(), и затем вызовем его:

public function testSetPasswordReturnsTrueWhenPasswordSuccessfullySet()
{
    $details = array();
    $user = new User($details);
    $password = 'fubar';
    $result = $user->setPassword($password);
}

Мы ожидаем, что $result будет равняться true, и я знаю, какое утверждение тут надо использовать —assertTrue()! Вот наш завершенный тест:

public function testSetPasswordReturnsTrueWhenPasswordSuccessfullySet()
{
    $details = array();
    $user = new User($details);
    $password = 'fubar';
    $result = $user->setPassword($password);
    $this->assertTrue($result);
}

В результате выполнения комплекта тестов вы получите зеленую полоску в награду за свои труды. Теперь мы можем сосредоточиться на тестировании ::getUser(). Это однострочный метод, и тестировать его будет несложно, так? Ну…не совсем. Дело в том, что мы тестируем ::getUser() для того, чтобы получить доступ к приватному (и, сответственно, недоступному) свойству $user. Мы хотим проверить, что свойство $user имеет ожидаемые нами значения – например, правильно структурированные пароли.

Это значит, что, тестируя ::getUser(), мы также будем тестировать ::__construct(), ::setPassword() и ::cryptPassword(). Вот наш пустой тест:

public function testGetUserReturnsUserWithExpectedValues()
{
    //
}

Единственное, что мы можем протестировать в нашем конкретном сценарии, это то, что пароль, созданный ::cryptPassword(), соответствует нашим ожиданиям. Для начала настройте метод подобно тому, как мы делали это с ::testSetPasswordReturnsTrueWhenPasswordSuccessfullySet():

public function testGetUserReturnsUserWithExpectedValues()
{
    $details = array();
    $user = new User($details);
    $password = 'fubar';
    $user->setPassword($password);
}

Мы не перехватываем результат ::setPassword(), потому что мы предполагаем, что он прошел. Если он не прошел, мы наверняка узнаем об этом в следующих шагах.

Мы знаем наш «сырой» пароль — fubar. Мы видим, что этот пароль «хэшируется» в ::setPassword()посредством md5(). Следовательно, мы можем определить, что мы ожидаем следующий результат:

$expectedPasswordResult = '5185e8b8fd8a71fc80545e144f91faf2';

Затем мы запрашиваем ::getUser(), чтобы получить пользователя в его текущем состоянии:

$currentUser = $user->getUser();

Что мы ожидаем от нашего теста?

Мы ожидаем, что ::getUser() вернет массив, и мы хотим сравнить ключ password с нашим ожидаемым значением. Идеальным утверждением здесь будет assertEquals(). Вот наш завершенный тест:

public function testGetUserReturnsUserWithExpectedValues()
{
    $details = array();
    $user = new User($details);
    $password = 'fubar';
    $user->setPassword($password);
    $expectedPasswordResult = '5185e8b8fd8a71fc80545e144f91faf2';
    $currentUser = $user->getUser();
    $this->assertEquals($expectedPasswordResult, $currentUser['password']);
}

По-видимому, PHPUnit наш тест пришелся по душе:

OK (9 tests, 9 assertions)

Таргетирование приватных/защищенных методов напрямую

А что если мы просто хотим написать больше сценариев для защищенного метода? Или мы не хотим проходить через публичный API напрямую, а вместо этого хотим взаимодействовать исключительно с защищенным методом?

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

/**
 * Вызовите защищенный/частный метод класса.
 *
 * @param object &$object    Инстанцированный объект, на котором мы будем прогонять метод.
 * @param string $methodName Вызываемое имя метода
 * @param array  $parameters Массив параметров для передачи в метод.
 *
 * @return mixed Возврат метода.
 */
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);
}

При помощи invokeMethod() вы можете с легкостью запросить свои приватные или защищенные методы напрямую, без необходимости прохождения через общедоступные методы. Вы просто делаете следующее:

$this->invokeMethod($user, 'cryptPassword', array('passwordToCrypt'));

Это то же самое, что просто набрать

$user->cryptPassword('passwordToCrypt');

предполагая, что ::cryptPassword() являются публичным методом.

Отчёт о покрытии

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

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

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

Этот инструмент сообщит даже то, что вы пропустили какие-либо возможные маршруты по тестируемому вами коду – например, не запустили оператор if и прогнали код внутри блока.

Создание отчета о покрытии

Чтобы создать отчет, просто передайте флаг --coverage-html в PHPUnit, а также адрес, по которому будут созданы файлы. Обычно я использую папку coverage:

./vendor/bin/phpunit --coverage-html coverage

Если вы сейчас взглянете на свою файловую систему, вы увидите, что там уже создана папка coverage с файлами HTML. Откройте файл index.html, и вы увидите следующее:

Если вы используете более раннюю версию PHPUnit, все может выглядеть немного по-другому, поскольку в последней версии был изменен CSS!

Не обращайте внимания на папку vendor. Сконцентрируйтесь на своем собственном коде. Нажмите на phpUnitTutorial — и вы увидите две записи для двух файлов, которые имеются у вас на данный момент.

Стоп! Всего лишь 2?

Именно так, проницательный читатель! Вообще-то, у вас 3 файла с тестами, но одного из них нет на этой странице : StupidTest.php. Это объясняется тем, что этот тест не соответствует коду в вашей кодовой базе. Поэтому PHPUnit не создал для него отчет о покрытии.

Покрытие класса URL

Нажмите на URL.php в отчете о покрытии, и вы увидите следующее:

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

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

Покрытие класса User

Вернитесь на странцу назад и нажмите на User.php:

Взглянув на этот отчет, вы сразу видите, что какие-то строки отмечены красным. В частности, оператор ifвнутри ::setPassword() не выдает значение true ни в одном из наших тестов, поэтому код внутри него никогда не прогоняется.

Для этого конкретного оператора if содержание очень простое: оно возвращает false.

Вернитесь в phpUnitTutorial\Test\UserTest и создайте новый метод, чтобы протестировать этот сценарий:

public function testSetPasswordReturnsFalseWhenPasswordLengthIsTooShort() { // }

В предыдущем тесте ::testSetPasswordReturnsTrueWhenPasswordSuccessfullySet() пароль, который мы задали для ::setPassword(), содержал 5 символов. Чтобы запустить блок if, мы должны задать пароль длиной менее 4 символов. Остальное практические то же, что и раньше:

public function testSetPasswordReturnsFalseWhenPasswordLengthIsTooShort()
{
    $details = array();
    $user = new User($details);
    $password = 'fub';
    $result = $user->setPassword($password);
}

Мы ожидаем, что $result будет false. Для этого есть утверждение! assertFalse() возвращает true, если значение, которое вы передаете ему, равняется false. Вот завершенный тест:

public function testSetPasswordReturnsFalseWhenPasswordLengthIsTooShort()
{
    $details = array();
    $user = new User($details);
    $password = 'fub';
    $result = $user->setPassword($password);
    $this->assertFalse($result);
}

После выполнения комплекта тестов вы получите OK (10 tests, 10 assertions).

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

Индекс CRAP

Если вы взгляните на верхнюю часть своих отчетов о покрытии, вы увидите столбец под названием «CRAP».

Эта аббревиатура расшифровывается как «Анализ и прогнозы риска изменений» (Change Risk Analysis and Predictions). Говоря простым языком, этот индекс показывает, насколько сложно будет вернуться к определенному методу в будущем и понять, что конкретно происходит.

Полное описание индекса CRAP вы найдете здесь.

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

Достаточно сказать то, что чем выше индекс CRAP у вашего метода, тем сложнее этот метод понять. Если ваш код очень простой, то индекс CRAP будет близок к 1 (1 это минимальное его значение). А если ваш код посложнее (скажем, с несколькими блоками if), то индекс CRAP будет повыше.

А если вы добавите несколько вложенных операторов foreach, то индекс CRAP взлетит к небесам! По мере того как вы будете писать тесты для покрытия разных маршрутов исполнения, ваш индекс CRAP будет снижаться. Как только метод будет полностью покрыт, то окончательное значение индекса обычно бывает намного меньше, чем значение, которое отображается, когда метод вообще не покрыт тестами.

Пока ваш код был довольно прост и лаконичен. Существует не так много разных маршрутов исполнения, из-за чего индекс CRAP остается довольно низким. Я предпочитаю кодировать таким образом: небольшие, но многочисленные методы, выполняющие узкоспециализированные задачи.

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

100% покрытие кода и почему оно не обязятельно

Многие разработчики ситают, что необходимо писать модульные тесты до достижения 100% покрытия.

Я не считаю, что 100% покрытие необходимо: если у вашего метода индекс CRAP < 5, то он недостаточно сложный для того, чтобы его тестировать.

Метод с индексом CRAP < 5, скорее всего, настолько простой, что на написание теста для такого метода уйдет не более 5 минут. Так что вам решать, сколько времени вы готовы потратить на написание базовых тестов.

Заключение

В этом уроке вы узнали, что тестирование приватных/защищенных методов можно осуществлять или опосредованно, или путем «обмана», при помощи класса ReflectionClass.

Вы также узнали, как использовать весьма полезный генератор покрытия кода, чтобы отслеживать непокрытый код. И, надеюсь, я убедил вас в том, что 100% покрытие тестами не обязательно.
Думаю, теперь мы можем познакомиться с более сложными понятиями – например, имитирующие объекты (mock) и методы имитации/заглушки (mock/stub), эффективность (и необходимость) внедрения зависимости и/или контейнера служб, а также мониторгинг непредвиденных ситуаций при рефакторинге кода.