PHP
Article

Building a SparkPost Client: TDD with PhpUnit and Mockery

By Christopher Pitt

In a previous post, we looked at SparkPost (as an alternative to Mandrill), and explored a bit of the official PHP client. The official client handles a decent amount of work, but I got to thinking about what it would take to build a new client.

The more I thought about it, the more it made sense. I could learn about the SparkPost API, and practice Test Driven Development at the same time. So, in this post we’ll look to do just that!

Home page screenshot

You can find the code for this post on Github.

Getting Started

To begin, we’re going to need Guzzle to make requests to the SparkPost API. We can install it with:

composer require guzzlehttp/guzzle

In addition, we’re going to be writing tests early, so we should also install PHPUnit and Mockery:

composer require --dev phpunit/phpunit mockery/mockery

Before we can run PHPUnit, we need to create a configuration file:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    backupGlobals="false"
    backupStaticAttributes="false"
    bootstrap="vendor/autoload.php"
    colors="true"
    convertErrorsToExceptions="true"
    convertNoticesToExceptions="true"
    convertWarningsToExceptions="false"
    processIsolation="false"
    stopOnFailure="false"
    syntaxCheck="false">
    <testsuites>
        <testsuite>
            <directory suffix="Test.php">tests</directory>
        </testsuite>
    </testsuites>
    <filter>
        <whitelist addUncoveredFilesFromWhitelist="true">
            <directory suffix=".php">src</directory>
        </whitelist>
    </filter>
</phpunit>

This configuration file handles a number of things:

  1. Many of the root node attributes are sensible, intuitive defaults. The one I want to draw particular attention to is bootstrap: which tells PHPUnit to load Composer’s autoload code.
  2. We tell PHPUnit to load all files ending in Test.php, in the tests folder. It will treat all files with this suffix as though they are class files with a single class each. If it can’t instantiate any of the classes it finds (like abstract classes) then it will just ignore those.
  3. We tell PHPUnit to add all PHP files (from the src folder) to code coverage reporting. If you’re unsure what that is, don’t worry. We’ll look at it in a bit…

We can now run:

vendor/bin/phpunit

… and we should see:

New phpunit screen

Designing The Interface

One of the things I love most about Test Driven Development is how it pushes me towards interfaces that are minimalistic and friendly. I start as the consumer, and end up with an implementation that is the easiest for me to use.

Let’s make our first test. The simplest thing we can do is send an email through the SparkPost API. If you check out the docs, you’ll find that this happens through a POST request to https://api.sparkpost.com/api/v1/transmissions, with a JSON body and some key headers. We can simulate this with the following code:

require "vendor/autoload.php";

$client = new GuzzleHttp\Client();

$method = "POST";
$endpoint = "https://api.sparkpost.com/api/v1/transmissions";
$config = require "config.php";

$response = $client->request($method, $endpoint, [
    "headers" => [
        "Content-type" => "application/json",
        "Authorization" => $config["key"],
    ],
    "body" => json_encode([
        "recipients" => [
            [
                "address" => [
                    "name" => "Christopher",
                    "email" => "cgpitt@gmail.com",
                ],
            ],
        ],
        "content" => [
            "from" => "sandbox@sparkpostbox.com",
            "subject" => "The email subject",
            "html" => "The email <strong>content</strong>",
        ],
    ]),
]);

You can use the same from/recipients details as in the previous post.

This assumes you have a config.php file, which stores your SparkPost API key:

return [
    "key" => "[your SparkPost API key here]",
];

Be sure to add this file to .gitignore so you don’t accidentally commit your SparkPost API key to Github.

Given the rough details of implementation, we can start to make some assumptions (or define some requirements) for the public interface:

  1. We don’t want to repeat https://api.sparkpost.com/api/v1 for every request. We could accept it as a constructor argument.
  2. We don’t want to know the specific request methods or endpoints. The client should work that stuff out and adjust itself for new versions of the API.
  3. We may want to define some or all of the endpoint parameters, but there should be some sensible defaults and a way for the client to rearrange them.

Starting With Tests

Since we want to use Mockery, it would be a good idea for us to make a central, base test class, to close the mocks:

namespace SparkPost\Api\Test;

use Mockery;
use PHPUnit_Framework_TestCase;

abstract class AbstractTest extends PHPUnit_Framework_TestCase
{
    /**
     * @inheritdoc
     */
    public function tearDown()
    {
        Mockery::close();
    }
}

We can use this as the base class for our other tests, and PHPUnit won’t try to instantiate it since it is abstract. The underlying reasons for closing Mockery are a bit out of scope for this post. It’s just something you need to know to do with Mockery (and similar libraries).

Now we can begin to define what we want the client interface to look like:

namespace SparkPost\Api\Test;

use Mockery;
use Mockery\MockInterface;
use GuzzleHttp\Client as GuzzleClient;

class ClientTest extends AbstractTest
{
    /**
     * @test
     */
    public function itCreatesTransmissions()
    {
        /** @var MockInterface|GuzzleClient $mock */
        $mock = Mockery::mock(GuzzleClient::class);

        // ...what do we want to test here?
    }
}

The idea behind Mockery is that it allows us to simulate classes we don’t want to test, that are required by those we do want to test. In this case, we don’t want to write tests for Guzzle (or even make real HTTP requests with it) so we create a mock of the Guzzle\Client class. We can tell the mock to expect a call to the request method, with a request method string, an endpoint string and an array of options:

$mock
    ->shouldReceive("request")
    ->with(
        Mockery::type("string"),
        Mockery::type("string"),
        $sendThroughGuzzle
    )
    ->andReturn($mock);

We don’t really care which request method is sent to Guzzle, or which endpoint. Those might change as the SparkPost API changes, but the important thing is that strings are sent for these arguments. What we are really interested in is seeing is whether the parameters we send to the client are formatted correctly.

Let’s say we wanted to test the following data:

$sendThroughGuzzle = [
    "headers" => [
        "Content-type" => "application/json",
        "Authorization" => "[fake SparkPost API key here]",
    ],
    "body" => json_encode([
        "recipients" => [
            [
                "address" => [
                    "name" => "Christopher",
                    "email" => "cgpitt@gmail.com",
                ],
            ],
        ],
        "content" => [
            "from" => "sandbox@sparkpostbox.com",
            "subject" => "The email subject",
            "html" => "The email <strong>content</strong>",
        ],
    ]),
];

…but we only wanted that data to go through if we called the following method:

$client = new SparkPost\Api\Client(
    $mock, "[fake SparkPost API key here]"
);

$client->createTransmission([
    "recipients" => [
        [
            "address" => [
                "name" => "Christopher",
                "email" => "cgpitt@gmail.com",
            ],
        ],
    ],
    "subject" => "The email subject",
    "html" => "The email <strong>content</strong>",
]);

Then we would probably need to make the rest of the test work with this structure in mind. Let’s assume we’re only interested in the json_decode data returned from SparkPost…

We could imagine using the client like:

  1. We call $client->createTransmission(...).
  2. The client formats those parameters according to how SparkPost wants them.
  3. The client sends an authenticated request to SparkPost.
  4. The response is sent through json_decode, and just gets an array of response data.

We can build these steps into the test:

public function itCreatesTransmissions()
{
    /** @var MockInterface|GuzzleClient $mock */
    $mock = Mockery::mock(GuzzleClient::class);

    $sendThroughGuzzle = [
        "headers" => [
            "Content-type" => "application/json",
            "Authorization" => "[fake SparkPost API key here]",
        ],
        "body" => json_encode([
            "recipients" => [
                [
                    "address" => [
                        "name" => "Christopher",
                        "email" => "cgpitt@gmail.com",
                    ],
                ],
            ],
            "content" => [
                "from" => "sandbox@sparkpostbox.com",
                "subject" => "The email subject",
                "html" => "The email <strong>content</strong>",
            ],
        ]),
    ];

    $mock
        ->shouldReceive("request")
        ->with(
            Mockery::type("string"),
            Mockery::type("string"),
            $sendThroughGuzzle
        )
        ->andReturn($mock);

    $mock
        ->shouldReceive("getBody")
        ->andReturn(
            json_encode(["foo" => "bar"])
        );

    $client = new Client(
        $mock, "[fake SparkPost API key here]"
    );

    $this->assertEquals(
        ["foo" => "bar"],
        $client->createTransmission([
            "recipients" => [
                [
                    "address" => [
                        "name" => "Christopher",
                        "email" => "cgpitt@gmail.com",
                    ],
                ],
            ],
            "subject" => "The email subject",
            "html" => "The email <strong>content</strong>",
        ])
    );
}

Before we can run PHPUnit to see this test in action, we need to add the autoload directives for our library, to composer.json:

"autoload": {
    "psr-4": {
        "SparkPost\\Api\\": "src"
    }
},
"autoload-dev": {
    "psr-4": {
        "SparkPost\\Api\\Test\\": "tests"
    }
}

You’ll probably also have to run composer du to get these to work…

Implementing The Interface

The moment we run PHPUnit, we’ll see all sorts of errors. We’ve not yet made the classes, let alone implemented their behavior. Let’s make the Client class:

<?php

namespace SparkPost\Api;

use GuzzleHttp\Client as GuzzleClient;

class Client
{
    /**
     * @var string
     */
    private $base = "https://api.sparkpost.com/api/v1";

    /**
     * @var GuzzleClient
     */
    private $client;

    /**
     * @var string
     */
    private $key;

    /**
     * @param GuzzleClient $client
     * @param string $key
     */
    public function __construct(GuzzleClient $client, $key)
    {
        $this->client = $client;
        $this->key = $key;
    }

    /**
     * @param array $options
     *
     * @return array
     */
    public function createTransmission(array $options = [])
    {
        $options = array_replace_recursive([
            "from" => "sandbox@sparkpostbox.com",
        ], $options);

        $send = [
            "recipients" => $options["recipients"],
            "content" => [
                "from" => $options["from"],
                "subject" => $options["subject"],
                "html" => $options["html"],
            ]
        ];

        return $this->request("POST", "transmissions", $send);
    }

    /**
     * @param string $method
     * @param string $endpoint
     * @param array $options
     *
     * @return array
     */
    private function request(
        $method, $endpoint, array $options = []
    )
    {
        // ...time to forward the request
    }
}

We’re making progress! When we run PHPUnit we should see something like:

Invalid return type error

Let’s finish up by forwarding the requests to SparkPost:

private function request(
    $method, $endpoint, array $options = []
)
{
    $endpoint = $this->base . "/" . $endpoint;

    $response = $this->client->request($method, endpoint, [
        "headers" => [
            "Content-type" => "application/json",
            "Authorization" => $this->key,
        ],
        "body" => json_encode($options),
    ]);

    return json_decode($response->getBody(), true);
}

With this, the tests will be passing.

Passing tests

It’s perhaps also helpful for us to see how this can be consumed without any mocking:

require "vendor/autoload.php";

$config = require "config.php";

$client = new SparkPost\Api\Client(
    new GuzzleHttp\Client(), $config["key"]
);

$reponse = $client->createTransmission([
    "recipients" => [
        [
            "address" => [
                "name" => "Christopher",
                "email" => "cgpitt@gmail.com",
            ],
        ],
    ],
    "subject" => "The email subject",
    "html" => "The email <strong>content</strong>",
]);

Coverage

Remember I mentioned code coverage reporting? We can run PHPUnit in the following way:

vendor/bin/phpunit --coverage-html coverage

This will generate a folder of HTML files. You can open coverage/index.html to get an idea of how much of your library is covered by your tests. You’ll need to have the XDebug extension installed, and running larger tests suites with this parameter may be slower than without.

Coverage report

Things To Consider

  1. We didn’t validate the parameters for createTransmission. That would be a good thing to do.

  2. We’re very tightly coupled to Guzzle. I think that’s ok, but if you have reservations, rather create a “client” interface and a Guzzle adapter, or even better, take the Httplug approach.

  3. There is a lot more to the SparkPost API, and there are some interesting paths you can take when designing such an interface. Here’s a recent screencast I did which explores an expressive syntax on top of the client we’ve built today…

How are you finding SparkPost? Do you like this sort of development flow? Let us know in the comments below.

  • Carl Weis

    Awesome Article. Thank You.

  • Ali Adab

    gfg

Recommended

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!

Get the latest in PHP, once a week, for free.