Юнит-тестирование — одна из неотъемлемых частей процесса разработки, и оно становится сложнее и противоречивее, если основная задача Вашего кода — отправлять запросы ко внешним 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/vendor/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, ['headers' => $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', \n'response' => '$response');";
    $requestData  = "<?\n\$reqdata = array('url' => '$requestUrl', \n'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['headers'] = (array)json_decode($data['headers_json']);
        $mockResponse    = new Response(200);
        $mockResponse->setHeaders($data['headers']);
        $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 партнёрам.

Комментарии (11)


  1. guyfawkes
    23.06.2015 20:27
    +1

    Скажите, а вы пробовали применить для тестирования php-vcr, или он не подошел?


    1. Fesor
      23.06.2015 21:53

      php-vcr интересный проект, но:
      — он бесполезен если разработчики используют TDD/ATDD
      — он еще сыроват

      В целом есть масса вариантов как можно автоматизировать функциональное тестирование API. Допустим неплохой вариант — документация по API в формате api blueprint + инструменты типа dredd.


  1. akubintsev
    24.06.2015 15:23

    Мне кажется, что использовать что-то вроде $isUnitTesting плохая идея в данном случае. Разумнее http-клиент поместить в контейнер зависимостей и затем заменять во время тестирования нужные методы на мокированные. По крайней мере такой подход использовался на практике не раз.
    Суть данной статьи конечно не меняется, но всё же.


    1. Vasiliskov Автор
      25.06.2015 12:20

      Увы, часть кода — легаси со старыми принципами, кучей инклудов, глобалов и т.п. Так что приходится от него зависеть.