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

—

от автора

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

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

ссылка на оригинал статьи http://habrahabr.ru/post/263139/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *