Статьи

Легче тестировать с помощью насмешек

К сожалению, хотя базовый принцип тестирования довольно прост, полностью внедрить этот процесс в повседневный рабочий процесс кодирования сложнее, чем можно было бы надеяться. Один только различный жаргон может оказаться подавляющим! К счастью, есть множество инструментов, которые помогают сделать процесс максимально простым. Mockery, главный фреймворк для фиктивных объектов для PHP, является одним из таких инструментов!

В этой статье мы рассмотрим, что такое насмешки, почему они полезны и как интегрировать Mockery в ваш рабочий процесс тестирования.


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

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

Помните: жаргон — это просто жаргон. Никогда не позволяйте запутанной терминологии помешать вам освоить новый навык.

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

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

Библиотека тестирования де-факто для PHP, PHPUnit, поставляется с собственным API-интерфейсом для проверки объектов; однако, к сожалению, работать с ним может быть сложно. Как вы наверняка знаете, чем сложнее тестирование, тем больше вероятность того, что разработчик просто (и, к сожалению) не будет.

К счастью, через Packagist (хранилище пакетов Composer) доступно множество сторонних решений, которые обеспечивают повышенную читаемость и, что более важно, возможность записи . Среди этих решений — и наиболее заметных из множества — есть Mockery, не зависящая от фреймворка фреймворк для фиктивных объектов.

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


Как и большинство современных инструментов PHP, Mockery может быть установлен с Composer.

Как и большинство инструментов PHP в настоящее время, рекомендуемый метод установки Mockery — через Composer (хотя он также доступен через Pear).

Подожди, что это за композитор ? Это предпочтительный инструмент сообщества PHP для управления зависимостями. Он предоставляет простой способ объявить зависимости проекта и вывести их с помощью одной команды. Как современный PHP-разработчик, важно, чтобы у вас было общее представление о том, что такое Composer и как его использовать.

Если вы работаете вместе, в целях обучения добавьте новый файл composer.json в пустой проект и добавьте:

1
2
3
4
5
{
       «require-dev»: {
           «mockery/mockery»: «dev-master»
       }
   }

Этот бит JSON указывает, что для разработки вашему приложению требуется библиотека Mockery. Из командной строки composer install --dev .

1
2
3
4
5
6
7
8
$ composer install —dev
Loading composer repositories with package information
Installing dependencies (including require-dev)
  — Installing mockery/mockery (dev-master 5a71299)
    Cloning 5a712994e1e3ee604b0d355d1af342172c6f475f
 
Writing lock file
Generating autoload files

В качестве дополнительного бонуса, Composer поставляется с собственным автозагрузчиком бесплатно! Либо задайте карту классов каталогов и composer dump-autoload компоновщика, либо следуйте стандарту PSR-0 и настройте структуру каталогов так, чтобы она соответствовала. Обратитесь к Nettuts +, чтобы узнать больше. Если вам по-прежнему вручную требуется бесчисленное количество файлов в каждом файле PHP, возможно, вы просто делаете это неправильно.


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

Если следовать принципу единой ответственности — который диктует, что каждый класс должен отвечать только за одну вещь — то вполне логично, что мы должны разделить эту логику на два класса: один для генерации необходимого контента, а другой для физической записи данных в файл. Класс Generator и File , соответственно, должны сделать свое дело.

Совет: почему бы не использовать file_put_contents непосредственно из класса Generator ? Ну, спросите себя: « Как я мог проверить это? » Существуют методы, такие как «исправление обезьян», которые могут позволить вам перегружать такие вещи, но лучше всего вместо этого обернуть такую ​​функциональность, так что что это может быть легко издеваться с инструментами, такими как издевательство!

Вот базовая структура (со здоровой дозой псевдокода) для нашего класса Generator .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php // src/Generator.php
 
class Generator {
    protected $file;
 
    public function __construct(File $file)
    {
        $this->file = $file;
    }
 
    protected function getContent()
    {
        // simplified for demo
        return ‘foo bar’;
    }
 
    public function fire()
    {
        $content = $this->getContent();
 
        $this->file->put(‘foo.txt’, $content);
    }
}

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

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

1
2
3
4
5
public function __construct()
{
    // anti-pattern
    $this->file = new File;
}

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

Инъекция сеттера более или менее идентична инъекции конструктора. Принцип точно такой же; единственное отличие состоит в том, что вместо введения зависимостей класса через метод конструктора, вместо этого они делают это через метод setter, например:

1
2
3
4
public function setFile(File $file)
{
    $this->file = $file;
}

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

1
2
3
4
5
6
class Generator {
    public function __construct(File $file = null)
    {
        $this->file = $file ?: new File;
    }
}

Теперь, если экземпляр File передается конструктору, этот объект будет использоваться в классе. С другой стороны, если ничего не будет передано, Generator обратится к созданию экземпляра соответствующего класса вручную. Это допускает такие вариации как:

1
2
3
4
5
6
7
8
# Class instantiates File
new Generator;
 
# Inject File
new Generator(new File);
 
# Inject a mock of File for testing
new Generator($mockedFile);

Продолжая, для целей этого урока класс File будет не более чем простой оболочкой PHP-функции file_put_contents .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
<?php // src/File.php
 
class File
{
    /**
     * Write data to a given file
     *
     * @param string $path
     * @param string $content
     * @return mixed
     */
    public function put($path, $content)
    {
        return file_put_contents($path, $content);
    }
}

Скорее просто, а? Давайте напишем тест, чтобы увидеть, во-первых, в чем проблема.

01
02
03
04
05
06
07
08
09
10
11
<?php // tests/GeneratorTest.php
 
class GeneratorTest extends PHPUnit_Framework_TestCase {
    public function testItWorks()
    {
        $file = new File;
        $generator = new Generator($file);
 
        $generator->fire();
    }
}

Обратите внимание, что в этих примерах предполагается, что необходимые классы автоматически загружаются с помощью Composer. Ваш файл composer.json может принимать объект autoload , где вы можете указать, какие каталоги или классы загружать автоматически. Нет больше грязных require заявления!

Если работать вместе, запуск phpunit вернет:

1
OK (1 test, 0 assertions)

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

Хотя тесты пройдены, они неправильно касаются файловой системы.

Все еще не убежден? Если снижение скорости тестирования не повлияет на вас, подумайте о здравом смысле. Подумайте об этом: мы тестируем класс Generator ; почему мы заинтересованы в выполнении кода из класса File ? У него должны быть свои тесты! Какого черта мы удвоимся?


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

1
2
3
4
5
6
7
public function testNativeMocks()
{
    $mock = $this->getMock(‘SomeClass’);
    $mock->expects($this->once())
         ->method(‘getName’)
         ->will($this->returnValue(‘John Doe’));
}

Пока он выполняет свою работу — утверждая, что метод getName вызывается один раз и возвращает John Doe — реализация PHPUnit сбивает с толку и многословна. С помощью насмешек мы можем значительно улучшить читаемость.

1
2
3
4
5
6
7
public function testMockery()
{
    $mock = Mockery::mock(‘SomeClass’);
    $mock->shouldReceive(‘getName’)
         ->once()
         ->andReturn(‘John Doe’);
}

Обратите внимание, что последний пример лучше читает (и говорит).

Продолжая пример из предыдущего раздела « Дилемма », на этот раз в классе GeneratorTest вместо этого давайте смоделируем — или смоделируем поведение — класса File с помощью Mockery. Вот обновленный код:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
<?php
 
class GeneratorTest extends PHPUnit_Framework_TestCase {
    public function tearDown()
    {
        Mockery::close();
    }
 
    public function testItWorks()
    {
        $mockedFile = Mockery::mock(‘File’);
 
        $mockedFile->shouldReceive(‘put’)
                   ->with(‘foo.txt’, ‘foo bar’)
                   ->once();
 
        $generator = new Generator($mockedFile);
        $generator->fire();
    }
}

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

Класс может быть смоделирован с использованием читаемого метода Mockery::mock() . Далее вам, как правило, нужно указать, какие методы для этого фиктивного объекта вы ожидаете вызывать, а также любые применимые аргументы. Это может быть достигнуто с помощью shouldReceive(METHOD) и with(ARG) .

В этом случае, когда мы вызываем $generate->fire() , мы утверждаем, что он должен вызвать метод put File экземпляра File и отправить ему path, foo.txt и data, foo bar .

1
2
3
4
5
6
7
8
// libraries/Generator.php
 
public function fire()
{
    $content = $this->getContent();
 
    $this->file->put(‘foo.txt’, $content);
}

Поскольку мы используем внедрение зависимостей, теперь проще всего внедрить макетированный объект File .

1
$generator = new Generator($mockedFile);

Если мы снова запустим тесты, они все равно вернутся зелеными, однако, класс File — и, следовательно, файловая система — никогда не будет затронут! Опять же, нет необходимости трогать File . У него должны быть свои тесты! Издеваться над победой!

Поддельные объекты не всегда должны ссылаться на класс. Если вам требуется только простой объект, возможно, для пользователя, вы можете передать массив в метод mock — где для каждого элемента ключ и значение соответствуют имени метода и возвращаемому значению соответственно.

1
2
3
4
5
public function testSimpleMocks()
{
    $user = Mockery::mock([‘getFullName’ => ‘Jeffrey Way’]);
    $user->getFullName();
}

Безусловно, будут времена, когда метод с поддельным классом должен возвращать значение. Продолжая наш пример «Генератор / Файл», что, если нам нужно убедиться, что, если файл уже существует, он не должен быть перезаписан? Как мы можем достичь этого?

Ключ заключается в том, чтобы использовать метод andReturn() для вашего смоделированного объекта для имитации различных состояний . Вот обновленный пример:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
public function testDoesNotOverwriteFile()
{
    $mockedFile = Mockery::mock(‘File’);
 
    $mockedFile->shouldReceive(‘exists’)
               ->once()
               ->andReturn(true);
 
    $mockedFile->shouldReceive(‘put’)
               ->never();
 
    $generator = new Generator($mockedFile);
    $generator->fire();
}

Этот обновленный код теперь утверждает, что exists метод должен быть запущен в классе фиктивного File , и для целей этого теста он должен возвращать значение true , сигнализируя о том, что файл уже существует и его не следует перезаписывать. Далее мы гарантируем, что в таких ситуациях метод put File класса File никогда не запускается. С Mockery это легко, благодаря ожиданию never() .

1
2
$mockedFile->shouldReceive(‘put’)
          ->never();

Если мы снова запустим тесты, будет возвращена ошибка:

1
2
Method exists() from File should be called
exactly 1 times but called 0 times.

Ага; поэтому тест ожидал, что $this->file->exists() должен быть вызван, но этого не произошло. Таким образом, это не удалось. Давайте это исправим!

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php
 
class Generator {
    protected $file;
 
    public function __construct(File $file)
    {
        $this->file = $file;
    }
 
    protected function getContent()
    {
        // simplified for demo
        return ‘foo bar’;
    }
 
    public function fire()
    {
        $content = $this->getContent();
        $file = ‘foo.txt’;
 
        if (! $this->file->exists($file))
        {
            $this->file->put($file, $content);
        }
    }
}

Это все, что нужно сделать! Мы не только следовали циклу TDD (разработка через тестирование), но и тесты вернулись к зеленому!

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


Давайте углубимся в объявления ожиданий Mockery. Вы уже знакомы с следует shouldReceive . Будьте осторожны с этим, хотя; его имя немного вводит в заблуждение. Если оставить его отдельно, он не требует запуска метода; значение по умолчанию равно нулю или более раз ( zeroOrMoreTimes() ). Чтобы утверждать, что требуется, чтобы метод вызывался один раз или, возможно, несколько раз, доступно несколько вариантов:

1
2
3
$mock->shouldReceive(‘method’)->once();
$mock->shouldReceive(‘method’)->times(1);
$mock->shouldReceive(‘method’)->atLeast()->times(1);

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

Вот несколько примеров.

1
2
3
$mock->shouldReceive(‘get’)->withAnyArgs()->once();
$mock->shouldReceive(‘get’)->with(‘foo.txt’)->once();
$mock->shouldReceive(‘put’)->with(‘foo.txt’, ‘foo bar’)->once();

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

1
$mock->shouldReceive(‘get’)->with(Mockery::type(‘string’))->once();

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

1
2
3
$mockedFile->shouldReceive(‘put’)
          ->with(‘/\.txt$/’, Mockery::any())
          ->once();

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

1
2
3
$mockedFile->shouldReceive(‘get’)
          ->with(Mockery::anyOf(‘log.txt’, ‘cache.txt’))
          ->once();

С этим кодом ожидание будет применяться только в том случае, если первый аргумент метода get — это log.txt или cache.txt . В противном случае исключение Mockery будет выдано при запуске тестов.

1
Mockery\Exception\NoMatchingExpectationException: No matching handler found…

Совет: не забывайте, вы всегда можете использовать псевдоним Mockery как m в верхней части вашего класса, чтобы сделать его более лаконичным: use Mockery as m; , Это позволяет более кратко, m::mock() .

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

1
2
3
$mock->shouldReceive(‘method’)
    ->once()
    ->andReturn(false);

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
<?php
 
class MyClass {
    public function getOption($option)
    {
        return config($option);
    }
 
    public function fire()
    {
        $timeout = $this->getOption(‘timeout’);
        // do something with $timeout
    }
}

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

1
2
3
4
5
6
7
8
9
public function testPartialMockExample()
{
    $mock = Mockery::mock(‘MyClass[getOption]’);
    $mock->shouldReceive(‘getOption’)
         ->once()
         ->andReturn(10000);
 
    $mock->fire();
}

Обратите внимание, как мы поместили метод для насмешки в скобках. Если у вас есть несколько методов, просто разделите их запятой, например:

1
$mock = Mockery::mock(‘MyClass[method1, method2]’);

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

Альтернативный вариант — использовать пассивные частичные имитации, которые можно рассматривать как установку состояния по умолчанию для фиктивного объекта: все методы относятся к основному родительскому классу, если не указано ожидание.

Предыдущий фрагмент кода может быть переписан как:

1
2
3
4
5
6
7
8
9
public function testPassiveMockExample()
{
    $mock = Mockery::mock(‘MyClass’)->makePartial();
    $mock->shouldReceive(‘getOption’)
         ->once()
         ->andReturn(10000);
 
    $mock->fire();
}

В этом примере все методы в MyClass будут вести себя как обычно, за исключением getOption , который будет проверен и вернет 10000`.


Библиотека Hamcrest предоставляет дополнительный набор сопоставителей для определения ожиданий.

После ознакомления с API Mockery рекомендуется также использовать библиотеку Hamcrest, которая предоставляет дополнительный набор сопоставителей для определения читаемых ожиданий. Как и Mockery, он может быть установлен через Composer.

1
2
3
4
«require-dev»: {
    «mockery/mockery»: «dev-master»,
    «davedevelopment/hamcrest-php»: «dev-master»
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
 
class HamCrestTest extends PHPUnit_Framework_TestCase {
    public function testHamcrestMatchers()
    {
        $name = ‘Jeffrey’;
        $age = 28;
        $hobbies = [‘coding’, ‘guitar’, ‘chess’];
 
        assertThat($name, is(‘Jeffrey’));
        assertThat($name, is(not(‘Joe’)));
 
        assertThat($age, is(greaterThan(20)));
        assertThat($age, greaterThan(20));
 
        assertThat($age, is(integerValue()));
 
        assertThat(new Foo, is(anInstanceOf(‘Foo’)));
 
        assertThat($hobbies, is(arrayValue()));
        assertThat($hobbies, arrayValue());
 
        assertThat($hobbies, hasKey(‘coding’));
    }
}

Обратите внимание, как Hamcrest позволяет вам записывать свои утверждения в удобочитаемой или краткой форме, как вы хотите. Использование функции is() является не чем иным, как синтаксическим сахаром для улучшения читабельности.

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

1
2
3
$mock->shouldReceive(‘method’)
    ->with(Mockery::type(‘string’))
    ->once();

Если используется Hamcrest, Mockery::type можно заменить на stringValue() , например, так:

1
2
3
$mock->shouldReceive(‘method’)
    ->with(stringValue())
    ->once();

Hamcrest следует соглашению об именовании значений ресурса для соответствия типу значения.

  • nullValue
  • integerValue
  • arrayValue
  • промыть и повторить

Альтернативно, чтобы соответствовать любому аргументу, Mockery::any() может стать anything() .

1
2
3
$file->shouldReceive(‘put’)
    ->with(‘foo.txt’, anything())
    ->once();

По иронии судьбы, самым большим препятствием для использования Mockery является не сам API.

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

Ключ должен изучить и уважать принцип единственной ответственности в вашем рабочем процессе кодирования. Созданный Бобом Мартином, SRP предписывает, чтобы класс « имел одну и только одну причину для изменения». Другими словами, класс не должен обновляться в ответ на многочисленные несвязанные изменения в вашем приложении, такие как как изменение бизнес-логики, или как форматируется вывод, или как данные могут сохраняться. В простейшей форме, как и метод, класс должен делать одну вещь.

Класс File управляет взаимодействиями файловой системы. Репозиторий MysqlDb сохраняет данные. Класс Email готовит и отправляет электронные письма. Обратите внимание, как ни в одном из этих примеров не использовалось слово « а» .

Как только это будет понято, тестирование станет значительно проще. Внедрение зависимостей должно использоваться для всех операций, которые не попадают под зонтик класса. При тестировании сосредоточьтесь на одном классе за раз и проверяйте все его зависимости. Вы все равно не заинтересованы в их тестировании; у них свои тесты!

Хотя ничто не мешает вам использовать встроенную насмешливую реализацию PHPUnit, зачем беспокоиться, когда улучшенная читаемость Mockery доступна только для composer update ?