Back to Home

A little bit about unit testing and external APIs in PHP / ua-hosting.company's blog

unit tests · guzzle · oauth

A little bit about unit testing and external APIs in PHP

    Unit testing is one of the integral parts of the development process, and it becomes more complicated and contradictory if the main task of your code is to send requests to external APIs and process responses. A lot of copies are broken about the topic of what testing should be for code tied to external sources, and where the line between testing your own code and other people's APIs goes.

    At this point, developers have to decide which requests to send to the remote server and which to simulate locally. There are many solutions for both sending requests and for simulating them. In my post I will tell you how to make both of them based on the Guzzle HTTP client.




    A few words about the product. Guzzle- Extensible HTTP client for PHP. It is under active development. Over the past year, two older versions. Version 4.0.0 was released in March 2014, and May 2015 brought the release of version 6.0.0. The transition between them can cause certain difficulties, because the developers in each release change the namespace and some principles of work.

    In addition, it may be difficult to combine various manuals, even recently written ones. However, if you thoroughly google and read the native documentation, you can finally find an acceptable solution.

    Installation


    Guzzle installs as a Composer package . The composer.json installation file for our needs is as follows:

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

    For some of our tasks, it is necessary to use 3-step OAuth authentication, so I had to stop on version Guzzle 5.3. At the time of writing, this is the latest version supporting the oauth-subsctiber plugin. However, if you do not need OAuth, you can try to adapt the solution for version 6. *. Naturally, check the documentation first.

    First steps


    First of all, you need to include the Composer package startup file:

    require_once "path_to_composer_files/vendor/autoload.php";
    

    In addition, we need to specify which namespaces will be used by our scripts. In my case, different files are responsible for sending requests and for saving the results. If necessary, you can combine them.

    Request Submission and Logging

    // Сам клиент
    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;
    

    Saving and simulating

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

    Request Submission


    To determine the current operating mode, we use two global variables:
    • $ isUnitTest determines whether the system is operating normally or in automatic testing mode;
    • $ recordTestResults tells the system to save all request and response data.

    OAuth Procedures

    Guzzle allows you to use the same code for OAuth authorization itself (according to different schemes), and for sending signed requests.

        $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);
    

    The constants OAUTH_CONSUMER_KEY and OAUTH_CONSUMER_SECRET are the key pair provided by your API provider. Depending on the current authorization status, a token and a secret key to it may be required. For more information about OAuth, you can refer to the relevant sources (for example, OAuth Bible ).

    HTTP client initialization

    At this step, we determine whether we need to send a real request or receive a locally stored response.

        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 - the base path of your API
    • 'defaults' => ['auth' => 'oauth'] is only necessary if you are sending OAuth requests. The same is true for $ client-> getEmitter () -> attach ($ oauth);
    • $ requestUrl - full request path (including base path)
    • $ requestBody request body (may be empty)

    The function getResponseLocally () will be described later. If you want to add logging in development mode, enter another global variable $ inDevMode and add the following code:

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

    Submitting requests and receiving responses

    At this stage, we are ready to send a request. I have simplified the save algorithm for myself and do not write the HTTP response code. If you need it, it's easy to modify the code.

        $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)

    Saving and simulating responses


    Local copies of responses can be saved to files or a database. Whichever option you choose, you must somehow unambiguously correlate the request with the response. I decided to use the MD5 hashes of the variables $ requestUrl and $ requestBody for these purposes . The array of headers is parked in JSON and, together with the response body, is saved as a php file, which can easily be loaded using 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         = " '$headers_json', \n'response' => '$response');";
        $requestData  = " '$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);
    }
    

    In fact, for further work you do not need to create and save $ requestData . However, this feature may be useful for debugging.

    As I mentioned, I do not save the response code, so I create all the answers with the code 200. If your error handling system requires a specific HTTP code, you can easily add the corresponding feature.
    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;
        }
    }
    

    In conclusion...


    I described only one of the options for simulating API responses and saving time during unit testing (in my case, testing with local answers takes about 10-20 times less time than “combat” requests). Guzzle provides a couple more ways to solve this problem.

    If you need more sophisticated testing, you can even create a local API simulator that will create the answers you need. Whatever path you choose, you can always be sure that you save a lot of time and avoid sending too many requests to your API partners.

    Read Next