2010-09-28

Unit Testing cURL Code in PHP

My team is writing an API that will be used from other parts of our infrastructure. We're using cURL to make connections from the other parts to the API. But sometimes our network and API server are a bit flaky, which makes code that calls the API brittle if we don't handle those transient errors.

Instead of using PHP's native curl_* functions, we've created a simple class to wrap them. This helps some of the more junior team members as well, hiding some of the nitty-gritty details of making HTTP connections:


/**
 * Class abstracting the cURL library for easier use.
 *
 * Usage:
 *     $curl = new Curl();
 *     $curl->setUrl('http://www.google.com/#')
 *         ->setData('&q=testing+curl')
 *         ->setType('GET');
 *     $curl->send();
 *     echo $curl->getStatusCode(), PHP_EOL;
 *     echo $curl->getResponse(), PHP_EOL;
 */
class Curl {
    /**
     * @var string Body returned by the last request.
     */
    protected $body;

    /**
     * @var resource Actual CURL connection handle.
     */
    protected $ch;

    /**
     * @var mixed Data to send to server.
     */
    protected  $data;

    /**
     * @var integer Response code from the last request.
     */
    protected $status;

    /**
     * @var string Request type.
     */
    protected $type;

    /**
     * @var string Url for the connection.
     */
    protected $url;


    /**
     * Constructor.
     */
    public function __construct() {
        $this->body = null;
        $this->ch = curl_init();
        $this->data = null;
        $this->status = null;
        $this->type = 'GET';
        $this->url = null;
        curl_setopt($this->ch, CURLOPT_RETURNTRANSFER,
            true);
        curl_setopt($this->ch, CURLOPT_USERAGENT,
            'Curl Client');
    }

    /**
     * Return the body returned by the last request.
     * @return string
     */
    public function getBody() {
        return $this->body;
    }

    /**
     * Return the current payload.
     * @return mixed
     */
    public function getData() {
        return $this->data;
    }

    /**
     * Set the payload for the request.
     *
     * This can either by a string, formatted like a query
     * string:
     *      foo=bar&mitz=fah
     * or a single-dimensional array:
     *      array('foo' => 'bar', 'mitz' => 'fah')
     * @param mixed $data
     * @return Curl
     */
    public function setData($data) {
        if (is_array($data)) {
            $data = http_build_query($data);
        }
        $this->data = $data;
        return $this;
    }

    /**
     * Return the status code for the last request.
     * @return integer
     */
    public function getStatusCode() {
        return $this->status;
    }

    /**
     * Return the current type of request.
     * @return string
     */
    public function getType() {
        return $this->type;
    }

    /**
     * Set the type of request to make (GET, POST, PUT,
     * DELETE, etc)
     * @param string $type Request type to send.
     * @return Curl
     */
    public function setType($type) {
        $this->type = $type;
        curl_setopt($this->ch, CURLOPT_CUSTOMREQUEST,
            $type);
        return $this;
    }

    /**
     * Return the connection's URL.
     * @return string
     */
    public function getUrl() {
        return $this->url;
    }

    /**
     * Set the URL to make an HTTP connection to.
     * @param string $url URL to connect to.
     * @return Curl
     */
    public function setUrl($url) {
        $this->url = $url;
        curl_setopt($this->ch, CURLOPT_URL, $url);
        return $this;
    }

    /**
     * Send the request.
     * @return Curl|null
     */
    public function send() {
        if (!$this->url) {
            return null;
        }
        if ('GET' == $this->type) {
            $this->url .= '?' . $this->data;
        } else {
            curl_setopt($this->ch, CURLOPT_POSTFIELDS,
                $this->data);
            if ('PUT' == $this->type) {
                $header = 'Content-Length: '
                    . strlen($this->data);
                curl_setopt($this->ch, CURLOPT_HTTPHEADER,
                    array($header));
            }
        }
        $this->body = curl_exec($this->ch);
        $this->status = curl_getinfo($this->ch,
            CURLINFO_HTTP_CODE);
        return $this;
    }
}



First, here's an example class that calls to the API to pull a customer record. Assume the API returns a JSON object when called with http://api/customer/42. We pass in a Curl object to actually make the connection, then parse the response to fill our customer object's members.


class Customer {
    /**
     * @var integer Database ID.
     */
    public $id;

    /**
     * @var string Customer's name.
     */
    public $name;

    /**
     * Load a customer from the API.
     * @param integer $id ID of the customer to load.
     * @param Curl $curl Curl object.
     */
    public function load($id, Curl $curl) {
        $curl->setUrl('http://api/customer/' . (int)$id)
            ->send();
        $customer = json_decode($curl->getResponse());
        $this->id = $customer->id;
        $this->name = $customer->name;
    }
}


In a perfect world, calling load() will always populate the id and name members of the customer. In the real world, the API server could be down, or the network could die, or random alpha particles could mess up the request, so the code should handle those error messages. But setting up a test network to create those conditions is expensive and probably a waste of time, so we'll create a stub for the Curl object that allows us to break the API calls in interesting ways.


/**
 * Stub for the Curl class.
 *
 * Allows testing code that uses cURL without actually
 * making any HTTP connections.
 */
class StubCurl
extends Curl {
    /**
     * Constructor.
     */
    public function __construct() {
        $this->body = null;
        $this->data = null;
        $this->status = null;
        $this->type = 'GET';
        $this->url = null;
    }

    /**
     * Set the response 'returned' by the server.
     * @param string $body
     * @return StubCurl
     */
    public function setBody($body) {
        $this->body = $body;
    }

    /**
     * Set the HTTP status 'returned' by the server.
     * @param integer $code
     * @return StubCurl
     */
    public function setStatusCode($status) {
        $this->status = $status;
    }

    /**
     * Set the request method (GET, POST, etc).
     * @param string $type
     * @return StubCurl
     */
    public function setType($type) {
        $this->type = $type;
        return $this;
    }

    /**
     * Set the URL to connect to.
     * @param string $url
     * @return StubCurl
     */
    public function setUrl($url) {
        $this->url = $url;
        return $this;
    }

    /**
     * 'Send' the cURL request.
     *
     * Obviously doesn't actually send any requests.
     * @return StubCurl
     */
    public function send() {
        if (!$this->url) {
            return null;
        }
        if ('GET' == $this->type) {
            $this->url .= '?' . $this->data;
        }
        return $this;
    }
}


By passing a StubCurl object in to Customer::Load(), we can make the server return any status (or no status at all):


require_once 'Curl.php';
require_once 'Customer.php';
require_once 'StubCurl.php';

class CustomerTest
extends PHPUnit_Framework_TestCase {
    private $customer;

    protected function setUp() {
        $this->customer = new Customer();
    }

    /**
     * Test loading with a good API connection.
     * @covers Customer::load
     * @test
     */
    public function testLoadFound() {
        $curl = new StubCurl();
        $curl->setStatusCode(200)
            ->setBody('{"id":42,"name":"Bob King"}');
        $this->customer->load(42, $curl);
        $this->assertEquals('Bob King',
            $this->customer->name);
    }

    /**
     * Test loading a customer that isn't found.
     * Add a test for whatever behavior you want the
     * customer object to take if the API returns a 404
     * Not Found error. In this case, we're going to want
     * the customer object to throw an exception.
     * @covers Customer::load
     * @expectedException NotFoundException
     * @test
     */
    public function testLoadNotFound() {
        $curl = new StubCurl();
        $curl->setStatusCode(404);
        $this->customer->load(42, $curl);
    }

    /**
     * Test loading a customer from bad JSON.
     * The API returns a garbled response body.
     * @covers Customer::load
     * @expectedException BadResponseException
     * @test
     */
    public function testBadResponse() {
        $curl = new StubCurl();
        $curl->setStatusCode(200)
            ->setBody('{"id":_*()*(*$#@#$');
        $this->customer->load(42, $curl);
    }
}


Then the Customer class needs to have its load() method beefed up to handle the errors:


/**
 * Load a customer from the API.
 * @param integer $id ID of the customer to load.
 * @param Curl $curl Curl object.
 * @throws BadResponseException
 * @throws NotFoundException
 */
public function load($id, Curl $curl) {
    $curl->setUrl('http://api/customer/' . (int)$id)
        ->send();
    if (404 == $curl->getStatusCode()) {
        throw new NotFoundException();
    }
    $customer = json_decode($curl->getResponse());
    if (null === $customer) {
        throw new BadResponseException();
    }
    $this->id = $customer->id;
    $this->name = $customer->name;
}


Obviously you'll want to handle more errors in a production environment, maybe logging bad responses and server errors for later analysis.

No comments:

Post a Comment