Далем тестирование проще вместе с Mockery

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

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

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

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

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

Особенно по мере развития процесса разработки — в том числе когда вы начинаете применять принцип единственной ответственности и использовать внедрение зависимостей — знакомство с mock-объектами быстро станет необходимым.

Mocks против Stubs: скорее всего вы все чаще будете встречать такие термины как mock и stub, часто причем они друг друга будут заменять. В действительности оба они используются для разных целей. Первый относится больше к процессу определения ожиданий и проверке требуемого поведения. Другими словами, mock-объект потенциально может привести к падению теста. С другой стороны stub объект — это просто набор данных в виде заглушки, который можно просто передать, чтобы удовлетворить определенные требования.

Тестовая библиотека де-факто для PHP — PHPUnit поставляется со своим собственным API для создания mock-объектов, которое однако может оказаться весьма обременительным в использовании. Как вы наверняка успели убедиться, что чем сложнее будет тестировании, тем вероятнее всего, что разработчик его просто не будет делать.

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

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

Как и множество других современных инструментов PHP, Mockery можно установить через Composer.

Как и большинство инструментов PHP в наши дни рекомендуется устанавливать библиотеку Mockery через Composer.

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

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

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

И в качестве дополнительного бонуса Composer поставляется со своим собственным автозагрузчиком. Либо укажите список директорий в classmap и выполните composer dump-autoload или просто следуйте стандарту PSR-0 и просто следите за соблюдением структуры папок. Перейдите сюда, чтобы узнать побольше. Если вы до сих пор вручную подключается бесконечное количество файлов в каждом вашем PHP файле, то вероятнее всего вы что-то не так делаете.

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

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

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

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

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

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

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

Инъекция через сеттеры более-менее идентична инъекции через конструктор. Принцип совершенно одинаковый, единственная разница только в том, что зависимость встраивается не через метод конструктора, а через сеттер:

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

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

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

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

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

Запускаем теперь phpunit, который вернет следующее:

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

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

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

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

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

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

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

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

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

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

Так как мы используем инъекцию зависимости, то теперь мы используем заглушку вместо реального объекта File.

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

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

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

Решением этого будет использование метода andReturn у замоканного объекта для имитации различных состояний. Вот обновленный пример:

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

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

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

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

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

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

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

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

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

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

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

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

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

И наконец у нас есть множество способов указать, что замоканный метод должен возвращать. Например, нам нужно, чтобы он возвращал булево значение. Запросто:

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

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

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

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

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

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

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

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

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

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

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

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

Используя же Hamcrest, конструкцию Mockery::type можно заменить на stringValue() следующим образом:

Hamcrest позволяет использовать resourceValue-соглашение для сравнения типа значения.

  • nullValue
  • integerValue
  • arrayValue
  • rinse и repeat

Альтернативой для Mockery::any() при проверке на любой аргумент может стать anything().

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

А понимание того, зачем и когда использовать моки в ваших тестах.

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

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

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

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