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

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 = <<
    
        
            
                http://24.media.tumblr.com/tumblr_lgg3m9tRY41qgnva2o1_500.jpg
                bie
                http://thecatapi.com/?id=bie
            
        
    

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 запросов. Бремя тестирования подобных библиотек лежит полностью на их разработчиках, но это позволяет вам сфокусироваться на важных частях именно вашего кода и сделает ваши тесты быстрее

Продолжение следует…

© Habrahabr.ru