Мобильная тема для Yii2 на примере Test First

На вебинаре о тестировании мы не уделили внимания практическому применению парадигмы Test Driven Development (TDD) и Test First в реальных проектах и написанию моков в модульных тестах. Попробуем решить сегодняшнюю задачу по практике написания тестов до кода и потренируемся в составлении модульных и функциональных тестов.

Рассмотрим классическое требование переключения внешнего вида сайта на мобильную тему и обратно в зависимости от используемого посетителем устройства. Адаптивная (responsive) вёрстка избавляет от разработки отдельного шаблона, но спасает не всегда, так как в сложном макете с насыщенной структурой не всё можно перекомпоновать при помощи CSS.

Те же широкие таблицы не ужимаются по ширине и всегда вылезают за экран. Их нужно либо оставить как есть, либо задать overflow: auto для окружающего таблицу блока ()чтобы появилась прокрутка по горизонтали), либо проявить изобретательность. Более гибко выглядит внедрение для таких страниц облегчённых мобильных представлений, где вместо широких таблиц будут узкие списки. Да и просто если верстать адаптивно всем лень.

Перед тем, как кидаться программировать, определим первоначальные бизнес-требования:

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

Теперь подумаем. Да-да, в TDD принято думать до написания кода, а не после. Привычный многим подход «семь раз отрежь, один отмерь» здесь не подойдёт. За это такую парадигму и любят, что код в итоге получается сразу продуманным и работающим.

Проверка функциональности сайта

Что мы хотим увидеть снаружи? Как будем проверять, что всё работает?

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

Для простоты будем рассматривать проект на примере свежей версии базового приложения на Yii2 с новыми Codeception тестами. Он ещё не релизнулся, так что загрузите обновлённую папку tests из репозитория yii2-app-basic, если захотите повторить. Но, как говорится, он и в Африке Codeception, работающий над PHPUnit, поэтому конкретный фреймворк здесь не особо важен.

Создадим заготовки:

vendor/bin/codecept generate:cest functional theme/DesktopViewMode
vendor/bin/codecept generate:cest functional theme/MobileViewMode

и напишем функциональные тесты для десктопного браузера:

namespace tests\functional\theme;
 
use FunctionalTester;
 
class DesktopViewModeCest
{
    public function _before(\FunctionalTester $I): void
    {
        $I->resetCookie('mode');
        $I->useDesktopBrowser();
        $I->amOnRoute('site/index');
    }
 
    public function viewDefaultTheme(FunctionalTester $I): void
    {
        $I->dontSeeCookie('mode');
        $I->seeLink('Mobile version');
        $I->dontSeeLink('Desktop version');
    }
 
    public function switchToAlternativeTheme(FunctionalTester $I): void
    {
        $I->click('Mobile version');
        $I->seeCookie('mode');
        $I->dontSeeInCurrentUrl('mobile');
        $I->dontSeeLink('Mobile version');
        $I->seeLink('Desktop version');
    }
 
    public function switchBack(FunctionalTester $I): void
    {
        $I->click('Mobile version');
        $I->seeLink('Desktop version');
        $I->click('Desktop version');
        $I->seeCookie('mode');
        $I->dontSeeInCurrentUrl('desktop');
        $I->seeLink('Mobile version');
        $I->dontSeeLink('Desktop version');
    }
}

И аналогичные напишем для мобильного, поменяв лишь метод useDesktopBrowser на useTabletBrowser и переставив местами тексты ссылок:

namespace tests\functional\theme;
 
use FunctionalTester;
 
class MobileViewModeCest
{
    public function _before(\FunctionalTester $I): void
    {
        $I->resetCookie('mode');
        $I->useTabletBrowser();
        $I->amOnRoute('site/index');
    }
 
    public function viewDefaultTheme(FunctionalTester $I): void
    {
        $I->dontSeeCookie('mode');
        $I->seeLink('Desktop version');
        $I->dontSeeLink('Mobile version');
    }
 
    ...
}

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

Конструкций вроде $I->useDesktopBrowser() в составе FunctionalTester не имеется. Мы сейчас придумали их сами. Но можем спокойно добавить собственные действия в объект $I. Для этого откроем файл tests/_support/FunctionalTester и допишем два этих метода:

class FunctionalTester extends \Codeception\Actor
{
    use _generated\FunctionalTesterActions;
 
    public function useDesktopBrowser(): void
    {
        $this->haveHttpHeader('USER_AGENT', UserAgent::DESKTOP);
    }
 
    public function useTabletBrowser(): void
    {
        $this->haveHttpHeader('USER_AGENT', UserAgent::TABLET);
    }
}

Мы будем писать ещё и Unit-тесты, поэтому список агентов вынесем в отдельный класс:

namespace tests\_support;
 
class UserAgent
{
    public const MOBILE = 'Mozilla/5.0 (Linux; Android 4.0; Galaxy Nexus AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0 Mobile Safari/535.19';
    public const TABLET = 'Mozilla/5.0 (iPad; CPU OS 10_2_1 like Mac OS X) AppleWebKit/602.4.6 (KHTML, like Gecko) Version/10.0 Mobile/14D27 Safari/602.1';
    public const DESKTOP = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3187.0 Safari/537.36';
}

Пробуем запустить:

vendor/bin/codecept build
vendor/bin/codecept run functional theme

и видим ошибки:

Functional Tests (6) -------------------------------------
 DesktopViewModeCest: View default theme (0.03s)
 DesktopViewModeCest: Switch to alternative theme (0.02s)
 DesktopViewModeCest: Switch back (0.02s)
 MobileViewModeCest: View default theme (0.00s)
 MobileViewModeCest: Switch to alternative theme (0.01s)
 MobileViewModeCest: Switch back (0.02s)
----------------------------------------------------------

Time: 300 ms, Memory: 28.75MB

There were 6 failures:

---------
1) ViewModeCest: View default theme
 Test  tests/functional/theme/DesktopViewModeCest.php:viewDefaultTheme
 Step  See link "Mobile version"
 Fail  No links containing text 'Mobile version' were found...

Это закономерно, так как ссылки «Mobile version» у нас в шаблоне ещё нет.

Проверка исходного кода

Что должно быть в программном коде внутри?

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

Какие можно придумать варианты? Если посетитель зашёл с телефона, то показываем мобильную тему. Если зашёл с десктопа, но выбрал мобильную (или на телефоне ещё и переключился на мобильную), то показываем тоже её.

Сгенерируем шаблон проверяющего класса:

vendor/bin/codecept generate:test unit themes/detector/ViewMode

И напишем заготовки модульных тестов для всех перечисленных выше вариаций:

class ViewModeTest
{
    public function testDefaultMobile() { $this->markTestIncomplete(); }
    public function testForceMobile() { $this->markTestIncomplete(); }
    public function testAlreadyMobile() { $this->markTestIncomplete(); }
    public function testDefaultDesktop() { $this->markTestIncomplete(); }
    public function testForceDesktop() { $this->markTestIncomplete(); }
    public function testAlreadyDesktop() { $this->markTestIncomplete(); }
}

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

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

Заготовки для ViewMode придумали. Что у нас дальше?

Наш ViewMode должен определять режим отображения на основе установленных cookies и данных устройства. Мы помним, что ООП у нас основан на разделении ответсвенностей. Поэтому дабы не захламлять код этого класса лишними обязанностями, переложим ответственности по работе с этими вещами на соответствующих помощников ClientMode и DeviceMode:

Клиент может либо просто зайти на сайт, либо выбрать тему. Либо, как злобный хакер, поменять cookies. Набросаем заготовки нужных тестовых методов:

class ClientModeTest
{
    public function testDefault() { $this->markTestIncomplete(); }
    public function testChooseMobile() { $this->markTestIncomplete(); }
    public function testChooseDesktop() { $this->markTestIncomplete(); }
    public function testIncorrect() { $this->markTestIncomplete(); }
}

Устройство может быть обычным мобильным телефоном, смартфоном или компьютером. Будем проверять работу DeviceMode тоже на всех режимах:

class DeviceModeTest
{
    public function testMobile() { $this->markTestIncomplete(); }
    public function testTablet() { $this->markTestIncomplete(); }
    public function testDesktop() { $this->markTestIncomplete(); }
}

Зачем нам такое разделение? Почему бы не вписать весь код в один класс? И в комментариях могут упрекнуть со словами «чувааак, там всего три строчки!»

Разделение одного сложного класса по ответственностям на три простых даёт нам возможность программировать и тестировать их независимо. Впоследствии для перехода от cookies к сессии (или для смены детектора устройства) можно будет переписать любую из запчастей, не боясь сломать окружающий код и чужие тесты.

Поэтому без разницы, пять там будет строчек, пятьдесят или пятьсот. Три обязанности – три объекта. И у разделения есть ещё один бонус:

Замена фреймворкозависимого класса ClientMode на интерфейс ClientModeInterface мгновенно сделает наш компонент совместимым с любым классом вроде YiiCookieClientMode, SymfonyCookieClientMode или LaravelSessionClientMode.

Два из трёх классов можно легко перенести на любой фреймворк. А в случае одного большого класса сделать это проблематично.

Реализация проверок

Реализуем ClientModeTest. Раз мы договорились сохранять режим в cookies, то классу для их извлечения нужно работать с $_COOKIES. Но мы обычно используем фреймворк, который эти данные может как-либо шифровать. Поэтому удобнее использовать предоставляемый фреймворком класс Request.

В случае Yii2 сымитируем yii\web\Request с пустыми cookies, передадим его объекту класса ClientMode и проверим, что в этом случае компонент покажет то, что не выбран ни мобильный, ни десктопный вариант:

use yii\web\Request;
 
class ClientModeTest extends Unit
{
    public function testDefault(): void
    {
        $request = $this->createStub(Request::class);
        $request->method('getCookies')->willReturn(new CookieCollection([]));
 
        $mode = new ClientMode();
 
        self::assertFalse($mode->isMobile($request));
        self::assertFalse($mode->isDesktop($request));
    }
 
    ...
}

Что при этом происходит внутри конструкции createStub? Как работают моки и стабы? Чем они отличаются?

Стабы – это простые заглушки с замещёнными методами:

$request = $this->createStub(Request::class);
$request->method('getUserAgent')->willReturn('Super Browser!');
 
echo $request->getUserAgent(); // 'Super Browser!'

Ими удобно просто подменять возвращаемые значения.

Моки – те же заглушки с замещёнными методами, но поумнее. Они ещё умеют следить за тем, сколько раз и как их вызвали.

Если указать в expect(), что метод должен вызываться всего однажды через $this->once():

$request = $this->createStub(Request::class);
$request->expect($this->once())->method('getCookies')->willReturn(new CookieCollection([]));
 
$mode = new ClientMode();
 
self::assertFalse($mode->isMobile($request));

то после теста произведётся проверка на число вызовов и вылетит ошибка, что метод getCookies вызвался, например, больше одного раза. А если ещё добавить проверку на аргументы через with() и попробовать вызвать метод с другим параметром:

$request = $this->createMock(Request::class);
$request->expect($this->once())->method('get')->with(['param1'])->willReturn(5);
 
$request->get('param2');

то сразу увидим ошибку несоответствия реальных аргументов ожидаемым.

Моками удобно делать умных «шпионов», чтобы быть уверенным, что всё внутри вызывается как надо.

В PHPUnit заглушки создаются методами createStub() createMock(). При их выполнении PHPUnit на лету создаёт новый класс, наследующийся от указанного класса или реализующий указанный интерфейс и переопределяющий его методы.

В нашем случае сгенерированный класс наследуется от Request, переопределяет указанный метод getCookies (или get) и приписывает рядом свой дополнительный код вроде expect и method. После этого из-за наличия наследования никто не догадается, что работает с подменой.

Можем дописать остальной тестовый код и вынести общий в stubRequest():

use yii\web\Request;
 
class ClientModeTest extends Unit
{
    public function testDefault(): void
    {
        $request = $this->stubRequest('');
        $mode = new ClientMode();
        self::assertFalse($mode->isMobile($request));
        self::assertFalse($mode->isDesktop($request));
    }
 
    public function testChooseMobile(): void
    {
        $request = $this->stubRequest('mobile');
        $mode = new ClientMode();
        self::assertTrue($mode->isMobile($request));
        self::assertFalse($mode->isDesktop($request));
    }
 
    ...
 
    private function stubRequest($mode): Request
    {
        $cookies = $mode ? ['mode' => new Cookie(['name' => 'mode', 'value' => $mode])] : [];
        $request = $this->createStub(Request::class);
        $request->method('getCookies')->willReturn(new CookieCollection($cookies));
        return $request;
    }
}

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

Для этого заменим assertTrue и assertFalse на assertEquals, чтобы можно было сравнивать результат с переданными в testMode аргументами:

namespace tests\unit\themes\detector;
 
use app\themes\detector\ClientMode;
use Codeception\Test\Unit;
use yii\web\Cookie;
use yii\web\CookieCollection;
 
class ClientModeTest extends Unit
{
    /**
     * @dataProvider modeProvider
     */
    public function testMode($mode, $isMobile, $isDesktop): void
    {
        $request = $this->stubRequest($mode);
        $clientMode = new ClientMode();
 
        self::assertEquals($isMobile, $clientMode->isMobile($request), 'Mobile is correct');
        self::assertEquals($isDesktop, $clientMode->isDesktop($request), 'Desktop is correct');
    }
 
    public function modeProvider(): array
    {
        return [
            'Default' => ['', false, false],
            'Choose mobile' => ['mobile', true, false],
            'Choose desktop' => ['desktop', false, true],
            'Incorrect' => ['other', false, false],
        ];
    }
 
    private function stubRequest($mode): Request
    {
        $cookies = $mode ? ['mode' => new Cookie(['name' => 'mode', 'value' => $mode])] : [];
        $request = $this->createStub(Request::class);
        $request->method('getCookies')->willReturn(new CookieCollection($cookies));
        return $request;
    }
}

Помимо самого метода с параметрами мы заготовили массив из значений. После этого аннотацией @dataProvider привязали его к методу testMode. PHPUnit запустит modeProvider(), пройдёт по вернувшемуся из него массиву циклом и запустит наш метод для каждой строки.

Далее реализуем DeviceModeTest. Дабы не велосипедить, будем использовать библиотеку MobileDetect, которая умеет определять мобильники и планшеты, браузеры и операционные системы. Установим её в наше приложение:

composer require mobiledetect/mobiledetectlib

Внутри нашего будущего DeviceMode мы могли бы работать с объектом класса MobileDetect напрямую как-нибудь так:

class DeviceMode
{
    public function isMobile(Request $request): bool
    {
        $detect = new MobileDetect();
        return $detect->...;
    }
}
 
$mode = new DeviceMode();
echo $mode->isMobile(Yii::$app->request);

Но такой подход неудобен, так как детектор создаётся внутри через new. При попытке протестировать DeviceMode нам нужно будет для каждого теста возиться с заполнением $_SERVER['HTTP_USER_AGENT'] и других значений для зависимого объекта класса MobileDetect. А нам нужно протестировать только свой класс без возни с детектором.

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

class DeviceMode
{
    private $detect;
 
    public function __construct (MobileDetect $detect)
    {
        $this->detect = $detect;
    }
 
    public function isMobile(Request $request): bool
    {  
        return $this->detect->...;
    }
}
 
$detect = new MobileDetect();
$mode = new DeviceMode($detect);
echo $mode->isMobile(Yii::$app->request);

Здесь мы сами создаём экземпляр $detect и передаём его внутрь $mode. А в тестах просто будем заменять $detect на заглушку:

class DeviceModeTest extends TestCase
{
    public function testMobile(): void
    {
        $deviceMode = new DeviceMode(new MobileDetect());
        $request = $this->stubRequest(UserAgent::MOBILE);
        self::assertTrue($deviceMode->isMobile($request));
    }
 
    ...
 
    private function stubRequest(string $agent): Request
    {
        $request = $this->createStub(Request::class);
        $request->method('getUserAgent')->willReturn($agent);
        return $request;
    }
}

Аналогично можем воспользоваться провайдером для замены одинаковых методов одним с массивом комбинаций:

namespace tests\unit\themes\detector;
 
use app\themes\detector\DeviceMode;
use Codeception\Test\Unit;
use Detection\MobileDetect;
use tests\UserAgent;
use yii\web\Request;
 
class DeviceModeTest extends Unit
{
    /**
     * @dataProvider modeProvider
     */
    public function testMode(string $agent, bool $result): void
    {
        $deviceMode = new DeviceMode(new MobileDetect());
        $request = $this->stubRequest($agent);
        self::assertEquals($result, $deviceMode->isMobile($request));
    }
 
    public function modeProvider(): array
    {
        return [
            'Mobile' => [UserAgent::MOBILE, true],
            'Tablet' => [UserAgent::TABLET, true],
            'Desktop' => [UserAgent::DESKTOP, false],
        ];
    }
 
    private function stubRequest(string $agent): Request
    {
        $request = $this->createStub(Request::class);
        $request->method('getUserAgent')->willReturn($agent);
        return $request;
    }
}

Реализуем тест будущего главного класса ViewMode. Этому классу нужно определять режим отображения на основе показаний определителей клиента и устройства.

Можно получать их значения вручную и передавать в конструктор императивным процедурным подходом:

$clientIsMobile = $clientMode->isMobile($request);
$clientIsDesktop = $clientMode->isDesktop($request);
$deviceIsMobile = $deviceMode->isMobile($request)
 
$mode = new ViewMode($clientIsMobile, $clientIsDesktop, $deviceIsMobile);
echo $mode->isMobile();

и подменять в тестах:

$mode = new ViewMode(true, false, false);

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

$clientMode = new ClientMode(...);
$deviceMode = new DeviceMode(...);
 
$mode = new ViewMode($clientMode, $deviceMode);
echo $mode->isMobile($request);

Пусть он сам вызывает их методы по своему усмотрению внутри своего isMobile(). А в тестах будем подменять датчики на моки:

class ViewModeTest
{
    public function testDefaultMobile()
    {
        $clientMode = $this->stubClientMode(false, false);
        $deviceMode = $this->stubDeviceMode(true);
 
        $viewMode = ViewMode($clientMode, $deviceMode);
 
        self::assertTrue($viewMode->isMobile(new Request()));
    }
 
    private function stubClientMode($isMobile, $isDesktop) { ... }
 
    private function stubDeviceMode($isMobile) { ... }
}

Для удобства также сплющим с помощью провайдера:

namespace tests\unit\themes\detector;
 
use app\themes\detector\ClientMode;
use app\themes\detector\DeviceMode;
use app\themes\detector\ViewMode;
use Codeception\Test\Unit;
use yii\web\CookieCollection;
use yii\web\Request;
 
class ViewModeTest extends Unit
{
    /**
     * @dataProvider modeProvider
     */
    public function testMode($clientIsMobile, $clientIsDesktop, $deviceIsMobile, $result): void
    {
        $clientMode = $this->stubClientMode($clientIsMobile, $clientIsDesktop);
        $deviceMode = $this->stubDeviceMode($deviceIsMobile);
        $viewMode = new ViewMode($clientMode, $deviceMode);
 
        self::assertEquals($result, $viewMode->isMobile($this->stubRequest()));
    }
 
    public function modeProvider(): array
    {
        return [
            'Default mobile' => [false, false, true, true],
            'Force mobile' => [true, false, false, true],
            'Already mobile' => [true, true, true, true],
            'Default desktop' => [false, false, false, false],
            'Force desktop' => [false, true, true, false],
            'Already desktop' => [false, true, false, false],
        ];
    }
 
    private function stubClientMode(bool $isMobile, bool $isDesktop): ClientMode
    {
        $mode = $this->createStub(ClientMode::class);
        $mode->method('isMobile')->willReturn($isMobile);
        $mode->method('isDesktop')->willReturn($isDesktop);
        return $mode;
    }
 
    private function stubDeviceMode(bool $isMobile): DeviceMode
    {
        $mode = $this->createStub(DeviceMode::class);
        $mode->method('isMobile')->willReturn($isMobile);
        return $mode;
    }
 
    private function stubRequest(): Request
    {
        $request = $this->createStub(Request::class);
        $request->method('getCookies')->willReturn(new CookieCollection([]));
        $request->method('getUserAgent')->willReturn('');
        $request->method('getHeaders')->willReturn([]);
        return $request;
    }
}

Кода для моков получилось много, но достаточно примитивного. Он генерируется «не напрягаясь» простым тыканьем в подсказки автоподстановки IDE.

Тест на совместимость

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

public function testDefaultDesktop(): void
{
    $viewMode = new ViewMode(
        new ClientMode(),
        new DeviceMode(new MobileDetect())
    );
 
    $request = $this->stubRequest('');
    self::assertFalse($viewMode->isMobile($request));
}

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

Но в проекте всё же появилась зависимость, которой безоговорочно доверять мы не можем. Это библиотека, которую мы используем. Мы не сомневаемся в крутости MobileDetect и не будем тестировать её самостоятельно. Но мы хотим быть уверены в интерфейсе, который от неё нужен.


Год назад на одном проекте был случай с библиотекой Apple Apn Push для рассылки push-уведомлений на смартфоны. Компонент пару раз проверили вручную и дальше верили на слово. Тестами были покрыты только модели и внешний API. а сам компонент в тестах был заменён заглушкой, чтобы не рассылать реальные сообщения на смартфоны при каждом запуске тестов.

Но вдруг после 2.1.6 в версии 2.2.0 внезапно поменялась сигнатура конструктора с такой:

$connection = new Connection('/path/certificate.pem', 'passphrase', $sandbox);

на такую:

$certificate = new Certificate('/path/certificate.pem', 'passphrase');
$connection = new Connection($certificate, $sandbox);

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


В таких случаях спасает только аккуратность просмотра хода обновлений при выполнении composer update. Если видите там изменение мажорной или минорной версии какого-либо пакета, то зайдите и прочитайте его CHANGELOG.

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

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

class MobileDetectTest extends Unit
{
    /**
     * @dataProvider agentProvider
     */
    public function testDetect(string $agent, bool $isMobile, bool $isTablet): void
    {
        $detect = new MobileDetect();
        self::assertEquals($isMobile, $detect->isMobile($agent, ['HTTP_USER_AGENT' => $agent]));
        self::assertEquals($isTablet, $detect->isTablet($agent, ['HTTP_USER_AGENT' => $agent]));
    }
 
    public function agentProvider(): array
    {
        return [
            'mobile' => [UserAgent::MOBILE, true, false],
            'tablet' => [UserAgent::TABLET, true, true],
            'desktop' => [UserAgent::DESKTOP, false, false],
        ];
    }
}

Всё. Этот код проще вышеуказанного интеграционного. Теперь если когда-нибудь переделают библиотеку, то после composer update мы сразу это увидим по упавшему юнит-тесту.

Запускаем получившиеся тесты:

vendor/bin/codecept run unit themes

Файлов классов у нас ещё нет, поэтому получаем ошибку:

PHP Fatal error: Class 'app\themes\detector\ClientMode' not found

После создания трёх классов ошибка изменится:

PHP Fatal error: Call to undefined method ClientMode::isMobile() 

Это уже начало нашей логики. Теперь переходим к программированию.

Реализация компонента

Исходя из кода тестов мы уже продумали и получили некие интерфейсы наших классов:

Диаграмма классов

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

class ViewMode
{
    private $client;
    private $device;
 
    public function __construct(ClientMode $client, DeviceMode $device)
    {
        $this->client = $client;
        $this->device = $device;
    }
 
    public function isMobile(Request $request): bool { ... }
}

Пользователь может выбрать любую тему из двух или не выбирать. Класс ClientMode будет извлекать эти данные из Request:

class ClientMode
{
    public function isMobile(Request $request): bool { ... }
    public function isDesktop(Request $request): bool { ... }
}

Класс DeviceMode на основе MobileDetect должен определить, используется ли мобильное устройство:

class DeviceMode
{
    private $detect;
 
    public function __construct(MobileDetect $detect)
    {
        $this->detect = $detect;
    }
 
    public function isMobile(Request $request): bool { ...  }
}

Теперь реализуем основные методы. Мобильную тему нужно показать если клиент её выбрал. Или если не выбрал десктопную тему на телефоне:

namespace app\themes\detector;
 
use yii\web\Request;
 
class ViewMode
{
    private $client;
    private $device;
 
    public function __construct(ClientMode $client, DeviceMode $device)
    {
        $this->client = $client;
        $this->device = $device;
    }
 
    public function isMobile(Request $request): bool
    {
        return
            $this->client->isMobile($request) ||
            (!$this->client->isDesktop($request) && $this->device->isMobile($request));
    }
}

Клиента определяем по cookies:

namespace app\themes\detector;
 
use yii\web\Request;
 
class ClientMode
{
    public const COOKIE_NAME = 'mode';
    public const MODE_MOBILE = 'mobile';
    public const MODE_DESKTOP = 'desktop';
 
    public function isMobile(Request $request): bool
    {
        return $this->is($request, self::MODE_MOBILE);
    }
 
    public function isDesktop(Request $request): bool
    {
        return $this->is($request, self::MODE_DESKTOP);
    }
 
    private function is(Request $request, $mode): bool
    {
        return $request->getCookies()->getValue(self::COOKIE_NAME) === $mode;
    }
}

А устройство по isMobile или isTablet детектора:

namespace app\themes\detector;
 
use Detection\MobileDetect;
use yii\web\Request;
 
class DeviceMode
{
    private $detect;
 
    public function __construct(MobileDetect $detect)
    {
        $this->detect = $detect;
    }
 
    public function isMobile(Request $request): bool
    {
        return
            $this->detect->isMobile($request->getUserAgent(), $request->getHeaders()) ||
            $this->detect->isTablet($request->getUserAgent(), $request->getHeaders());
    }
}

Вот и всё. Запускаем модульные тесты:

vendor/bin/codecept run unit themes

Всё работает идеально:

Unit Tests (14) ------------------------------------------
 ClientModeTest: Mode | "default" (0.00s)
 ClientModeTest: Mode | "choose mobile" (0.00s)
 ClientModeTest: Mode | "choose desktop" (0.00s)
 ClientModeTest: Mode | "incorrect" (0.00s)
 DeviceModeTest: Mode | "mobile" (0.00s)
 DeviceModeTest: Mode | "tablet" (0.00s)
 DeviceModeTest: Mode | "desktop" (0.00s)
 MobileDetectTest: Detect | "mobile" (0.01s)
 MobileDetectTest: Detect | "tablet" (0.01s)
 MobileDetectTest: Detect | "desktop" (0.01s)
 ViewModeTest: Mode | "default mobile" (0.00s)
 ViewModeTest: Mode | "force mobile" (0.00s)
 ViewModeTest: Mode | "already mobile" (0.00s)
 ViewModeTest: Mode | "default desktop" (0.00s)
 ViewModeTest: Mode | "force desktop" (0.00s)
 ViewModeTest: Mode | "already desktop" (0.00s)
----------------------------------------------------------

Time: 308 ms, Memory: 23.00MB

OK (16 tests, 23 assertions)

С кодом компонента разобрались. Теперь перейдём к внешним работам на сайте.

Реализация интерфейса переключения

Откроем views/layouts/main.php и добавим ссылку для переключения версии, ведущую на текущий адрес с добавлением GET-параметра mode:

<?php
<footer class="footer">
    <div class="container">
        <p class="pull-left">&copy; My Company <?= date('Y') ?></p>
        <p class="pull-right">
            <?= Html::a('Mobile version', Url::current(['mode' => 'mobile']), [
                'data-method' => 'post',
            ]) ?>
        </p>
    </div>
</footer>

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

Скопируем шаблон в файл themes/mobile/layouts/main.php и там поменяем ссылку на обратную:

<?php
<?= Html::a('Desktop version', Url::current(['mode' => 'desktop']), [
    'data-method' => 'post',
]) ?>

Настроим тему в файле конфигурации config/web.php:

'components' => [
    ...
    'view' => [
        'class' => 'yii\web\View',
        'theme' => [
            'basePath' => '@app/themes/mobile',
            'baseUrl' => '@web',
            'pathMap' => [
                '@app/views' => '@app/themes/mobile',
            ],
        ],
    ],
],

и проверим. Ссылка должна поменяться:

Теперь вместо жёсткого указания темы сделаем динамическое переключение с использованием нашего сервиса:

use app\themes\detector\ViewMode; 
...
$config = [
    ...
    'components' => [
        ...
        'view' => function (ViewMode $mode) {
            return Yii::createObject([
                'class' => 'yii\web\View',
                'theme' => $mode->isMobile(Yii::$app->request) ? [
                    'basePath' => '@app/themes/mobile',
                    'baseUrl' => '@web',
                    'pathMap' => [
                        '@app/views' => '@app/themes/mobile',
                    ],
                ] : null,
            ]);
        },
    ],    
    'params' => $params,
];

Эту анонимную функцию фреймворк запустит через Yii::$container->invoke(...) и DI контейнер Yii2 подтянет все зависимости в её аргументах по именам классов автоматически.

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

Можно переместить всё в метод beforeAction контроллера:

class SiteController extends Controller
{
    public function beforeAction($action): bool
    {
        if (parent::beforeAction($action)) {
            if ($mode = Yii::$app->request->get('mode')) {
                $response = Yii::$app->response;
                $response->cookies->add(new Cookie([
                    'name' => ClientMode::COOKIE_NAME,
                    'value' => $mode,
                    'expire' => time() + 3600 * 24 * 30,
                ]));
                $response->redirect(Url::current(['mode' => null]), 301);
                return false;
            }
            return true;
        }
        return false;
    }
 
    ...
}

Но чтобы не копировать это в каждый контроллер мы пойдём дальше и переместим этот код в фильтр:

namespace app\themes\filter;
 
use app\themes\detector\ClientMode;
use Yii;
use yii\base\ActionFilter;
use yii\helpers\Url;
use yii\web\Cookie;
 
class ViewModeFilter extends ActionFilter
{
    public $expire = 0;
 
    public function beforeAction($action): bool
    {
        if ($mode = Yii::$app->request->get('mode')) {
            $response = Yii::$app->response;
            $response->cookies->add(new Cookie([
                'name' => ClientMode::COOKIE_NAME,
                'value' => $mode,
                'expire' => time() + $this->expire,
            ]));
            $response->redirect(Url::current(['mode' => null]), 301);
            return false;
        }
        return true;
    }
}

И вместо контроллера подключим этот фильтр-поведение ко всему приложению Application в конфигурационном файле config/web.php:

$config = [
    ...
    'components' => [
        ...
    ],
    'as viewMode' => [
        'class' => 'app\themes\filter\ViewModeFilter',
        'expire' => 2592000 // 30 days
    ],
    'params' => $params,
];

Можно попробовать пщёлкать по ссылке переключения версии. Она уже должна работать.

После этого скопируем настройки компонента view и поведения as viewMode в config/test.php и запустим функциональные тесты:

vendor/bin/codecept run functional theme
Functional Tests 6) --------------------------------------
 DesktopViewModeCest: View default theme (0.04s)
 DesktopViewModeCest: Switch to alternative theme (0.03s)
 DesktopViewModeCest: Switch back (0.03s)
 MobileViewModeCest: View default theme (0.01s)
 MobileViewModeCest: Switch to alternative theme (0.01s)
 MobileViewModeCest: Switch back (0.02s)
----------------------------------------------------------

Всё работает также идеально.

Заключение

В результате мы получили следующую структуру компонента:

themes
├── detector
│   ├── ClientMode.php
│   ├── DeviceMode.php
│   └── ViewMode.php
├── filter
│   └── ViewModeFilter.php
└── mobile
    └── layouts
        └── main.php

и тестов к нему:

tests
├── functional
│   ├── ...
│   └── theme
│       ├── DesktopViewModeCest.php
│       └── MobileViewModeCest.php
├── _support
│   ├── FunctionalTester.php
│   └── ...
└── unit
    ├── ...
    └── themes
        └── detector
            ├── ClientModeTest.php
            ├── DeviceModeTest.php
            ├── MobileDetectTest.php
            └── ViewModeTest.php

Всё написано и всё работает.

Мы потатили дополнительное время на написание проверок и на обдумывание поведения нашего кода. Но что мы получили взамен?

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

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

В отличие от обычного подхода:

  • код, написанный наобум;
  • не всё проверено;
  • ручная проверка занимает полдня;
  • страшно делать composer update;
  • страшно что-то переписывать;
  • сильное переплетение кода в едином месиве;
  • где-то кто-то поставил скобки в if-е неправильно...

Но вроде бы переключатель темы – это мелочь. Нужно ли тестировать каждую такую мелочь? В качестве ответа вспоминается цитата из недавней статьи про неидеальный мир:

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

Это хорошо осознаётся лишь когда попадаете в рабочий проект, в котором есть 800 таких «мелочей», который до сих пор висит на фреймворке годовалой давности и где при этом нет ни одного теста. Думаю, что многие уже встречали такие проекты (или даже делали их сами). И кому-то теперь нужно работать в нём несколько месяцев или лет...

Комментарии

 

Павел Агейчик

Воу, тема TDD, очень кстати

Ответить

 

Юрий

класс спасибо

Ответить

 

Andrewkha – Sportforecast.net

Что то не нашёл описания класса Detection\MobileDetect;

Ответить

 

Andrewkha – Sportforecast.net

Разобрался, пардон

Ответить

 

Денис

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

Ответить

 

Дмитрий Елисеев

Четыре класса кода - четыре класса тестов. Всё сошлось и пошли дальше. В данном случае можно обойтись только функциональными. Экономия на ручных тестерах, техподдержке и ловле багов. О каком нуле речь?

Ответить

 

lynicidn

Полный бред, в юнит тестах юзаем куки, повторение кода, тест проверки браузера тестирует вообще не свою логику

Ответить

 

Дмитрий Елисеев

О! Старый знакомый, забаненный за говносрач на форуме, добрался и до сюда :)

Ответить

 

lynicidn

хаха, дебил, ктож меня забанил то?

Ответить

 

Дмитрий Елисеев

Не знаю кто. Я там не модератор.

Ответить

 

Andrewkha – sportforecast.net

Дмитрий, не обращай внимания на троллей.
Вопрос вот по этому куску:

use app\themes\detector\ViewMode; 
...
$config = [
    ...
    'components' => [
        ...
        'view' => function (ViewMode $mode) {
            return Yii::createObject([
                'class' => 'yii\web\View',
                'theme' => $mode->isMobile() ? [
                    'basePath' => '@app/themes/mobile',
                    'baseUrl' => '@web',
                    'pathMap' => [
                        '@app/views' => '@app/themes/mobile',
                    ],
                ] : null,
            ]);
        },
    ],    
    'params' => $params,
];

Эту анонимную функцию фреймворк запустит через Yii::$container->invoke(...) и DI контейнер Yii2 подтянет все зависимости в её аргументах по именам классов автоматически.

Не очень понятно. Если смотреть на Yii::createObject, то у него параметром явлется массив, что в исходном коде этой функции соответствует:

elseif (is_array($type) && isset($type['class'])) {
    $class = $type['class'];
    unset($type['class']);
    return static::$container->get($class, $params, $type);
} ...

Каким образом вызывается invoke?

Ответить

 

Дмитрий Елисеев
} elseif (is_array($type) && isset($type['class'])) {
    $class = $type['class'];
    unset($type['class']);
    return static::$container->get($class, $params, $type);
} elseif (is_callable($type, true)) {
    return static::$container->invoke($type, $params);
}

Для return Yii::createObject([...]) сработает get.

Для анонимки Yii::createObject(function (...) {...}) сработает invoke.

Ответить

 

Andrewkha – sportforecast.net

Сорри, если туплю... А где здесь анонимка Yii::createObject(function (...) {...})? Я в коде вижу лишь один createObject с массивом в качестве параметра

Ответить

 

Andrewkha – sportforecast.net

Вроде бы, разобрался...
Конструкция

'view' => function (ViewMode $mode) {

каким-то образом преобразуется в createObject(function(ViewMode $mode){}), а тот, в свою очередь, вызывает Yii::$container->invoke(...), который создает объект ViewMode $mode (с разворачиванием всех зависимостей) и передает его в качестве параметра.
Ну и далее уже идет создание объекта View согласно телу функции.

Так?

Ответить

 

Дмитрий Елисеев

Да, когда запрашиваем любой компонент вроде Yii::$app->view вызывается get('view') класса ServiceLocator и через Yii::createObject создаётся этот компонент по конфигу 'view' из 'components'.

Ответить

 

Andrewkha – Sportforecast.net

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

Ответить

 

Andrewkha – sportforecast.net

Дмитрий, еще один вопрос по Yii::createObject. Я правильно понимаю, что если использовать ее как Yii::createObject('classname', [$params]), то в данном случае $params - это то, что будет передано в конструктор создаваемого объекта.
А если Yii::createObject(function (Class $class, [$params]), [$params]), то здесь $params пойдут, как дополнительные параметры в функцию, и в конструктор Class таким образом ничего передать нельзя?

Ответить

 

Дмитрий Елисеев

Нельзя.

Ответить

 

Александр Компаниец

Очень полезный материал, есть одна рекоммендация - я не делал бы метод

ClientMode::is()

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

Почему вы не используете интерфейсы? к примеру если будет нужно использовать библиотеку в другом фреймворке? На примере того же

ClientMode

не выйдет так как в него вхардкожен Request класс фреймворка как зависимость.

А в целом все очень понравилось, материал раскрыт лаконично и подробно. Большое спасибо.

Ответить

 

Дмитрий Елисеев

Про интерфейс как раз упоминал.

Ответить

 

Виктор

Делаю все по порядку,
на этапе получения всех пройденных юнит тестов в конце получаю:

There were 7 warnings:

[PHPUnit_Framework_Warning] PHPUnit_Framework_TestCase::getMock() is deprecated, use PHPUnit_Framework_TestCase::createMock() or PHPUnit_Framework_TestCase::getMockBuilder() instead
......

Функциию createMock() не смог найти, поэтому переписав ClientModeTest и DeviceModeTest на getMockBuilder() все заработало.

Ответить

 

Виктор

И в конце получил надпись:
Time: 15.5 seconds, Memory: 24.75MB

OK (14 tests, 19 assertions)

Что называется - почувствуйте разницу (это я про время выполнения тестов)

Ответить

 

Alex_Smart

Здравствуйте, заюзал ваш компонент все хорошо работает, как надо, спасибо! Но есть одно но: как сделать что бы если с телефона мы заходили на сайт он редиректил на суб домен сайта, m.сайт? Буду очень благодарен за помощь!

Ответить

 

Alex_Smart

Вот сайт https://alex.smartintegra.com.ua, https://prnt.sc/p9hqb6 - вот переключает и корректно все меняется темплейты и т.д. .Так вот, хотелось бы когда с мобильного захожу был редирект на m.alex.smartintegra.com.ua автоматически. Все сделал по вашему мануалу, пожскажите как быть с редиректом, в beforeAction сделать что то

header('Location: http://m.'.$_SERVER['HTTP_HOST'].'');

не вариант, переадресация бесконечная получается. Подскажите, пожалуйста)

Ответить

 

Дмитрий Елисеев

Вариант. Только добавьте проверку, что домен не m.*

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>