Этот перевод является продолжением цикла статей про рефакторинг от Matthias Noback.
Во время юнит тестирования нет необходимости в том, чтобы внешняя среда была вовлечена в сам процесс тестирования. Выполняя реальные запросы к базе данных, HTTP запросы или же запись в файлы, вы замедляете тесты, так как эти операции непредсказуемы. Например, если сервер, к которому вы совершаете запросы во время тестирования упал или же ответил не лучшим образом — юнит тест упадет даже в том случае, если все остальное работает верно. Это плохо, так как юнит тесты должны падать только тогда, когда код выполняет что-то, чего он делать не должен.
Как можно было заметить в прошлой статье, оба класса (CachedCatApi и RealCatApi) зависят от внешних факторов. Первый из них записывает файлы в файловую систему, второй — делает реальные HTTP запросы, в то время как эти моменты довольно низкоуровневые и для них не используются правильные инструменты. Более того, в этих классах не учитывается большое количество пограничных случаев.
Оба класса могут быть лишены подобных зависимостей и для этого достаточно того, чтобы новые классы инкапсулировали все эти низкоуровневые детали. Например, мы запросто можем убрать вызов file_get_contents() в другой класс с названием FileGetContentsHttpClient.
Как и в прошлой статье, нельзя просто так взять и вынести немного кода в другой класс. Для нового класса нужно ввести интерфейс, так как без него будет трудно написать нормальный тест:
Теперь можно передавать HttpClient в качестве аргумента конструктора RealCatApi:
С этого момента у нас будет действительно крутой юнит тест для RealCatApi. Нужно лишь подменить (stand-in?) HttpClient, чтобы тот возвращал предопределенный XML-ответ:
Теперь это правильный тест, который проверяет следующее поведение RealCatApi: он должен вызвать HttpClient с определенным URL и вернуть значение поля из XML ответа.
Остается пофиксить еще один момент — метод get() класса HttpClient все еще зависит от поведения file_get_contents(), то есть возвращает false, если запрос был неудачным, или же тело ответа в виде строки, если запрос успешен. Мы без проблем можем скрыть эту деталь реализации, конвертировав некоторые возвращаемые значения (как false, например) в определенные для них исключения (кастомный эксепшн). Таким образом, мы строго ограничиваем количество обрабатываемых сущностей, которые проходят через наши объекты. В нашем случае это лишь аргумент функции, возвращаемая строка или исключение:
Остается немного изменить RealCatApi, чтобы тот мог ловить исключения вместо того, чтобы реагировать на false:
Вы же заметили, что раньше у нас был юнит тест только правильного адреса? Мы тестировали только успешный результат file_get_contents() с валидным XML ответом. Не было возможности протестировать упавший HTTP запрос, так как непонятно, каким образом вы можете принудительно «завалить» HTTP запрос, ну, кроме как вытащив сетевой кабель?
Сейчас же у нас есть полный контроль над HttpClient и мы можем симулировать падение запроса — для этого просто нужно бросить исключение HttpRequestFailed:
Мы можем повторить аналогичные шаги для зависимости CachedCatApi от файловой системы:
Наконец-то, наконец-то мы можем избавиться от этих страшных вызовов sleep() в CachedCatApiTest! И все это благодаря тому, что у нас есть простая обертка для Cache. Я оставлю эту часть как самостоятельное упражнение для читателя.
Появилось несколько проблем:
Первая может быть решена переименованием некоторых методов и инвертирования некоторой булевой логики. Вторая же решается передачей необходимого пути как аргумента конструктора.
В этой части мы скрыли с глаз долой много низкоуровневых деталей, связанных с файловой системой и HTTP запросами. Это позволяет писать действительно правильные юнит тесты.
Конечно, код в FileCache и FileGetContentsHttpClient все еще нужно протестировать, статья заканчивается, а тесты все еще медленные и хрупкие. Но вы можете сделать вот что: откажитесь от их тестирования в пользу использования существующих решений для работы с файлами или выполнения HTTP запросов. Бремя тестирования подобных библиотек лежит полностью на их разработчиках, но это позволяет вам сфокусироваться на важных частях именно вашего кода и сделает ваши тесты быстрее
UPD: Киски: Рефакторинг. Часть третья или причесываем шероховатости
Мир не так надежен, чтобы на него опираться
Во время юнит тестирования нет необходимости в том, чтобы внешняя среда была вовлечена в сам процесс тестирования. Выполняя реальные запросы к базе данных, 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 запросов. Бремя тестирования подобных библиотек лежит полностью на их разработчиках, но это позволяет вам сфокусироваться на важных частях именно вашего кода и сделает ваши тесты быстрее
UPD: Киски: Рефакторинг. Часть третья или причесываем шероховатости