Трохи про юніт-тестування і зовнішніх API в PHP

Юніт-тестування — одна з невід'ємних частин процесу розробки, і воно стає складнішими та суперечливішими, якщо основне завдання Вашого коду — надсилати запити до зовнішніх API і обробляти відповіді. Чимало списів зламано про тему, яким повинно бути тестування коду, зав'язаного на зовнішніх джерелах, і де проходить межа між тестуванням власного коду і чужих API.

На цьому етапі розробникам доводиться вирішити, які запити надсилати на віддалений сервер, а які симулювати локально. Існує чимало рішень як для відправки запитів, так і для їх симуляції. У своєму пості я розповім, як зробити і те, і інше на базі HTTP клієнта Guzzle.




Пару слів про продукт. Guzzle — розширювана HTTP клієнт для PHP. Він знаходиться в активній розробці. За минулий рік дві старші версії. Версія 4.0.0 вийшла в березні 2014, а травень 2015 приніс реліз версії 6.0.0. Перехід між ними може викликати певні складнощі, оскільки розробники в кожному релізі змінюють простір імен і деякі принципи роботи.

Крім того, може виникнути складність поєднання різних посібників, навіть написаних зовсім недавно. Проте, якщо гарненько погуглити і шанувати рідну документацію, в результаті вдається знайти прийнятне рішення.

Установка

Guzzle встановлюється як пакет Composer. Інсталяційний файл composer.json для наших потреб виглядає наступним чином:

{
"name": "our-guzzle-test",
"description": "Guzzle setup API testing",
"minimum-stability": "dev",
"require": {
"guzzlehttp/guzzle": "5.*",
"guzzlehttp/log-subscriber": ".*",
"monolog/monolog": ".*",
"guzzlehttp/oauth-subscriber": ".*"
}
}

Для деяких наших завдань необхідно використовувати 3-крокову аутентифікацію OAuth, тому мені довелося зупинитися на версії Guzzle 5.3. На момент написання це остання версія, що підтримує плагін oauth-subsctiber. Однак якщо Вам OAuth не потрібен, Ви можете спробувати адаптувати рішення для версії 6.*. Природно, попередньо звіртеся з документацією.

Перші кроки

Першим ділом Вам знадобиться підключити файл автозавантаження пакетів Composer:

require_once "path_to_composer_files/виробника/autoload.php";

Крім того, нам необхідно вказати, які простору імен будуть використовуватися нашими скриптами. В моєму випадку за відправку запитів і за збереження результатів відповідають різні файли. При необхідності Ви можете об'єднати їх.

Надсилання запитів і логування

// Сам клієнт
use GuzzleHttp\Client;

// Підписування OAuth 
use GuzzleHttp\Subscriber\Oauth\Oauth1; 

// Логування
use GuzzleHttp\Subscriber\Log\LogSubscriber; 
use Monolog\Logger; 
use Monolog\Handler\StreamHandler; 
use GuzzleHttp\Subscriber\Log\Formatter;

Збереження і симулирование

use GuzzleHttp\Subscriber\Mock; 
use GuzzleHttp\Message\Response; 
use GuzzleHttp\Stream\Stream; 

Надсилання запитів

Для визначення поточного режиму роботи у нас використовуються дві глобальні змінні:
  • $isUnitTest визначає, чи працює система в штатному режимі або в режимі автоматичного тестування;
  • $recordTestResults повідомляє систему про необхідність зберегти дані всіх запитів та відповідей.
Процедури OAuth
Guzzle дозволяє використовувати один і той же код як для самої авторизації OAuth (за різними схемами), так і для відправки підписаних запитів.

$oauthKeys = [
'consumer_key' => OAUTH_CONSUMER_KEY,
'consumer_secret' => OAUTH_CONSUMER_SECRET,
];
if ($authStatus == 'preauth') { // завершальна частина 3-етапної OAuth аутентифікації
$oauthKeys['token'] = $oauth_request_token;
$oauthKeys['token_secret'] = $oauth_request_token_secret;
} elseif ($authStatus == 'auth') { // звичайний запит
$oauthKeys['token'] = $oauth_access_token;
$oauthKeys['token_secret'] = $oauth_access_token_secret;
}
$oauth = new Oauth1($oauthKeys);

Константи OAUTH_CONSUMER_KEY та OAUTH_CONSUMER_SECRET — пара ключів, наданих Вашим провайдером API. В залежності від поточного статусу авторизації можуть вимагатися токен і секретний ключ до нього. За більш детальною інформацією про OAuth Ви можете звернутися до відповідних джерел (наприклад, OAuth Bible).

Ініціалізація HTTP-клієнта
На цьому кроці ми визначаємо, чи нам потрібно відправляти реальний запит або отримати локально збережений відповідь.

if (empty($isUnitTest) || !empty($recordTestResults)) {
$client = new Client(['base_url' => $apiUrl, 'defaults' => ['auth' => 'oauth']]);
$client->getEmitter()->attach($oauth);
} else {
$mock = getResponseLocally($requestUrl, $requestBody);
$client = new Client();
$client->getEmitter()->attach($mock);
}

  • $apiUrl — базовий шлях Вашого API
  • 'defaults' => ['auth' => 'oauth'] необхідний тільки якщо Ви відправляєте запити OAuth. То ж справедливо і для $client->getEmitter()->attach($oauth);
  • $requestUrl — повний шлях запиту (включаючи базовий шлях)
  • $requestBody тіло запиту може бути порожнім)
Роботу функції getResponseLocally() я опишу трохи пізніше. Якщо Ви хочете додати логування в режимі розробки, введіть ще одну глобальну змінну $inDevMode і додайте наступний код:

if ($inDevMode) { 
$log = new Logger('guzzle');
$log->pushHandler(new StreamHandler('/tmp/guzzle.log'));
$subscriber = new LogSubscriber($log, Formatter::SHORT);
$client->getEmitter()->attach($subscriber);
}

Надсилання запитів та отримання відповідей
На даному етапі ми готові до відправки запиту. Я спростив для себе алгоритм збереження і не записую код HTTP-відповіді. Якщо він Вам потрібен, нескладно модифікувати код.

$request = $client->createRequest($method, $requestUrl, ['заголовки' => $requestHeaders, 'body' => $requestBody, 'verify' => 'false']);
$output = new stdClass();
try {
$response = $client->send($request, ['timeout' => 2]);
$responseRaw = (string)$response->getBody();
$headers = $response->getHeaders();
} catch (Exception $e) {
$responseRaw = $e->getResponse();
$headers = array();
}
if ($recordTestResults) {
saveResponseLocally($requestUrl, $requestBody, $headers, $responseRaw);
}

  • $method — HTTP request method (GET, POST, PUT etc)
  • $requestHeaders — request headers (if needed)
  • $headers — response headers
  • $responseRaw — raw response (you may get XML, JSON or whatever else as a response but you need to save it before decoding)

Збереження і симуляція відповідей

Локальні копії відповідей можна зберегти у файл або базу даних. Який би варіант Ви не вибрали, необхідно яким-небудь чином однозначно ідентифікувати запит з відповіддю. Я вирішив використовувати для цих цілей MD5-хеш змінних $requestUrl та $requestBody. Масив заголовків паркується в JSON і разом з тілом відповіді зберігається як php-файл, який легко можна довантажити з допомогою require().

function saveResponseLocally ($requestUrl, $requestBody, $headers_source, $response) { 
if (!is_string($requestBody)) { 
$requestBody = print_r($requestBody, true);
}
$filename = md5($requestUrl) . md5($requestBody);
$headers = array();
foreach ($headers_source as $name => $value) {
if (is_array($value)) {
$headers[$name] = $value[0]; // Guzzle returns some header values as 1-element array
} else {
$headers[$name] = $value;
}
}
$response = htmlspecialchars($response, ENT_QUOTES);
$headers_json = json_encode($headers);
$data = "<?\n\$data = array('headers_json' => '$headers_json', \'response' => '$response');";
$requestData = "<?\n\$reqdata = array('url' => '$requestUrl', \'body' => '$requestBody');";
file_put_contents("path_of_your_choice/localResponses/{$filename}.inc", $data);
file_put_contents("path_of_your_choice/localResponses/{$filename}_req.inc", $requestData);
}

Фактично, для подальшої роботи Вам не потрібно створювати і зберігати $requestData. Однак для налагодження дана можливість може бути корисна.

Як я вже згадував, я не зберігаю код відповіді, тому створюю всі відповіді з кодом 200. Якщо Ваша система обробки помилок вимагає конкретного HTTP коду Ви можете легко додати відповідну можливість.
function getResponseLocally ($requestUrl, $requestBody) { 
if (!is_string($requestBody)) {
$requestBody = print_r($requestBody, true);
}
$filename = md5($requestUrl) . md5($requestBody) . '.inc';
if (file_exists("path_of_your_choice/localResponses/{$filename}")) {
require("path_of_your_choice/localResponses/{$filename}");
$data['заголовки'] = (array)json_decode($data['headers_json']);
$mockResponse = new Response(200);
$mockResponse->setHeaders($data['заголовки']);
$separator = "\r\n\r\n";
$bodyParts = explode($separator, htmlspecialchars_decode($data['response']), ENT_QUOTES);
if (count($bodyParts) > 1) {
$mockResponse->setBody(Stream::factory($bodyParts[count($bodyParts) - 1]));
} else {
$mockResponse->setBody(Stream::factory(htmlspecialchars_decode($data['response'])));
}
$mock = new Mock([
$mockResponse
]);
return $mock;
} else {
return false;
}
}

І на закінчення...

Я описав лише один з варіантів симуляції відповідей API і економії часу при юніт-тестування (в моєму випадку тестування з локальними відповідями займає приблизно у 10-20 разів менше часу, ніж «бойові» запити). Guzzle надає ще кілька способів вирішення даної задачі.

Якщо Вам необхідно більш складне тестування, Ви навіть можете створити локальний симулятор API, який буде створювати необхідні Вам відповіді. Який би шлях Ви не вибрали, Ви завжди можете бути впевнені в тому, що збережіть чимало часу і уникнете відправки зайвої кількості запитів до Ваших API партнерам.



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

0 коментарів

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