Этот перевод является продолжением цикла статей про рефакторинг от 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. Я оставлю эту часть как самостоятельное упражнение для читателя.
Появилось несколько проблем:
- Мне не нравится API интерфейса Cache. Метод isNotFresh() тяжело воспринимается. Он также не соответствует уже существующим абстракциям (например тем, что из Doctrine), что делает его непонятным для людей, знакомых с кэшированием в PHP.
- Путь для кэша все еще захардкожен в классе FileCache. Это плохо для тестирования — нет возможности его изменить.
Первая может быть решена переименованием некоторых методов и инвертирования некоторой булевой логики. Вторая же решается передачей необходимого пути как аргумента конструктора.
Заключение
В этой части мы скрыли с глаз долой мно низкоуровневых деталей, связанных с файловой системой и HTTP запросами. Это позволяет писать действительно правильные юнит тесты.
Конечно, код в FileCache и FileGetContentsHttpClient все еще нужно протестировать, статья заканчивается, а тесты все еще медленные и хрупкие. Но вы можете сделать вот что: откажитесь от их тестирования в пользу использования существующих решений для работы с файлами или выполнения HTTP запросов. Бремя тестирования подобных библиотек лежит полностью на их разработчиках, но это позволяет вам сфокусироваться на важных частях именно вашего кода и сделает ваши тесты быстрее
Продолжение следует…
ссылка на оригинал статьи http://habrahabr.ru/post/263139/
Добавить комментарий