Киски: Рефакторинг. Часть вторая или лечение зависимостей

http://php-and-symfony.matthiasnoback.nl/2015/07/refactoring-the-cat-api-client-part-2/
  • Перевод
  • Tutorial
image Этот перевод является продолжением цикла статей про рефакторинг от Matthias Noback.

Мир не так надежен, чтобы на него опираться


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

Как можно было заметить в прошлой статье, оба класса (CachedCatApi и RealCatApi) зависят от внешних факторов. Первый из них записывает файлы в файловую систему, второй — делает реальные HTTP запросы, в то время как эти моменты довольно низкоуровневые и для них не используются правильные инструменты. Более того, в этих классах не учитывается большое количество пограничных случаев.

Оба класса могут быть лишены подобных зависимостей и для этого достаточно того, чтобы новые классы инкапсулировали все эти низкоуровневые детали. Например, мы запросто можем убрать вызов file_get_contents() в другой класс с названием FileGetContentsHttpClient.

class FileGetContentsHttpClient
{
    public function get($url)
    {
        return @file_get_contents($url);
    }
}

И снова инверсия зависимостей


Как и в прошлой статье, нельзя просто так взять и вынести немного кода в другой класс. Для нового класса нужно ввести интерфейс, так как без него будет трудно написать нормальный тест:

interface HttpClient
{
    /**
     * @return string|false Response body
     */
    public function get($url);
}

Теперь можно передавать HttpClient в качестве аргумента конструктора RealCatApi:

class RealCatApi implements CatAPi
{
    private $httpClient;

    public function __construct(HttpClient $httpClient)
    {
        $this->httpClient = $httpClient;
    }

    public function getRandomImage()
    {
        $responseXml = $this->httpClient->get('http://thecatapi.com/api/images/get?format=xml&type=jpg');

        ...
    }
}

Настоящий юнит тест


С этого момента у нас будет действительно крутой юнит тест для RealCatApi. Нужно лишь подменить (stand-in?) HttpClient, чтобы тот возвращал предопределенный XML-ответ:

class RealCatApiTest extends \PHPUnit_Framework_TestCase
{
    /** @test */
    public function it_fetches_a_random_url_of_a_cat_gif()
    {
        $xmlResponse = <<<EOD
<response>
    <data>
        <images>
            <image>
                <url>http://24.media.tumblr.com/tumblr_lgg3m9tRY41qgnva2o1_500.jpg</url>
                <id>bie</id>
                <source_url>http://thecatapi.com/?id=bie</source_url>
            </image>
        </images>
    </data>
</response>
EOD;
        $httpClient = $this->getMock('HttpClient');
        $httpClient
            ->expect($this->once())
            ->method('get')
            ->with('http://thecatapi.com/api/images/get?format=xml&type=jpg')
            ->will($this->returnValue($xmlResponse));
        $catApi = new RealCatApi($httpClient);

        $url = $catApi->getRandomImage();

        $this->assertSame(
            'http://24.media.tumblr.com/tumblr_lgg3m9tRY41qgnva2o1_500.jpg',
            $url
        );
    }
}

Теперь это правильный тест, который проверяет следующее поведение RealCatApi: он должен вызвать HttpClient с определенным URL и вернуть значение поля из XML ответа.

Отделяем API от file_get_contents()


Остается пофиксить еще один момент — метод get() класса HttpClient все еще зависит от поведения file_get_contents(), то есть возвращает false, если запрос был неудачным, или же тело ответа в виде строки, если запрос успешен. Мы без проблем можем скрыть эту деталь реализации, конвертировав некоторые возвращаемые значения (как false, например) в определенные для них исключения (кастомный эксепшн). Таким образом, мы строго ограничиваем количество обрабатываемых сущностей, которые проходят через наши объекты. В нашем случае это лишь аргумент функции, возвращаемая строка или исключение:

class FileGetContentsHttpClient implements HttpClient
{
    public function get($url)
    {
        $response = @file_get_contents($url);
        if ($response === false) {
            throw new HttpRequestFailed();
        }

        return $response;
    }
}

interface HttpClient
{
    /**
     * @return string Response body
     * @throws HttpRequestFailed
     */
    public function get($url);
}

class HttpRequestFailed extends \RuntimeException
{
}

Остается немного изменить RealCatApi, чтобы тот мог ловить исключения вместо того, чтобы реагировать на false:

class RealCatApi implements CatAPi
{
    public function getRandomImage()
    {
        try {
            $responseXml = $this->httpClient->get('http://thecatapi.com/api/images/get?format=xml&type=jpg');

            ...
        } catch (HttpRequestFailed $exception) {
            return 'http://cdn.my-cool-website.com/default.jpg';
        }

        ...
    }
}

Вы же заметили, что раньше у нас был юнит тест только правильного адреса? Мы тестировали только успешный результат file_get_contents() с валидным XML ответом. Не было возможности протестировать упавший HTTP запрос, так как непонятно, каким образом вы можете принудительно «завалить» HTTP запрос, ну, кроме как вытащив сетевой кабель?

Сейчас же у нас есть полный контроль над HttpClient и мы можем симулировать падение запроса — для этого просто нужно бросить исключение HttpRequestFailed:

class RealCatApiTest extends \PHPUnit_Framework_TestCase
{
    ...

    /** @test */
    public function it_returns_a_default_url_when_the_http_request_fails()
    {
        $httpClient = $this->getMock('HttpClient');
        $httpClient
            ->expect($this->once())
            ->method('get')
            ->with('http://thecatapi.com/api/images/get?format=xml&type=jpg')
            ->will($this->throwException(new HttpRequestFailed());
        $catApi = new RealCatApi($httpClient);

        $url = $catApi->getRandomImage();

        $this->assertSame(
            'http://cdn.my-cool-website.com/default.jpg',
            $url
        );
    }
}

Избавляемся от файловой системы


Мы можем повторить аналогичные шаги для зависимости CachedCatApi от файловой системы:

interface Cache
{
    public function isNotFresh($lifetime);

    public function put($url);

    public function get();
}

class FileCache implements Cache
{
    private $cacheFilePath;

    public function __construct()
    {
        $this->cacheFilePath = __DIR__ . '/../../cache/random';
    }

    public function isNotFresh($lifetime)
    {
        return !file_exists($this->cacheFilePath) 
                || time() - filemtime($this->cacheFilePath) > $lifetime
    }

    public function put($url)
    {
        file_put_contents($this->cacheFilePath, $url);
    }

    public function get()
    {
         return file_get_contents($this->cacheFilePath);
    }
}

class CachedCatApi implements CatApi
{
    ...
    private $cache;

    public function __construct(CatApi $realCatApi, Cache $cache)
    {
        ...
        $this->cache = $cache;
    }

    public function getRandomImage()
    {
        if ($this->cache->isNotFresh()) {
            ...

            $this->cache->put($url);

            return $url;
        }

        return $this->cache->get();
    }
}

Наконец-то, наконец-то мы можем избавиться от этих страшных вызовов sleep() в CachedCatApiTest! И все это благодаря тому, что у нас есть простая обертка для Cache. Я оставлю эту часть как самостоятельное упражнение для читателя.

Появилось несколько проблем:

  1. Мне не нравится API интерфейса Cache. Метод isNotFresh() тяжело воспринимается. Он также не соответствует уже существующим абстракциям (например тем, что из Doctrine), что делает его непонятным для людей, знакомых с кэшированием в PHP.
  2. Путь для кэша все еще захардкожен в классе FileCache. Это плохо для тестирования — нет возможности его изменить.

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

Заключение


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

Конечно, код в FileCache и FileGetContentsHttpClient все еще нужно протестировать, статья заканчивается, а тесты все еще медленные и хрупкие. Но вы можете сделать вот что: откажитесь от их тестирования в пользу использования существующих решений для работы с файлами или выполнения HTTP запросов. Бремя тестирования подобных библиотек лежит полностью на их разработчиках, но это позволяет вам сфокусироваться на важных частях именно вашего кода и сделает ваши тесты быстрее

UPD: Киски: Рефакторинг. Часть третья или причесываем шероховатости
Поделиться публикацией

Комментарии 0

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

Самое читаемое