Имитирующие методы и переопределение конструкторов

В предыдущем уроке по PHPUnit вы познакомились с такими эффективными инструментами, как имитирующие объекты и методы-заглушки. Эти понятия являются залогом успешного модульного тестирования, и как только они «улягутся» у вас в голове, вы начнете понимать, насколько полезным и простым может быть тестирование.

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

Имитирующие методы

В предыдущем уроке вы познакомились с имитирующими объектами и методами-заглушками. Теперь пришло время познакомиться с имитирующими методами — еще одним понятием, которое в чем-то схоже с двумя предыдущими.

Давайте повторим:

Имитирующий объект

Имитирующий объект это объект, который вы создаете при помощи PHPUnit- метода getMockBuilder(). По сути, это объект, который расширяет определяемый вами класс и позволяет вам применять к нему разные приемчики и делать утверждения.

Метод-заглушка

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

Имитирующий метод

С имитирующим методом все довольно просто – он делает все то же самое, что и оригинальный метод. Иными словами, любой код, содержащийся в методе, который вы имитируете, будет выполняться и не будет возвращать значение null по умолчанию (при условии, что он изначально этого не делал).

Марк Николс очень доступно объясняет, в чем состоит разница между имитирующими методами и методами-заглушками.

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

Не беспокойтесь, если пока все не совсем ясно.

Четыре способа создания объекта при помощи getMockBuilder()

Мы уже рассмотрели способы применения прекрасного инструмента PHPUnit — getMockBuilder() API. А знали ли вы, что существует четыре разных способа создания объекта? Все зависит от того, используете ли вы метод setMethods().

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

Не вызываем setMethods()

Это самый простой способ:

$authorizeNet = $this->getMockBuilder('\AuthorizeNetAIM')->getMock();

Создается имитирующий объект, в котором методы

  • Все являются заглушками,
  • Все возвращают значение null по умолчанию,
  • Легко переопределяемы.

Задание пустого массива

Вы можете задать пустой массив для setMethods():

$authorizeNet = $this->getMockBuilder('\AuthorizeNetAIM')
    ->setMethods(array())
    ->getMock();

Создается точно такой же имитирующий объект, как если бы вы не вызывали setMethods(). Методы

  • Все являются заглушками,
  • Все возвращают значение null по умолчанию,
  • Легко переопределяемы.

Задание значения null

Вы также можете задать значение null:

$authorizeNet = $this->getMockBuilder('\AuthorizeNetAIM')
    ->setMethods(null)
    ->getMock();

Создается имитирующий объект, в котором методы

  • Все являются имитирующими,
  • Когда вызываются, выполняют реальный код, содержащийся внутри метода
  • Не позволяют вам переопределять возвращаемое значение

Задание массива, содержащего имена методов:

$authorizeNet = $this->getMockBuilder('\AuthorizeNetAIM')
    ->setMethods(array('authorizeAndCapture', 'foobar'))
    ->getMock();

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

Методы, которые вы определили:

  • Все являются заглушками,
  • Все возвращают значение null по умолчанию,
  • Легко переопределяемы.

Методы, которые вы не определили:

  • Все являются имитирующими,
  • Когда вызываются, выполняют оригинальный код, содержащийся внутри метода,
  • Не позволяют переопределять возвращаемое значение.

Это означает, что в имитирующем объекте $authorizeNet методы ::authorizeAndCapture() и ::foobar() будут возвращать значение null или вы можете переопределить их возвращаемые значения, но любой метод внутри этого класса, отличный от двух вышеназванных методов, будет выполнять оригинальный код.

Зачем нужны имитирующие методы?

Я начну с очень простого примера, с которым вы, возможно, ранее сталкивались:

<?php
namespace phpUnitTutorial;
class BadCode
{
    protected $user;
    public function __construct(array $user)
    {
        $this->user = $user;
    }
    public function authorize($password)
    {
        if ($this->checkPassword($password)) {
            return true;
        }
        return false;
    }
    protected function checkPassword($password)
    {
        if (empty($this->user['password']) || $this->user['password'] !== $password) {
            echo 'YOU SHALL NOT PASS';
            exit;
        }
        return true;
    }
}

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

Проблема, связанная с этим классом, заключается в том, что вызов exit в вашем коде остановит текущее выполнение PHP, включая любые выполняемые на тот момент тесты! Ничего хорошего в этом нет.

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

protected function checkPassword($password)
{
    if (empty($this->user['password']) || $this->user['password'] !== $password) {
        echo 'YOU SHALL NOT PASS';
        $this->callExit();
    }
    return true;
}
protected function callExit()
{
    exit;
}

Внезапно класс становится пригодным для тестирования, и вам нужно лишь снять заглушку с метода callExit()!

Вот наш тест:

<?php
namespace phpUnitTutorial\Test;
class BadCodeTest extends \PHPUnit_Framework_TestCase
{
    public function testAuthorizeExitsWhenPasswordNotSet()
    {
        $user = array('username' => 'jtreminio');
        $password = 'foo';
        $badCode = $this->getMockBuilder('phpUnitTutorial\BadCode')
            ->setConstructorArgs(array($user))
            ->setMethods(array('callExit'))
            ->getMock();
        $badCode->expects($this->once())
            ->method('callExit');
        $this->expectOutputString('YOU SHALL NOT PASS');
        $badCode->authorize($password);
    }
}

Задавая array('callExit') для setMethods(), вы создали имитирующий объект с набором методов-заглушек и имитирующих методов. В этом примере callExit() является единственным методом, на который можно поставить заглушку. Все остальные методы – имитирующие и будут выполнять содержащийся в них оригинальный код.

Благодаря волшебству имитирующих методов, как только ваш код достигнет стадии if (empty($this->user['password']) || $this->user['password'] !== $password) { и вызовет callExit() , выполнение ваших тестов не остановится, потому что callExit() теперь возвращает значение null вместо того, чтобы выполнять содержащуюся внутри него строку exit.

Если вы ставите заглушку на несколько методов в имитирующем объекте, вы можете вызывать expects()столько раз, сколько пожелаете. В PHPUnit это называется «мягкими» утверждениями, которые не мешают вам иметь в своих тестах всего одно утверждение. Если вы уберем строки

$badCode->expects($this->once())
    ->method('callExit');

наш тест будет успешно выполнен. Но поскольку мы добавили мягкое утверждение, то если в будущем наш код не вызовет callExit() , наш тест окончится неудачей и мы сразу будем знать, что что-то привело наш код в негодность.

Если вы попытаетесь определить возвращаемое значение для метода checkPassword() посредством ->will($this->returnValue('RETURN VALUE HERE!')); PHPUnit проигнорирует это и продолжит выполнять тест. Помните: имитирующие методы не позволяют вам переопределять возвращаемые значения!

Что делать с плохими конструкторами

Иногда встречается унаследованный код, который делает немыслимое – его конструктор не просто настраивает значения свойств объекта, но и по-настоящему работает!

Мишко Хевери объясняет, почему конструктор должен быть «лентяем» по сравнению с другими методами в классе. Он не должен делать практически нечего, кроме простого задания свойств объекта из его параметров.

Пример плохого конструктора

Создайте файл ./phpUnitTutorial/NaughtyConstructor.php и вставьте следующее:

<?php
namespace phpUnitTutorial;
class NaughtyConstructor
{
    public $html;
    public function __construct($url)
    {
        $this->html = file_get_contents($url);
    }
    public function getMetaTags()
    {
        $mime = 'text/plain';
        $filename = "data://{$mime};base64," . base64_encode($this->html);
        return get_meta_tags($filename);
    }
    public function getTitle()
    {
        preg_match("#<title>(.+)</title>#siU", $this->html, $matches);
        return !empty($matches[1]) ? $matches[1] : false;
    }
}

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

$naughty = new NaughtyConstructor('http://jtreminio.com');
$metaTags = $naughty->getMetaTags();
$title = $naughty->getTitle();

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

Ответ: А что если вы попытаетесь выполнить свой тест при отсутствии подключения к Интернету? Поскольку вы в своем конструкторе создали зависимость от file_get_contents(), то для того, чтобы ваш тест завершился успешно, вам нужно всегда быть онлайн. Не очень хорошие новости! Тесты не должны зависеть от внешних факторов, а требование в виде подключения в Интернету — как раз такой фактор.

Создайте базовый тест для текущего кода, файл ./phpUnitTutorial/Test/NaughtConstructorTest.php:

<?php
namespace phpUnitTutorial\Test;
use phpUnitTutorial\NaughtyConstructor;
class NaughtyConstructorTest extends \PHPUnit_Framework_TestCase
{
    public function testGetMetaTagsReturnsArrayOfProperties()
    {
        $naughty = new NaughtyConstructor('http://jtreminio.com');
        $result = $naughty->getMetaTags();
        $expectedAuthor = 'Juan Treminio';
        $this->assertEquals(
            $expectedAuthor,
            $result['author']
        );
    }
}

Перед выполнением этого простого теста я активирую на своем ноутбуке автономный режим, чтобы отключиться от Интернета. Затем я выполняю тест при помощи./vendor/bin/phpunit phpUnitTutorial/Test/NaughtyConstructorTest.php. После длительного ожидания получаю результат: Неудача.

Вполне ожидаемо, правда?

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

Но если бы мы могли изменить этот класс, но нам были бы доступные следующие решения:

  • Задание HTML в качестве параметра конструктора (например, $naughty = new NaughtyConstructor($html);),
  • Использование локального URL для разработки (например, из вашей виртуальной машины для разработки),
  • Перемещение вызова функции file_get_contents() за пределы конструктора, а затем установка заглушки на метод

Но в этом уроке мы не будем применять ни один из этих способов.

Если вы умеете читать, то уже знаете, что нам нужно создать имитацию конструктора! __construct(), я имитирую тебя!

Замените первую строку в своем тесте $naughty = new NaughtyConstructor('http://jtreminio.com'); на getMockBuilder() из PHPUnit:

$naughty = $this->getMockBuilder('\phpUnitTutorial\NaughtyConstructor')
    ->setMethods(array('__construct'))
    ->setConstructorArgs(array('http://jtreminio.com'))
    ->getMock();

Как вы помните, какой бы метод вы ни определили в setMethods(), он будет методом-заглушокй и будет возвращать значение null по умолчанию.

Но сейчас, при выполнении этого теста, этого не происходит. Почему?

Нельзя поставить заглушку на конструктор!

Заглушка это метод, который возвращает значение null по умолчанию. Когда вы инстанцируете объект при помощи new, PHP волшебным образом возвращает новый экземпляр определенного класса. Поэтому нет смысла переписывать это волшебство и возвращать значение null вместо нового объекта, так?

PHPUnit предлагает решение в виде disableOriginalConstructor():

$naughty = $this->getMockBuilder('\phpUnitTutorial\NaughtyConstructor')
    ->setMethods(array('__construct'))
    ->setConstructorArgs(array('http://jtreminio.com'))
    ->disableOriginalConstructor()
    ->getMock();

Примечание: задание __construct для метода setMethods() кажется ненужным, но помните, что если вы не вызовете setMethods() или если вы зададите пустой массив array() для setMethods(), то все методы в объекте будут заглушками, возвращающими значение null. А это нас не устравивает!

Выполните тест снова… Неудача!

Конечно неудача, ведь getMetaTags(); пытается использовать $this->html, который пуст из-за того, чтобы мы деактивировали конструктор!

Тут возникает один интересный вопрос: что произойдет, если мы попытаемся протестировать вебсайт, которым мы не можем управлять? Мы можем успешно тестировать HTML этого сайта неделями, но однажды код сайта может быть изменен, и у нас больше не будет авторского метатега, в отношении которого мы делаем утверждения. И из-за этого наши тесты завершатся неудачей! Это еще раз доказывает, что следует избегать внешних зависимостей в модульных тестах.

Решение нашей проблемы заключается в создании образца HTML в нашем тесте и его вставке в наш код.

Теперь ваш тест должен выглядеть так:

<?php
namespace phpUnitTutorial\Test;
use phpUnitTutorial\NaughtyConstructor;
class NaughtyConstructorTest extends \PHPUnit_Framework_TestCase
{
    public function testGetMetaTagsReturnsArrayOfProperties()
    {
        $naughty = $this->getMockBuilder('\phpUnitTutorial\NaughtyConstructor')
            ->setMethods(array('__construct'))
            ->setConstructorArgs(array('http://jtreminio.com'))
            ->disableOriginalConstructor()
            ->getMock();
        $naughty->html = $this->getHtml();
        $result = $naughty->getMetaTags();
        $expectedAuthor = 'Juan Treminio';
        $this->assertEquals(
            $expectedAuthor,
            $result['author']
        );
    }
    protected function getHtml()
    {
        return '
             <!DOCTYPE html>
                <html lang="en">
                <head>
                    <meta name="viewport" content="width=1, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no"/>
                    <meta name="description" content="Dallas PHP/MySQL Web Developer"/>
                    <meta name="author" content="Juan Treminio"/>
                    <meta name="generator" content="PieCrust 1.0.0-dev"/>
                    <meta name="template-engine" content="Twig"/>
                    <title>Juan Treminio - Dallas PHP/MySQL Web Developer &mdash; Blog</title>
                </head>
                <body>
                </body>
                </html>
        ';
    }
}

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

Мы даже можем добавить еще один тест:

public function testGetTitleReturnsExpectedTitle()
{
    $naughty = $this->getMockBuilder('\phpUnitTutorial\NaughtyConstructor')
        ->setMethods(array('__construct'))
        ->setConstructorArgs(array('http://jtreminio.com'))
        ->disableOriginalConstructor()
        ->getMock();
    $naughty->html = $this->getHtml();
    $result = $naughty->getTitle();
    $expectedTitle = 'Juan Treminio - Dallas PHP/MySQL Web Developer &mdash; Blog';
    $this->assertEquals(
        $expectedTitle,
        $result
    );
}

Зеленая полоска!

Заключение

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