Dec 07, 2016
A subtle introduction to Mocking (2)
6 min read
In my previous post about Mocking - which i think you should checkout -, I talked about building (and testing) a Github sample app. One that fetches users' repositories and profile. This isn't a full featured app by any means but it would be super useful for our purpose here - mocking.
The Github App
The code for this has been put on Github.
Users Story
As a user of this app, i want to
- Search for a user on github and get his profile.
- Find all repositories belonging to a specific user.
Cool ? That's all we need to implement. Fairly easy.
Since we would need to hit the github api to fulfill this stories, we would be needing a sort of HttpClient
.
For this we would be using Guzzle - which is kind of the industry standard in PHP.
You should cd
to some directory and create a composer.json
file with the following content ;
{
"require": {
"guzzlehttp/guzzle": "^6.2"
},
"require-dev": {
"mockery/mockery": "^0.9.6",
"phpunit/phpunit": "^5.6"
},
"autoload": {
"psr-4": {
"Adelowo\\Github\\": "src/"
}
}
}
$composer install
Great, we have our testing tools and an HttpClient
. All we have to do is create an object that interacts with Github
's api using Guzzle.
<?php
namespace Adelowo\Github;
use GuzzleHttp\Client;
use function GuzzleHttp\json_decode;
class GithubClient
{
const BASE_API_LINK = "https://api.github.com/";
protected $httpClient;
public function __construct(Client $client)
{
$this->httpClient = $client;
}
public function getUserProfile(string $userName)
{
$response = $this->get("users/{$userName}");
if (200 !== $response->getStatusCode()) {
throw $this->throwInvalidResponseException();
}
return json_decode($response->getBody(), true);
}
protected function get(string $relativeUrl)
{
return $this->httpClient->get(self::BASE_API_LINK . $relativeUrl);
}
protected function throwInvalidResponseException()
{
return new InvalidResponseException(
InvalidResponseException::MESSAGE
);
}
public function getUserRepositories(string $userName)
{
$response = $this->get("users/{$userName}/repos");
if (200 !== $response->getStatusCode()) {
throw $this->throwInvalidResponseException();
}
return json_decode($response->getBody(), true);
}
}
This object is quite easy to follow. Our GithubClient
object has a dependency on GuzzleHttp\Client
.
We only have two public method apis - getUserProfile
and getUserRepositories
-. Their communication with the Github api has been moved to a single
method - get(string $relativeUrl)
- in other to prevent duplication.
Fairly straight forward.
The PSR-7 standard is actually a nice way to understanding how Guzzle was implemented.
How about we test this ? Since this is going to be a lot to take in, i would only show a test per block code.
The most interesting part here are the
setUp
,getGithubClient
methods. They are the main places that shows how to test code that requires an internet connection.
<?php
namespace Adelowo\Github\Tests;
use Adelowo\Github\GithubClient;
use Adelowo\Github\InvalidResponseException;
use GuzzleHttp\Client;
use Mockery;
use Psr\Http\Message\ResponseInterface;
class GithubClientTest extends \PHPUnit_Framework_TestCase
{
protected $httpClient;
protected $response;
public function setUp()
{
$this->httpClient = Mockery::mock(Client::class)->makePartial();
$this->response = Mockery::mock(ResponseInterface::class)->makePartial();
$this->httpClient->shouldReceive('get') //get is actually a method we called in GithubClient
->once()
->andReturn($this->response);
}
public function tearDown()
{
Mockery::close();
}
protected function getGithubClient()
{
return new GithubClient($this->httpClient); //give our client object the mock
}
}
How about fulfilling user story no 1.
<?php
//GithubClientTest
/**
* @dataProvider getUserProfile
*/
public function testUserProfileWasFetchedSuccessfully($response)
{
$this->response->shouldReceive('getStatusCode')
->once()
->withNoArgs()
->andReturn(200);
$this->response->shouldReceive('getBody')
->once()
->withNoArgs()
->andReturn(\GuzzleHttp\json_encode($response));
$userProfile = $this->getGithubClient()->getUserProfile('fabpot');
$this->assertJsonStringEqualsJsonString(
\GuzzleHttp\json_encode($response),
\GuzzleHttp\json_encode($userProfile)
);
}
public function getUserProfile()
{
//Let's fake the result since we are not going to hit the api
return [
[
"login" => "fabpot",
"id" => 47313,
"avatar_url" => "https://avatars.githubusercontent.com/u/47313?v=3",
"gravatar_id" => "",
"url" => "https://api.github.com/users/fabpot",
"html_url" => "https://github.com/fabpot",
"followers_url" => "https://api.github.com/users/fabpot/followers",
"following_url" => "https://api.github.com/users/fabpot/following{/other_user}",
"gists_url" => "https://api.github.com/users/fabpot/gists{/gist_id}",
"starred_url" => "https://api.github.com/users/fabpot/starred{/owner}{/repo}",
"subscriptions_url" => "https://api.github.com/users/fabpot/subscriptions",
"organizations_url" => "https://api.github.com/users/fabpot/orgs",
"repos_url" => "https://api.github.com/users/fabpot/repos",
"events_url" => "https://api.github.com/users/fabpot/events{/privacy}",
"received_events_url" => "https://api.github.com/users/fabpot/received_events",
"type" => "User",
"site_admin" => false,
"name" => "Fabien Potencier",
"company" => "SensioLabs",
"blog" => "http://fabien.potencier.org/",
"location" => "San Francisco",
"email" => "[email protected]",
"hireable" => true,
"bio" => null,
"public_repos" => 19,
"public_gists" => 8,
"followers" => 6505,
"following" => 0,
"created_at" => "2009-01-17T13:42:51Z",
"updated_at" => "2016-11-30T09:52:54Z"
]
];
}
With this, we have fulfilled the first user story. Let's move to the next one i.e for repositories.
<?php
public function testAllRepositoriesOwnedByAUserWasFetchedCorrectly()
{
$response = $this->getUserRepos();
$this->response->shouldReceive('getStatusCode')
->once()
->withNoArgs()
->andReturn(200);
$this->response->shouldReceive('getBody')
->once()
->withNoArgs()
->andReturn(\GuzzleHttp\json_encode($response));
$userRepos = $this->getGithubClient()->getUserRepositories("adelowo");
$this->assertJsonStringEqualsJsonString(
\GuzzleHttp\json_encode($response),
\GuzzleHttp\json_encode($userRepos)
);
}
protected function getUserRepos()
{
return [
[
"id" => 73918229,
"name" => "address-bok",
"full_name" => "adelowo/address-bok",
"owner" => [
"login" => "adelowo",
"id" => 12677701,
"avatar_url" => "https://avatars.githubusercontent.com/u/12677701?v=3",
"gravatar_id" => "",
"url" => "https://api.github.com/users/adelowo",
"html_url" => "https://github.com/adelowo",
"followers_url" => "https://api.github.com/users/adelowo/followers",
"following_url" => "https://api.github.com/users/adelowo/following{/other_user}",
"gists_url" => "https://api.github.com/users/adelowo/gists{/gist_id}",
"starred_url" => "https://api.github.com/users/adelowo/starred{/owner}{/repo}",
"subscriptions_url" => "https://api.github.com/users/adelowo/subscriptions",
"organizations_url" => "https://api.github.com/users/adelowo/orgs",
"repos_url" => "https://api.github.com/users/adelowo/repos",
"events_url" => "https://api.github.com/users/adelowo/events{/privacy}",
"received_events_url" => "https://api.github.com/users/adelowo/received_events",
"type" => "User",
"site_admin" => false
],
"private" => false,
"html_url" => "https://github.com/adelowo/address-bok",
"description" => "Some Sample project",
"fork" => false,
"url" => "https://api.github.com/repos/adelowo/address-bok",
"forks_url" => "https://api.github.com/repos/adelowo/address-bok/forks",
"keys_url" => "https://api.github.com/repos/adelowo/address-bok/keys{/key_id}",
"collaborators_url" => "https://api.github.com/repos/adelowo/address-bok/collaborators{/collaborator}",
"teams_url" => "https://api.github.com/repos/adelowo/address-bok/teams",
"hooks_url" => "https://api.github.com/repos/adelowo/address-bok/hooks",
"issue_events_url" => "https://api.github.com/repos/adelowo/address-bok/issues/events{/number}",
"events_url" => "https://api.github.com/repos/adelowo/address-bok/events",
"assignees_url" => "https://api.github.com/repos/adelowo/address-bok/assignees{/user}",
"branches_url" => "https://api.github.com/repos/adelowo/address-bok/branches{/branch}",
"tags_url" => "https://api.github.com/repos/adelowo/address-bok/tags",
"blobs_url" => "https://api.github.com/repos/adelowo/address-bok/git/blobs{/sha}",
"git_tags_url" => "https://api.github.com/repos/adelowo/address-bok/git/tags{/sha}",
"git_refs_url" => "https://api.github.com/repos/adelowo/address-bok/git/refs{/sha}",
"trees_url" => "https://api.github.com/repos/adelowo/address-bok/git/trees{/sha}",
"statuses_url" => "https://api.github.com/repos/adelowo/address-bok/statuses/{sha}",
"languages_url" => "https://api.github.com/repos/adelowo/address-bok/languages",
"stargazers_url" => "https://api.github.com/repos/adelowo/address-bok/stargazers",
"contributors_url" => "https://api.github.com/repos/adelowo/address-bok/contributors",
"subscribers_url" => "https://api.github.com/repos/adelowo/address-bok/subscribers",
"subscription_url" => "https://api.github.com/repos/adelowo/address-bok/subscription",
"commits_url" => "https://api.github.com/repos/adelowo/address-bok/commits{/sha}",
"git_commits_url" => "https://api.github.com/repos/adelowo/address-bok/git/commits{/sha}",
"comments_url" => "https://api.github.com/repos/adelowo/address-bok/comments{/number}",
"issue_comment_url" => "https://api.github.com/repos/adelowo/address-bok/issues/comments{/number}",
"contents_url" => "https://api.github.com/repos/adelowo/address-bok/contents/{+path}",
"compare_url" => "https://api.github.com/repos/adelowo/address-bok/compare/{base}...{head}",
"merges_url" => "https://api.github.com/repos/adelowo/address-bok/merges",
"archive_url" => "https://api.github.com/repos/adelowo/address-bok/{archive_format}{/ref}",
"downloads_url" => "https://api.github.com/repos/adelowo/address-bok/downloads",
"issues_url" => "https://api.github.com/repos/adelowo/address-bok/issues{/number}",
"pulls_url" => "https://api.github.com/repos/adelowo/address-bok/pulls{/number}",
"milestones_url" => "https://api.github.com/repos/adelowo/address-bok/milestones{/number}",
"notifications_url" => "https://api.github.com/repos/adelowo/address-bok/notifications{?since,all,participating}",
"labels_url" => "https://api.github.com/repos/adelowo/address-bok/labels{/name}",
"releases_url" => "https://api.github.com/repos/adelowo/address-bok/releases{/id}",
"deployments_url" => "https://api.github.com/repos/adelowo/address-bok/deployments",
"created_at" => "2016-11-16T12:30:10Z",
"updated_at" => "2016-11-23T14:52:23Z",
"pushed_at" => "2016-11-23T14:53:45Z",
"git_url" => "git://github.com/adelowo/address-bok.git",
"ssh_url" => "[email protected]:adelowo/address-bok.git",
"clone_url" => "https://github.com/adelowo/address-bok.git",
"svn_url" => "https://github.com/adelowo/address-bok",
"homepage" => "",
"size" => 59,
"stargazers_count" => 1,
"watchers_count" => 1,
"language" => "PHP",
"has_issues" => true,
"has_downloads" => true,
"has_wiki" => true,
"has_pages" => false,
"forks_count" => 0,
"mirror_url" => null,
"open_issues_count" => 0,
"forks" => 0,
"open_issues" => 0,
"watchers" => 1,
"default_branch" => "master"
]
];
}
Running phpunit
should make us green without touching the internet.
Like i said in the previous post, mocking is a big deal. Learning to use it has changed the way i write my tests and even increased my coverage - even though coverage isn't always a measure of quality.
Alternate Ending
But we have a problem. Tests should also cover extreme edge cases right ?. For example in our GithubClient
object, we only care if the HTTP
status code is 200
- anything other than that would be considered an invalid response.
<?php
//GithubClient.php
public function getUserProfile(string $userName)
{
$response = $this->get("users/{$userName}");
if (200 !== $response->getStatusCode()) { //Hey, look here!!!
throw $this->throwInvalidResponseException();
}
return json_decode($response->getBody(), true);
}
protected function throwInvalidResponseException()
{
return new InvalidResponseException(
InvalidResponseException::MESSAGE
);
}
But our tests didn't cover that edge case. Let's have that fixed
<?php
//GithubClientTest.php
public function testUserProfileCouldNotBeFetchedBecauseAnInvalidHttpResponseWasReceived()
{
$this->response->shouldReceive('getStatusCode')
->once()
->withNoArgs()
->andReturn(201);
$this->response->shouldReceive('getBody')
->never(); //we aren't expecting the getBody call. An exception should "kill" the GithubClient
$this->expectException(InvalidResponseException::class);
$this->getGithubClient()->getUserProfile("fabpot");
}
public function testAUserRepositoriesCouldNotBeFetchedBecauseAnInvalidHttpResponseWasReceived()
{
$this->response->shouldReceive('getStatusCode')
->once()
->andReturn(201);
$this->response->shouldReceive('getBody')
->never();
$this->expectException(InvalidResponseException::class);
$this->getGithubClient()->getUserRepositories("adelowo");
}
The source code for this (including a sample console script that shows our dummy app in usage) can be found on Github.