Testing API calls in PHP with Guzzle Mocks

I’m working on a CouchDB library for PHP, and so I needed to write some tests for it. CouchDB has an HTTP API so I’m basically making web requests and while I could certainly set up a test database and run full-on integration tests, there are a few limitations with that approach. Firstly: it means I’m testing the database as well, which isn’t what I want and brings extra dependencies that make the tests harder to run. Also: I want to be able to test error cases, rate limiting and so on, that would be difficult to recreate reliably.

With all this in mind, I decided to just mock the CouchDB API for the calls I expected to make to it. Guzzle makes this much easier than I expected and it means that I can test all kinds of edge cases without trying to make contrived sequences of operations. As a side effect, the tests will run a lot faster using mocks than if I were making actual API calls to a remote database!

Testing a GET Request

I started simple! When you make a GET request to CouchDB, it returns a string like this:

{"couchdb":"Welcome","version":"2.0.0","vendor":{"name":"The Apache Software Foundation"}}

The PHP library accepts either a URL or a Guzzle client as arguments to its constructor, and then offers a getVersion() method. We therefore create a mock client in Guzzle, preloaded with the mock responses that it should provide, and give this to the PHP library. The test code ends up looking like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    public function testGetVersion() {
        $db_response = new GuzzleHttp\Psr7\Response(200, [],
             '{"couchdb":"Welcome","version":"2.0.0","vendor":{"name":"The Apache Software Foundation"}}');
        $mock = new GuzzleHttp\Handler\MockHandler([ $db_response, $db_response ]);

        $handler = GuzzleHttp\HandlerStack::create($mock);
        $client = new GuzzleHttp\Client(['handler' => $handler]);

        // userland code starts
        $server = new \PHPCouchDB\Server(["client" => $client]);
        $this->assertEquals("2.0.0", $server->getVersion());
    }

In fact, the library also makes the GET / request to the server to check it can connect, so we need to return the response twice: once when we create the PHPCouchDB/Server object and again when we call getVersion(). To do this, we first create the Response object(s) to return (but this example needs the same response twice) and feed them to the MockHandler. From this, we create a HandlerStack that then becomes the Client to pass in to the library itself.

The last two lines are the code that is really being tested: creating a Server object from a Client, and then seeing that getVersion() does what we expect and that we can recover the value from it.

Complete aside: Setting up with an injectable Guzzle Client was important to me because there are so many possible ways to connect to CouchDB and secure that connection – implementing and testing each one would be time-consuming. This way, users can easily use the well-documented and totally capable Guzzle Client if there is any special setup. The CouchDB library just focuses on the CouchDB stuff!

A Sequence of Requests

When working on this stuff, I found few resources with complete examples so I’ll share one that’s a bit more involved in case it’s useful. When you delete a record from CouchDB, if you omit the version information or there is a never version of a document than the one you are deleting, an error occurs. The error is expected though so we need to write code to expect the error in our test!

This is a pretty involved set of requests that the mock will need:

  1. a simple GET response as above when we connect to the server
  2. some database details in a response when we select which database to use
  3. a successful response when a record is created
  4. we fetch the document after creating it to make sure it looks like we think it should (maybe one request too many here, but it’s there so we’re testing it!)
  5. the response for a failed delete: a 409 response code

The test code builds up this array of responses, sets up the mock, and then runs the userland code to connect to a database and then create and delete a record. We’re expecting the delete to fail so we need to handle it. Ready?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?php
require __DIR__ . "/../../vendor/autoload.php";

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Exception\RequestException;

class DocumentTest extends \PHPUnit\Framework\TestCase
{
    public function setUp() {
        // create the first request to check we can connect, can be added to
        // the mocks for any test that wants it
        $couchdb1 = '{"couchdb":"Welcome","version":"2.0.0","vendor":{"name":"The Apache Software Foundation"}}';
        $this->db_response = new Response(200, [], $couchdb1);

        // offer a use_response for when selecting this database
        $egdb1 = '{"db_name":"egdb","update_seq":"0-g1AAAABXeJzLYWBgYMpgTmEQTM4vTc5ISXLIyU9OzMnILy7JAUklMiTV____PyuRAY-iPBYgydAApP5D1GYBAJmvHGw","sizes":{"file":8488,"external":0,"active":0},"purge_seq":0,"other":{"data_size":0},"doc_del_count":0,"doc_count":0,"disk_size":8488,"disk_format_version":6,"data_size":0,"compact_running":false,"instance_start_time":"0"}';
        $this->use_response = new Response(200, [], $egdb1);

        $create = '{"ok":true,"id":"abcde12345","rev":"1-928ec193918889e122e7ad45cfd88e47"}';
        $this->create_response = new Response(201, [], $create);
        $fetch = '{"_id":"abcde12345","_rev":"1-928ec193918889e122e7ad45cfd88e47","noise":"howl"}';
        $this->fetch_response = new Response(200, [], $fetch);
    }

    /**
     * @expectedException \PHPCouchDB\Exception\DocumentConflictException
     */
    public function testDeleteConflict() {
        $delete = '{"error":"conflict","reason":"Document update conflict."}';
        $delete_response = new Response(409, [], $delete);

        $mock = new MockHandler([ $this->db_response, $this->use_response, $this->create_response, $this->fetch_response, $delete_response ]);
        $handler = HandlerStack::create($mock);
        $client = new Client(['handler' => $handler]);

        // userland code starts
        $server = new \PHPCouchDB\Server(["client" => $client]);
        $database = $server->useDB(["name" => "egdb"]);
        $doc = $database->create(["noise" => "howl", "id" => "abcde12345"]);

        $result = $doc->delete();
    }
}

The interesting bit starts halfway down around line 32, with the test we’re interested in. A bunch of the mocked requests are used by other tests, so they are created in the shared setUp() method, but we need to create the response to the failed DELETE request with a 409 response code.

Line 36 sees the mockhandler, stack and client created with the list of responses outlined above. Basically this allows us to simulate every stage of the library’s operation to get to the point where we can call delete and it fails.

The test function ends with no assertion, which might look strange. In fact the library will detect the failure and throw an exception. In PHPUnit, we use annotations to specify which exception should be thrown by this code so look at line 30 to see that the DocumentConflictException should be triggered during this test. Counterintuitively, if the exception occurs, the test passes – but if everything works brilliantly then the test fails! This makes complete sense when you think about it enough but can be confusing the first time so beware :)

PHP and CouchDB

If you’re interested in using CouchDB, or are already using it and have some time to try out the library and let me know what you like or don’t like, you can find the project including all the tests above on GitHub: https://github.com/ibm-watson-data-lab/php-couchdb

One thought on “Testing API calls in PHP with Guzzle Mocks

  1. Pingback: No REST for Women : API articles written by women : Maverick Tester

Leave a Reply

Please use [code] and [/code] around any source code you wish to share.

This site uses Akismet to reduce spam. Learn how your comment data is processed.