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"
            ]
        ];
    }

whoops

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.