Кицьки: Рефакторинг. Частина друга або лікування залежностей

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 виконання запитів. Тягар тестування подібних бібліотек лежить повністю на їх розробників, але це дозволяє вам зосередитися на важливих частинах саме вашого коду і зробить ваші тести швидше

далі буде...

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.