Мобильная тема для Yii2 на примере Test First
На вебинаре о тестировании мы не уделили внимания практическому применению парадигмы Test Driven Development (TDD) и Test First в реальных проектах и написанию моков в модульных тестах. Попробуем решить сегодняшнюю задачу по практике написания тестов до кода и потренируемся в составлении модульных и функциональных тестов.
Рассмотрим классическое требование переключения внешнего вида сайта на мобильную тему и обратно в зависимости от используемого посетителем устройства. Адаптивная (responsive) вёрстка избавляет от разработки отдельного шаблона, но спасает не всегда, так как в сложном макете с насыщенной структурой не всё можно перекомпоновать при помощи CSS.
Те же широкие таблицы не ужимаются по ширине и всегда вылезают за экран. Их нужно либо оставить как есть, либо задать overflow: auto для окружающего таблицу блока ()чтобы появилась прокрутка по горизонтали), либо проявить изобретательность. Более гибко выглядит внедрение для таких страниц облегчённых мобильных представлений, где вместо широких таблиц будут узкие списки. Да и просто если верстать адаптивно всем лень.
Перед тем, как кидаться программировать, определим первоначальные бизнес-требования:
На сайте имеется десктопная и мобильная тема. По умолчанию тема выбирается на основе устройства пользователя. В подвале сайта есть ссылка для ручного переключения на альтернативный вариант. В этом случае выбранное значение сохраняется в браузере. Важно не указывать режим отображения в адресе страницы, чтобы никто случайно не скопировал адрес с этим параметром.
Теперь подумаем. Да-да, в TDD принято думать до написания кода, а не после. Привычный многим подход «семь раз отрежь, один отмерь» здесь не подойдёт. За это такую парадигму и любят, что код в итоге получается сразу продуманным и работающим.
Проверка функциональности сайта
Что мы хотим увидеть снаружи? Как будем проверять, что всё работает?
Вручную на эту проверку на компьютере и на мобильнике каждый раз будет уходить по десять минут, а тесты сработают за 300 миллисекунд, Поэтому эффективнее вместо ручных проверок и догадок сформулировать перечисленные выше требования в виде тестов.
Для простоты будем рассматривать проект на примере свежей версии базового приложения на Yii2 с новыми Codeception тестами. Он ещё не релизнулся, так что загрузите обновлённую папку tests из репозитория yii2-app-basic, если захотите повторить. Но, как говорится, он и в Африке Codeception, работающий над PHPUnit, поэтому конкретный фреймворк здесь не особо важен.
и напишем функциональные тесты для десктопного браузера:
И аналогичные напишем для мобильного, поменяв лишь метод useDesktopBrowser на useTabletBrowser и переставив местами тексты ссылок:
Так мы английскими предложениями записали, что бы мы хотели сделать и что при этом хотели бы увидеть.
Конструкций вроде $I->useDesktopBrowser() в составе FunctionalTester не имеется. Мы сейчас придумали их сами. Но можем спокойно добавить собственные действия в объект $I . Для этого откроем файл tests/_support/FunctionalTester и допишем два этих метода:
Мы будем писать ещё и Unit-тесты, поэтому список агентов вынесем в отдельный класс:
Это закономерно, так как ссылки «Mobile version» у нас в шаблоне ещё нет.
Проверка исходного кода
Что должно быть в программном коде внутри?
С технической точки зрения нам нужен компонент, определяющий режим отображения. А именно проверяющий, мобильную тему использовать или нет. Для выбора он ориентируется на UserAgent и на выбранный пользователем вариант в cookies.
Какие можно придумать варианты? Если посетитель зашёл с телефона, то показываем мобильную тему. Если зашёл с десктопа, но выбрал мобильную (или на телефоне ещё и переключился на мобильную), то показываем тоже её.
Сгенерируем шаблон проверяющего класса:
И напишем заготовки модульных тестов для всех перечисленных выше вариаций:
Будьте в правильную сторону ленивым программистом. Больше думайте о задаче глобально, а не об отвлекающем низкоуровневом мусоре. И соблюдайте гигиену:
Помечайте пустые заготовки тестов незавершёнными с помощью метода markTestIncomplete , чтобы это было видно при их запуске. Иначе они будут отображаться пройденными и придётся искать «пустоты» каждый раз вручную.
Заготовки для ViewMode придумали. Что у нас дальше?
Наш ViewMode должен определять режим отображения на основе установленных cookies и данных устройства. Мы помним, что ООП у нас основан на разделении ответсвенностей. Поэтому дабы не захламлять код этого класса лишними обязанностями, переложим ответственности по работе с этими вещами на соответствующих помощников ClientMode и DeviceMode :
Клиент может либо просто зайти на сайт, либо выбрать тему. Либо, как злобный хакер, поменять cookies. Набросаем заготовки нужных тестовых методов:
Устройство может быть обычным мобильным телефоном, смартфоном или компьютером. Будем проверять работу DeviceMode тоже на всех режимах:
Зачем нам такое разделение? Почему бы не вписать весь код в один класс? И в комментариях могут упрекнуть со словами «чувааак, там всего три строчки!»
Разделение одного сложного класса по ответственностям на три простых даёт нам возможность программировать и тестировать их независимо. Впоследствии для перехода от cookies к сессии (или для смены детектора устройства) можно будет переписать любую из запчастей, не боясь сломать окружающий код и чужие тесты.
Поэтому без разницы, пять там будет строчек, пятьдесят или пятьсот. Три обязанности – три объекта. И у разделения есть ещё один бонус:
Замена фреймворкозависимого класса ClientMode на интерфейс ClientModeInterface мгновенно сделает наш компонент совместимым с любым классом вроде YiiCookieClientMode , SymfonyCookieClientMode или LaravelSessionClientMode .
Два из трёх классов можно легко перенести на любой фреймворк. А в случае одного большого класса сделать это проблематично.
Реализация проверок
Реализуем ClientModeTest . Раз мы договорились сохранять режим в cookies, то классу для их извлечения нужно работать с $_COOKIES . Но мы обычно используем фреймворк, который эти данные может как-либо шифровать. Поэтому удобнее использовать предоставляемый фреймворком класс Request .
В случае Yii2 сымитируем yii\web\Request с пустыми cookies, передадим его объекту класса ClientMode и проверим, что в этом случае компонент покажет то, что не выбран ни мобильный, ни десктопный вариант:
Что при этом происходит внутри конструкции createStub ? Как работают моки и стабы? Чем они отличаются?
Стабы – это простые заглушки с замещёнными методами:
Ими удобно просто подменять возвращаемые значения.
Моки – те же заглушки с замещёнными методами, но поумнее. Они ещё умеют следить за тем, сколько раз и как их вызвали.
Если указать в expect() , что метод должен вызываться всего однажды через $this->once() :
то после теста произведётся проверка на число вызовов и вылетит ошибка, что метод getCookies вызвался, например, больше одного раза. А если ещё добавить проверку на аргументы через with() и попробовать вызвать метод с другим параметром:
то сразу увидим ошибку несоответствия реальных аргументов ожидаемым.
Моками удобно делать умных «шпионов», чтобы быть уверенным, что всё внутри вызывается как надо.
В PHPUnit заглушки создаются методами createStub() createMock() . При их выполнении PHPUnit на лету создаёт новый класс, наследующийся от указанного класса или реализующий указанный интерфейс и переопределяющий его методы.
В нашем случае сгенерированный класс наследуется от Request , переопределяет указанный метод getCookies (или get ) и приписывает рядом свой дополнительный код вроде expect и method . После этого из-за наличия наследования никто не догадается, что работает с подменой.
Можем дописать остальной тестовый код и вынести общий в stubRequest() :
Все методы у нас однородны. Можно упростить код за счёт использования провайдера тестовых данных.
Для этого заменим assertTrue и assertFalse на assertEquals , чтобы можно было сравнивать результат с переданными в testMode аргументами:
Помимо самого метода с параметрами мы заготовили массив из значений. После этого аннотацией @dataProvider привязали его к методу testMode . PHPUnit запустит modeProvider() , пройдёт по вернувшемуся из него массиву циклом и запустит наш метод для каждой строки.
Далее реализуем DeviceModeTest . Дабы не велосипедить, будем использовать библиотеку MobileDetect, которая умеет определять мобильники и планшеты, браузеры и операционные системы. Установим её в наше приложение:
Внутри нашего будущего DeviceMode мы могли бы работать с объектом класса MobileDetect напрямую как-нибудь так:
Но такой подход неудобен, так как детектор создаётся внутри через new . При попытке протестировать DeviceMode нам нужно будет для каждого теста возиться с заполнением $_SERVER['HTTP_USER_AGENT'] и других значений для зависимого объекта класса MobileDetect . А нам нужно протестировать только свой класс без возни с детектором.
Вместо первоначального подхода с созданием зависимых объектов через new внутри метода воспользуемся простейшей инъекцией этой зависимости в приватное поле через конструктор:
Здесь мы сами создаём экземпляр $detect и передаём его внутрь $mode . А в тестах просто будем заменять $detect на заглушку:
Аналогично можем воспользоваться провайдером для замены одинаковых методов одним с массивом комбинаций:
Реализуем тест будущего главного класса ViewMode . Этому классу нужно определять режим отображения на основе показаний определителей клиента и устройства.
Можно получать их значения вручную и передавать в конструктор императивным процедурным подходом:
и подменять в тестах:
но при этом всегда будут запускаться проверки в $deviceMode , даже если они нам не нужны. Для отложенного запуска вместо готовых значений удобнее передавать сами вспомогательные объекты внутрь главного:
Пусть он сам вызывает их методы по своему усмотрению внутри своего isMobile() . А в тестах будем подменять датчики на моки:
Для удобства также сплющим с помощью провайдера:
Кода для моков получилось много, но достаточно примитивного. Он генерируется «не напрягаясь» простым тыканьем в подсказки автоподстановки IDE.
Тест на совместимость
Модульными тестами мы проверили каждый свой класс по отдельности, используя заглушки, поэтому можем более-менее доверять своему будущему коду. Для большей уверенности мы могли бы добавить ещё и сложный интеграционный тест, собирающий всё воедино и проверяющий интеграцию компонентов друг с другом:
Этот код сложнее всех предыдущих, так как охватывает абсолютно всё. Его сложнее поддерживать. Но необходимости в таком интеграционном тесте у нас нет, так как достаточно будет для проверки запустить прошлый функциональный.
Но в проекте всё же появилась зависимость, которой безоговорочно доверять мы не можем. Это библиотека, которую мы используем. Мы не сомневаемся в крутости MobileDetect и не будем тестировать её самостоятельно. Но мы хотим быть уверены в интерфейсе, который от неё нужен.
Год назад на одном проекте был случай с библиотекой Apple Apn Push для рассылки push-уведомлений на смартфоны. Компонент пару раз проверили вручную и дальше верили на слово. Тестами были покрыты только модели и внешний API. а сам компонент в тестах был заменён заглушкой, чтобы не рассылать реальные сообщения на смартфоны при каждом запуске тестов.
Но вдруг после 2.1.6 в версии 2.2.0 внезапно поменялась сигнатура конструктора с такой:
и компонент приложения, который это подключение использовал, молча перестал бы работать. А заглушка продолжила бы работу, и никто бы этого сразу не заметил.
В таких случаях спасает только аккуратность просмотра хода обновлений при выполнении composer update . Если видите там изменение мажорной или минорной версии какого-либо пакета, то зайдите и прочитайте его CHANGELOG.
Как разработчик, не заставляйте пользователей ваших компонентов ковыряться в исходниках или смотреть diff файлов документации после каждого обновления. Вместо этого заведите в репозитории файл CHANGELOG.md и перечисляйте в нём, что изменилось в каждой версии.
Обновления нужно учесть. У нас в подобном случае просто отвалится переключение по ссылке и это будет заметно. Но если что-то нельзя будет отловить внешними функциональными тестами, то либо напишите интеграционный, либо сделайте минимальную проверку на работоспособность библиотеки. Нас, например, интересуют только её методы isMobile и isTablet , чтобы вообще понять, корректные ли мы используем агенты в наших константах:
Всё. Этот код проще вышеуказанного интеграционного. Теперь если когда-нибудь переделают библиотеку, то после composer update мы сразу это увидим по упавшему юнит-тесту.
Запускаем получившиеся тесты:
Файлов классов у нас ещё нет, поэтому получаем ошибку:
После создания трёх классов ошибка изменится:
Это уже начало нашей логики. Теперь переходим к программированию.
Реализация компонента
Исходя из кода тестов мы уже продумали и получили некие интерфейсы наших классов:
Главный класс рассчитывает результат на основе показаний режима клиента и типа устройства. Напишем его каркас по этой диаграмме:
Пользователь может выбрать любую тему из двух или не выбирать. Класс ClientMode будет извлекать эти данные из Request :
Класс DeviceMode на основе MobileDetect должен определить, используется ли мобильное устройство:
Теперь реализуем основные методы. Мобильную тему нужно показать если клиент её выбрал. Или если не выбрал десктопную тему на телефоне:
Клиента определяем по cookies:
А устройство по isMobile или isTablet детектора:
Вот и всё. Запускаем модульные тесты:
Всё работает идеально:
С кодом компонента разобрались. Теперь перейдём к внешним работам на сайте.
Реализация интерфейса переключения
Откроем views/layouts/main.php и добавим ссылку для переключения версии, ведущую на текущий адрес с добавлением GET-параметра mode :
Сделаем её работающей по POST-запросу, чтобы нам не мешало кеширование браузера. Ссылка появилась:
Скопируем шаблон в файл themes/mobile/layouts/main.php и там поменяем ссылку на обратную:
Настроим тему в файле конфигурации config/web.php :
и проверим. Ссылка должна поменяться:
Теперь вместо жёсткого указания темы сделаем динамическое переключение с использованием нашего сервиса:
Эту анонимную функцию фреймворк запустит через Yii::$container->invoke(. ) и DI контейнер Yii2 подтянет все зависимости в её аргументах по именам классов автоматически.
Теперь нам нужно сделать установку cookies и учесть, чтобы параметр mode не оставался в адресной строке. Это можно осуществить редиректом на оригинальную страницу уже без параметра.
Можно переместить всё в метод beforeAction контроллера:
Но чтобы не копировать это в каждый контроллер мы пойдём дальше и переместим этот код в фильтр:
И вместо контроллера подключим этот фильтр-поведение ко всему приложению Application в конфигурационном файле config/web.php :
Можно попробовать пщёлкать по ссылке переключения версии. Она уже должна работать.
После этого скопируем настройки компонента view и поведения as viewMode в config/test.php и запустим функциональные тесты:
Всё работает также идеально.
Заключение
В результате мы получили следующую структуру компонента:
и тестов к нему:
Всё написано и всё работает.
Мы потатили дополнительное время на написание проверок и на обдумывание поведения нашего кода. Но что мы получили взамен?
- актуальные и полные тесты;
- проверенный код;
- модульный тестируемый код;
- работающее приложение;
- устойчивость к обновлениям;
- устойчивость к изменениям.
Теперь нам или заказчику практически не нужно ежедневно тратить по несколько часов на то, чтобы избороздить весь сайт и досконально прокликать все ссылки и кнопки. И можем спокойно обновлять фреймворк и зависимые компоненты. При этом после любого изменения срзу увидим по непройденным тестам, где и что у нас отвалилось.
В отличие от обычного подхода:
- код, написанный наобум;
- не всё проверено;
- ручная проверка занимает полдня;
- страшно делать composer update ;
- страшно что-то переписывать;
- сильное переплетение кода в едином месиве;
- где-то кто-то поставил скобки в if-е неправильно.
Но вроде бы переключатель темы – это мелочь. Нужно ли тестировать каждую такую мелочь? В качестве ответа вспоминается цитата из недавней статьи про неидеальный мир:
Т.е. если вы в одиночку разрабатываете проект на 100К строк кода, то вы вполне можете обойтись без тестов вообще, но как только к проекту подключается еще один разработчик (не такой гениальный, как вы), то необходимость создания тестов резко возрастает. А если этот разработчик еще и junior, то тесты становятся жизненно важны, т.к. даже ваша гениальность может спасовать перед тем энтузиазмом, с которым junior вносит ошибки в ваш любимый код.
Это хорошо осознаётся лишь когда попадаете в рабочий проект, в котором есть 800 таких «мелочей», который до сих пор висит на фреймворке годовалой давности и где при этом нет ни одного теста. Думаю, что многие уже встречали такие проекты (или даже делали их сами). И кому-то теперь нужно работать в нём несколько месяцев или лет.