Recently at work I had to create a PHP client library for one of our REST microservices. The client library will be used by many of our projects. I wanted to implement the library using test driven development
I had some trouble finding any advice or examples of client libraries that had be implemented using Test Driven Development where the tests did not actually rely on making calls the service.
I wanted Guzzle to be a dependency of the client library. Doing this would allow me to pass in a mocked Guzzle instance to my PHPSpec tests .This would mean my unit tests would not rely on communicating with API to pass.
However I did not want users of the client library to have to worry about the Guzzle dependency.
After a discussion with some colleagues I decided the best approach was have the client object require Guzzle as a constructor dependency but provide a static factory method for client library users to use for construction without providing the Guzzle dependency.
class PokeClient
{
/**
* @var \GuzzleHttp\ClientInterface
*/
private $httpClient;
/**
* @var \Braddle\PokeApi\Factory\PokemonFactory
*/
private $pokemonFactory;
/**
* PokeClient constructor.
*/
public function __construct(
ClientInterface $httpClient,
PokemonFactory $pokemonFactory
) {
$this->httpClient = $httpClient;
$this->pokemonFactory = $pokemonFactory;
}
/**
* @return Client
*/
public static function create()
{
$httpClient = new Client(
['base_uri' => 'http://pokeapi.co/api/v2/']
);
return new self($httpClient, new PokemonFactory());
}
Any users of the client library now have a very simple way to instantiate the client library.
In this example the base URI is hard coded as the is only one version of the service. If you have different environments to work with you can pass URI or a constant for the environment in to the create() function.
Creating an instance of the PokeClient is now as simple at this
$pokeCLient = PokeClient::create();
This implementation allows me to mock Guzzle calls in my PHPSpec tests.
class PokeClientSpec extends ObjectBehavior
{
function let(Client $httpClient, PokemonFactory $pokemonFactory)
{
$this->beConstructedWith($httpClient, $pokemonFactory);
}
function it_is_initializable()
{
$this->shouldHaveType('Braddle\PokeApi\PokeClient');
}
function it_should_be_able_to_create_an_instance_of_itself()
{
$this::create()->shouldReturnAnInstanceOf(PokeClient::class);
}
function it_should_return_a_pokemon_when_attmepting_to_find_one_by_id(
Client $httpClient,
PokemonFactory $pokemonFactory,
Response $response,
StreamInterface $stream,
Pokemon $pokemon
) {
$pokemonId = 975;
$json = json_encode(
[
'id' => 34,
'name' => '',
'base_experience' => 4,
'height' => 15,
'is_default' => false,
'order' => 1,
'weight' => 468,
]
);
$response->getBody()->willReturn($stream);
$stream->getContents()->willReturn($json);
$httpClient->request('GET', 'pokemon/' . $pokemonId)
->willReturn($response);
$pokemonFactory->createPokemon(Argument::any())
->willReturn($pokemon);
$this->findPokemonById($pokemonId)
->shouldReturnAnInstanceOf(Pokemon::class);
}
}
This method also allowed my to add addition Guzzle options for my Behat integration tests
Feature: Getting Pokemon
Scenario: Ensure that is it possible to find a Pokemon by its ID
Given The client has been instantiated
When I try to find a Pokemon by ID 1
Then I should have been returned a Pokemon
class FeatureContext implements Context, SnippetAcceptingContext
{
/**
* @var PokeClient
*/
private $client;
/**
* @var Pokemon
*/
private $pokemon;
/**
* Initializes context.
*
* Every scenario gets its own context instance.
* You can also pass arbitrary arguments to the
* context constructor through behat.yml.
*/
public function __construct()
{
}
/**
* @Given The client has been instantiated
*/
public function theClientHasBeenInstantiated()
{
$this->client = PokeClient::create();
}
/**
* @When I try to find a Pokemon by ID :id
*/
public function iTryToFindAPokemonById($id)
{
$this->pokemon = $this->client->findPokemonById($id);
}
/**
* @Then I should have been returned a Pokemon
*/
public function iShouldHaveBeenReturnedAPokemon()
{
if (!$this->pokemon instanceof Pokemon) {
throw new \Exception('No Pokemon found');
}
}
}
See the full example client library for the Pokemon API in this GitHub repository.