Unit-тестирование в PHP. Часть 1: теория
версия для печатиДанная статья (все ее части) - это лекция о модульном тестировании, которую мне нужно было провести среди PHP-разрабочиков компании, на которую я сейчас работаю.
Я не гуру тестирования и статья не претендует на полное руководство по модульным тестам или использованию PHPUnit. По первому вопросу целые книги пишут, да и у PHPUnit есть нормальный мануал. Цель статьи - объяснить на пальцах, как создавать unit-тесты, помочь разобраться с нуля в этой теме. Я подразумеваю ваше дальшнейшее самообразование. используя эту статью, как отправную точку.
Содержание
Статья получилась огромная, потому разделена на 3 части. Для удобства содержание продублированно в каждой части, переходы на текущей странице выделены жирным шрифтом.
- Введение
- Теория
- Практика
- PHPUnit
Введение
Определение из Википедии:
Модульное тестирование, или юнит-тестирование (англ. unit testing) — процесс в программировании, позволяющий проверить на корректность отдельные модули исходного кода программы.
Идея состоит в том, чтобы писать тесты для каждой нетривиальной функции или метода. Это позволяет достаточно быстро проверить, не привело ли очередное изменение кода к регрессии, то есть к появлению ошибок в уже проверенных местах программы, а также облегчает обнаружение и устранение таких ошибок.
Источник, где можно полноценно почитать о unit-тестировании - книга "The Art of Unit Testing with examples on C# - Roy Osherove (2014)". С недавних пор она есть и на русском, ищите "Искусство автономного тестирования с примерами на С# - Рой Ошероув (2014)". Несмотря на C#, примеры прекрасно воспринимаются и для иностранцев С-шного кода :) Я буду приводить некоторые цитаты из этой книги по ходу статьи.
Модульное тестирование - это не наука, это искусство (перефразированно из той самой книги).
Мое личное мнение: говоря о тестах в PHP, мы рассматриваем вопрос в упрощенном виде, потому что код на PHP имеет невысокую сложность. Да, да, даже если в задаче 100500 строк кода, все равно любая программа на PHP - простая, и в общем случае сводится к сборке страницы для браузера. Хотите понять разницу? Загляните хотя бы в исходники PHP - это уже на порядок сложнее любого php-скрипта.
Теория
Идеология модульного тестирования
Тестирование в PHP проще, но принципы те же самые.
Хороший автономный тест должен обладать следующими свойствами (цитата из книги Р. Ошероув):
- он должен быть автоматизированным и повторяемым;
- eго должно быть просто реализовать;
- он должен сохранять актуальность и завтра;
- любой должен иметь возможность выполнить его одним нажатием кнопки;
- он должен работать быстро;
- его результаты должны быть стабильны (тест всегда должен возвращать один и тот же результат, если между двумя последовательными запусками ничего не менялось);
- он способен легко управлять всеми аспектами поведения автономной единицы, моделируя нужное (полный контроль над тестируемым объектом);
- он должен быть полностью изолирован (работать независимо от других тестов);
- если тест завершается неудачно, то должно быть легко понять, каков ожидаемый результат и в каком месте искать ошибку.
Мое мнение: unit-тест не должен ограничиваться проверкой одного ожидаемого результата метода. Он должен проверять вообще все результаты! Если рабочий метод может вернуть принципиально разный результат в зависимости от полученных параметров или внешних состояний, значит в тесте все их нужно описать и проверить, что будет. Если рабочий метод может пробросить исключение - это тоже желательно проверить, причем оба случая - исключение было и его нет. Это и есть полностью покрытый тестом метод, т.е. можно утверждать, что он работает, как задумано.
Тестирование метода можно проводить по трем направлениям (в порядке уменьшения предпочтения):
- значение: если проверяемый метод что-нибудь возвращает, то относительно его результата строятся утверждения тест-метода.
- состояние: нет возвращаемого значения, но можно проверить ожидаемое изменение некоего внешнего состояния после отработки целевого метода.
- взаимодействие: в крайнем случае проверить взаимодействие метода с его зависимостями
Модульное тестирование не отменяет ручные проверки задачи в целом и работу бетта-тестеров, потому что оно не позволяет отловить все ошибки программы. Это следует из практической невозможности трассировки всех возможных путей выполнения программы в целом, за исключением простейших случаев.
Выгоды и недостатки unit-тестов
Существование unit-тестов на проекте имеет ряд достоинств, которые сложно переоценить:
- при разработке большой задачи не приходится сочинять тестовое окружение с breackpoint-ми, var_dump(), исскуственными редиректами, заглушками и т.д. Весь этот огород удобно помещается в unit-тесты, причем отлаживать рабочие методы можно прямо из тестов.
- модульные тесты можно рассматривать как "живой документ" для тестируемого класса. Разработчики, которые не знают, как использовать данный класс, могут использовать unit-тест в качестве примера.
- задачу сделали, проверили, ушла в production. Дальнейшие изменения функционала можно контролировать уже имеющимеся тестами и не допускать регрессии готового кода. Если конечно тесты поддерживаются в актуальном состоянии и про них знают и не забывают их запускать. Последнее - это и моя проблема, я забываю запускать тесты :)
- unit-тесты приучают использовать декомпозицию задачи. Т.е. писать маленькие методы, играющие свою единственную роль, а не пихать всю логику в один огромный метод, который потом сложно тестировать автоматически.
- отделение интерфейса от реализации. Это уже на уровне классов. Например, класс пользуется базой данных; в ходе написания теста программист обнаруживает, что тесту приходится взаимодействовать с базой. Это ошибка, поскольку тест не должен выходить за границу класса. В результате разработчик абстрагируется от соединения с базой данных и реализует этот интерфейс, используя свой собственный mock-объект. Это приводит к менее связанному коду, минимизируя зависимости в системе.
- упоминая автомат - когда тест готов, это просто Праздник, если спустя неделю не требуется вспоминать, как бы вручную проверить конкретный метод. Автоматические проверки избавляют программиста от монотонной скучной работы.
Ложка дегтя:
- На написание теста нужно тратить отдельное время. Иногда - значительное. Например, чтобы вспомнить, как работать с определенными дополнениями тестового фреймфорка или найти по ним вразумительный мануал.
- Тесты нужно поддерживать в актуальном состоянии, иначе они бесполезны. Поддержка - это еще дополнительное время.
- Если тест был с ошибкой или неполностью покрыл метод - в результате получится баг там, где его не ожидали. Подобные баги это здорово подпортят настроение, как минимум. Поэтому требует основательно подходить к разработке теста, продумать все возможные случаи использования рабочего метода.
Что нужно тестировать
Как правило, бизнес-логику. Т.е. методы, выполняющие реальную работу, результат которых можно проверить. Сказано очень размыто, согласен, но так оно и есть :) Абстрактный пример: шаблон проектирования MVC. В нем стоит тестировать слой модели, несущей всю бизнес-логику, т.к. контроллер только управляет, а полученное представление лучше проверять приемочными тестами (к теме не относится).
Eсли какой-то рабочий метод вызывает некоторые другие, то лучше написать тесты на них, а этот метод оставить без тестирования, или проверить его после всех подчиненных. Это объясняется практическим смыслом: если возникнет ошибка, ее проще найти именно там, где она произошла, а не там, к чему она привела. Хотя эта мысль спорная. Например, Рой Ошероув в своей книге предлагает следующее:
Возможно, вам кажется, что размер тестируемой автономной единицы следует сводить к минимуму. Мне тоже так казалось. Но больше не кажется. Я полагаю, что если удается создать более крупную единицу работы, конечный результат которой более явственно виден пользователю API, то и тесты окажутся более пригодными для сопровождения. Стараясь минимизировать размер единицы работы, вы в конце концов дойдете до того, что будете тестировать не конечные результаты, видимые пользователю открытого API, а промежуточные остановки поезда на пути к конечному пункту назначения.
Мое предложение такое: напишите тест для вашего открытого конечного метода. Если получаемые результаты по вашему мнению слишком размытые, или описано слишком много тестируемых ситуаций, тогда попробуйте создать перед этим методом тесты для его частей (разумеется, их нужно будет выделить в отдельные методы). Т.е. двигайтесь от большего к меньшему, и возможно тогда найдете золотую середину.
Когда именно писать тест
Я пробовал по-разному: сначала полностью сделать задачу, потом покрывать тестами рабочие методы - это занимает больше времени, чем в других подходах, потому что через пару дней уже сложнее понять, для чего метод и как должен работать. Т.е. перед написанием теста нужно заново разобраться с методом, который является лишь частью задачи. Плюс такого подхода: все рабочие методы скорее всего уже в своем конечном состоянии, т.к. были проверены вручную, следовательно тесты переписывать не придется.
Другой вариант: писать тест сразу после написания рабочего метода. Выгода очевидна: точно знаешь, что ожидаешь от метода, ты ж его только что написал. Еще плюс: вместо того, чтобы вставлять прерывания в приложении, какие-то костылки для проверки метода вручную, сразу же проверяешь его написанным тестом, причем все случаи поведения сразу. Тут же вылавливаются простые ошибки, типа опечаток, неправильного вызова функций и т.д. Недостаток же такого подхода в том, что в ходе разработки метод может поменяться или вообще исчезнуть, следовательно тратится лишнее время на создание и изменение теста.
Вариант "по учебнику": TDD - test driven development - сначала пишем тест, только потом сам проверяемый метод. Все, разрыв шаблона, занавес… :D Т.е. изначально нужно, не написав ни байта кода рабочего метода, продумать, как он будет работать, что будет возвращать. Потом под его ожидаемый результат пишем полноценный тест. Тест естественно сразу не проходит, потому что метода вообще нет. Потом пишем метод и добиваем его, пока тест не пойдет. Сам не пробовал, не могу пока настолько изменить свое мировоззрение :) Замечу, что в книге есть пара пунктов (1.6 и 1.7) посвещенных этой методике. Там TDD изложено в подробностях.
Мне нравится второй вариант, тестировать методы сразу после их создания. Чтобы сократить время на переписывание, подход должен быть шире: в первую очередь нужно составить детальное техническое задание. Да, да, именно задание. Пример из личного опыта: разрабатывая сборщик статики я сначала потратил несколько часов на описание мануала к будущему инструменту и вторым документом вел заметки, что и как должно работать. В итоге ни один тест не был удален, только переезжали они из класса в класс и немного модифицировались. Бетту задуманной фичи я получил примерно через 40 часов. Без тех.задания и тестирования, я предполагаю, ушло бы в 2-3 раза больше времени.
Подмена зависимостей (fakes). Разница между mock и stub
Как только вы станете писать тесты чуть сложнее "Hello, world" у вас появится проблема: тестируемый метод не получается изолировать, он взаимодействует с другими классами и подсистемами. Например, для выполнения своей задачи метод дополнительно лезет в базу или на диск за файлами. Т.о. ваш тест перестает полностью контролировать проверяемый метод, результат может измениться в зависимости от внешних факторов.
Поэтому как-то нужно заменить реальные внешние системы (классы), на свои заглушки, обеспечивающие контролируемое поведение метода. Такая заглушка будет всегда выдавать один и тот же результат проверяемому методу, и значит он в свою очередь так же должен выдавать ожидаемое значение. В англоязычном программирования такая заглушка называется stub.
Похожее понятие - mock. Дословно переводится "пародия, подражание, подставка". Это тоже вид заглушки. Мне было трудно понять разницу, пока книгу не почитал. Попробую объяснить на примере: тестируем какой-то метод; организуем подмену систем, от которых он зависит - получаем stub-ы. В тесте описываем утверждения относительно проверяемого метода, обычное дело. Однако, мы можем так же описать утверждения относительно подменных классов. Например, ожидаем, что некий метод из заглушки будет вызван дважды с конкретными параметрами. И вот тогда такая заглушка (stub) становится mock-объектом.
Вместе stub-ы и mock-и называют fakes (подделки). Это слово используют для удобства, чтобы не перечислять подряд оба понятия или когда еще неизвестно, чем именно окажется подменяемый объект.
Как именно подменять зависимости
Серьезный вопрос: как на практике переключить боевой метод на использование подделки? Для этого есть несколько способов по созданию "швов" (seams) в приложении. Швы - это определенные места в коде для возможности подмены зависимых классов. Что можно придумать для создания шва: добавление открытого свойства, допускающего установку, внедрение зависимости в класс/метод ("чистые функции"), фабрика, проксирование вызовов любых методов.
Небольшое отступление: PHP - слаботипизированный язык. На практике, для улучшения кода используется явное указание типа ожидаемого объекта. И вот тут потребуется завести интерфейсы на все зависимые классы, которые будем подменять. Хотя я бы рекомендовал не интерфейсфы, а абстрактные классы, как более узкое понятие. В нужных местах указываем не конечный класс, а реализуемый интерфейс. При разрешении зависимостей PHP cвободно переходит он интерфейса к реализации, код не падает.
Теперь о том, как подменять зависимости.
С публичным свойством, я думаю, понятно: задали его из-вне, в боевом методе через такое свойство обращаемся к зависимой системе.
Внедрение зависимости в класс: в параметры конструктора пишем интерфейсы классов, от которых зависит проверяемый метод. Сохраняем полученные параметры в приватных свойствах класса, используем эти свойства в методе. По сути - это первый вариант, только без торчащих наружу лишних публичных свойств класса. Аналогично делается внедрение зависимости в сам проверяемый метод, только уже ненужны приватные свойства класса.
class SomeClass
{
/**
* подключение к БД
* @var IDatabase $db
*/
private $db;
/**
* Внедрение зависимости в класс
* @param IDatabase $db подключение к БД
*/
public function __construct(IDatabase $db)
{
$this->$db = $db;
}
/**
* Какой-то боевой метод
*
* Внедрение еще одной зависимости, прямо в метод
*
* @param ILogger $logger объект логера
* @return int
*/
public function doIt(ILogger $logger):int
{
// тут только чистый код, без инициализации зависимых систем
$id = $this->db->query('INSERT ...');
$logger->add('Создана новая запись #' . $id);
return $id;
}
}
У внедрения зависимостей в метод/класс есть недостаток: если их слишком много, получим кучу параметров конструктора (или метода), следовательно менее читабельный код и неудобство сопровождения. Цитата из книги:
Для решения проблемы можно было бы создать специальный класс, содержащий все необходимое для инициализации данного класса и передавать конструктору только один параметр: объект спец.класса. Этот подход иногда называют рефакторингом параметрического объекта. Правда, если у такого объекта окажется несколько десятков свойств, то целесообразность решения будет сомнительна, но тем не менее так поступить можно.
Другое решение - воспользоваться контейнерами инверсии управления (inversion of control – IoC). IoC-контейнер можно рассматривать как «умную фабрику» объектов (хотя это далеко не все, на что они способны). Такие контейнеры предоставляют специальные фабричные методы, которые принимают тип создаваемого объекта и все необходимые ему зависимости, после чего инициализируют объект, применяя специальные настраиваемые правила, например: какой конструктор вызывать, какие свойства устанавливать и в каком порядке и т.д. Они умеют работать со сложными иерархиями объектов, когда для создания одного объекта требуется создать и инициализировать несколько других, лежащих ниже него в иерархии. Как правило, применение контейнеров
упрощает порождение нужных объектов и причиняет меньше хлопот в части зависимостей и сопровождения конструкторов.
Из известных мне, на PHP есть компонент Symfony2 Dependency Injection, который можно прикрутить в сторонний проект, но придется выкручиваться. В Laravel своя реализация - сервис-контейнер.
Внедрение зависимости еще связывают с понятием чистой функции: вся необходимая подготовка делается за ее пределами, а функция выполняет только то, для чего на предназначена. Выгоду проще понять, если представить, что зависимый класс сам имеет сложную инициализацию (свои зависимости). Тогда его подготовка за пределами боевого метода делает последний более читабельным, как минимум.
Просто фабрика: отдельный класс или метод, который по запросу будет возвращать экземпляр зависимого класса. Что именно возвращать - определяется, например, конфигурацией или константой (хотя в фабрике можно описать и более сложные условия). Боевой метод для разрешения своей зависимости обращается к фабрике. В нормальном режиме фабрика отдает объект реального класса, в тестовом - заглушку.
Это решение более узкое, чем использование IoC-контейнера, пишем фабрику под конкретный тестируемый класс. Более того, вызываем ее в коде метода, а не передаем объекты в параметрах. Так же можно вызвать фабрику в коде конструктора проверяемого класса, суть не меняется.
/**
* Фабрика для получения объекта логера
*/
class LoggerFactory
{
public static function get()
{
if (Env::isProduction()) {
$db = new DB('db_master');
return new DBLogger($db);
}
if (Env::isTest()) {
return new MockLogger;
}
return new FileLogger(LOG_PATH . date('Ymd'));
}
}
class SomeClass
{
/**
* Какой-то боевой метод
*/
public function doIt()
{
// тут только чистый код, без инициализации зависимых систем
$id = $this->db->query('INSERT ...');
LoggerFactory::get()->add('Создана новая запись #' . $id);
}
}
О проксировании вызовов расскажу в разделе про AspeckMock, это нечто уникальное в PHP.
Описанные подходы можно комбинировать в зависимости от ситуации. Основная трудность заключается не в выборе конкретного способа создания шва, а в переписывании уже готового кода. Может даже придется основательно пересмотреть текущую архитектуру кода, а тестов на него еще нет, поломаешь и не заметишь. Выход: пишите код сразу с оглядкой на возможно будущее его тестирование, даже если тесты не планируются.
Ну а если код уже написан, то в книге описан еще один способ: виртуальный метод класса (техника "выделить и переопределить" ). В PHP такая реализация будет заковыристой, опишу коротко: прямо в проверяемом классе заводим отдельные методы, возвращающие зависимости. В боевом методе получаем зависимости через них. Далее, специально для тестирования создаем наследника от проверяемого класса, переопределяем в нем методы зависимостей.
В чем проблемы: методы, разрешающие зависимости, должны иметь видимость protected, хотя без учета тестов достаточно private; способ хорош при моделировании входных данных для тестируемого кода, но оказывается громоздким, если требуется проверить, как зависимость реагирует на данные, исходящие из тестируемого кода.
Явная выгода такого решения - наименьшие изменения боевого класса в уже имеющейся системе, минимальные риски что-то поломать.
[1oo%, EoF]Остальные части:
Часть 2: знакомство с PHPUnit
Часть 3: углубление в PHPUnit
Понравилась статья? Расскажите о ней друзьям: