3

I have the following class in a file located in ./src/PCMagas/Dropbox.php. that I need to test it:

namespace PCMagas;
define("API_OAUTH_TOKEN_URL","https://api.dropboxapi.com/oauth2/token");

use \GuzzleHttp\Client; 
use \GuzzleHttp\RequestOptions;

class Dropbox
{
    /**
     * @param String $appid The Dropbox Application Id.
     * @param String $secret The dropbox Secret
     * @param Client $httpClient The interface used to consume the Dropbox Rest API
     */
    public function __construct($appId,$secret,Client $httpClient)
    {
        $this->appId=$appId;
        $this->secret=$secret;
        $this->httpClient=$httpClient;
    }

    /**
     * Common Logic for Handling Http Error
     * @param Integer $code
     * @throws Exception
     */
    private function httpErrorHandling($code)
    {
        switch($code){
            case 400:
                throw new Exception('Invalid HttpRequest to DropBoxApi');
            case 401:
                throw new Exception('Invalid Dropbox Token');
            case 403:
                throw new Exception('Access Denied');
            case 429:
                throw new Exception('Try again later (after a 10th cup of coffee)');
            case 409:
                throw new Exception('Api user provided error');
            //Treat all 500 error code (seems kinda ugly)
            case 500:
            case 501:
            case 502:
            case 503:
            case 504:
            case 505:
            case 506:
            case 507:
            case 508:
            case 510:
            case 511:
                throw new Exception('Internal Dropbox Error');
        }
    }

    /**
     * @param String $code
     * @return String
     * @throws InvalidArgumentException In case that the code is not correctly Provided.
     * @throws Exception if any error occured when token cannot be fetched
     */
    public function getToken($code)
    {
        //If code get token from code
        //Else get token from $session
        //Not satisfiable thows Esception
        session_start();
        if(!empty($_SESSION['token'])){
            return $_SESSION['token'];
        }

        if(empty($code)){
            throw new \InvalidArgumentException('Please provide a code fetched from Dropbox Athorization.');
        }

        if(empty($_SESSION['redirect_url'])){
            throw new \Exception('Cannot find the url that Dropbox Redirected From');
        }

        $response = $this->httpClient->request("POST",API_OAUTH_TOKEN_URL,[
            RequestOptions::FORM_PARAMS =>[
                'code'=>$code,
                'grant_type'=>'authorization_code',
                'redirect_uri'=>$_SESSION['redirect_url']
            ],
            RequestOptions::AUTH=>[$this->appId,$this->secret]
        ]);

        //Call method and let it blow up
        $this->httpErrorHandling($response->getStatusCode());

        $body=$response->getBody()->getContents();
        $body=json_decode($body,true);
        $_SESSION['token']=$body['access_token'];
        return $_SESSION['token'];
    }
}

Then I try to unit test the method getToken like that (file located in ./tests/DropBoxTest.php:

namespace PCMagas\Tests;

use PHPUnit\Framework\TestCase;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Client;

use PCMagas\Dropbox;

define('ERROR_CODES',[400,401,403,409,429,500,501,502,503,504,505,506,507,508,510,511]);
define('ERROR_CODE_LENGTH',count(ERROR_CODES));

final class DropBoxTest extends TestCase
{

    private function mockErrorGuzzle()
    {
        $responses=array_map(function($statusCode){
            return new Response($statusCode);
        },ERROR_CODES);
        $handler = new MockHandler($responses);
        $client = new Client(['handler'=>$handler]);
        return $client;
    }

    public function testHttpErrorOnTonenFetch()
    {
        $guzzle=$this->mockErrorGuzzle();
        $dropBox=new Dropbox("dummyappId","dummySecret",$guzzle);
        for($i=0;$i<ERROR_CODE_LENGTH;$i++) {
            $this->expectException(\Exception::class);
            $dropBox->getToken("dummyCode");
        }
    }
}

My file structure is:

|- src
|-- PCMagas
|---- Dropvox.php
|- tests
|-- DropBoxTest.php

And my phpunit.xml is:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit
     backupStaticAttributes="false"
     bootstrap="./vendor/autoload.php"
     cacheTokens="false"
     colors="true"
     convertErrorsToExceptions="true"
     convertNoticesToExceptions="true"
     convertWarningsToExceptions="true"
     forceCoversAnnotation="false"
     mapTestClassNameToCoveredClassName="false"
     processIsolation="false"
     stopOnError="false"
     stopOnFailure="false"
     stopOnIncomplete="false"
     stopOnSkipped="false"
     verbose="false">
    <testsuites>
        <testsuite name="Application Unit Tests">
            <directory>./tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

Whilst my composer.json has the following input:

{
    "require": {
        "guzzlehttp/guzzle": "~6.0",
    },
    "require-dev": {
        "phpunit/phpunit": "~6.0",
        "laravel/homestead": "^7.20"
    },
    "autoload": {
        "psr-4":{
            "PCMagas\\": "src/PCMagas"
        }
    },
    "autoload-dev": {
        "psr-4": { "PCMagas\\Tests\\": "tests/" }
    }
}

But when I try to run the unit test I get the following error:

Time: 621 ms, Memory: 4.00MB

There was 1 error:

1) PCMagas\Tests\DropBoxTest::testHttpErrorOnTonenFetch session_start(): Cannot start session when headers already sent

/home/vagrant/code/src/PCMagas/Dropbox.php:84 /home/vagrant/code/tests/DropBoxTest.php:36

Do you know how to fix that error?

1 Answer 1

3

A good idea is t use the Adapter pattern and create a Session Adapter (In my case I PSR-4 autoload it in ./src/PCMagas/Session.php):

namespace PCMagas;

/**
 * A simple Session Adapter in order to offer easyness on Unit testing.
 */
class Session
{

    private $started=false;

    /**
     * Start the session
     * @return Session
     */
    public function start()
    {
        if(!$this->started){
            session_start();
            $this->started=true;
        }
        return $this;
    }

    /**
     * Sets or replaces a session value.
     *  
     * @param String|Integer $key The Session Item
     * @param Mixed $value The value of this Session Item
     * @return Session
     */
    public function setItem($key,$value)
    {
        $_SESSION[$key]=$value;
        return $this;
    }


    /**
     * Returns an Item of a session.
     * @param String|Integer $key
     * @throws Exception
     * @return Mixed
     */
    public function getItem($key)
    {    
        if(!isset($_SESSION[$key])){
            throw Exception("Session item $key does not exist");
        }

        return $_SESSION[$key];
    }

    /**
     * Check if a Session has a Key
     * @param String|Integer $key
     * @return Boolean
     */
    public function has($key)
    {
        return isset($_SESSION[$key]);
    }


    /**
     * @return Session
     */
    public function end()
    {
        session_destroy();
        $this->started=false;
        return $this;
    }
}

Then refactor the DropBox class into this via Depedency Injecting the Session instance:

namespace PCMagas;

define("API_OAUTH_TOKEN_URL","https://api.dropboxapi.com/oauth2/token");

use \GuzzleHttp\Client; 
use \GuzzleHttp\RequestOptions;
use PCMagas\Session;

class Dropbox
{

    /**
     * @var Session
     */
    private $session=null;

    /**
     * @var Client
     */
    private $httpClient=null;

    /**
     * @var String
     */
    private $appId=null;

    /**
     * @var String
     */
    private $secret=null;

    /**
     * @param String $appid The Dropbox Application Id.
     * @param String $secret The dropbox Secret.
     * @param Client $httpClient The interface used to consume the Dropbox Rest API.
     * @param Session $session The session Adapter in order to have ease in Testing.
     */
    public function __construct($appId,$secret,Client $httpClient,Session $session)
    {
        $this->appId=$appId;
        $this->secret=$secret;
        $this->session=$session;
        $this->httpClient=$httpClient;
    }

    /**
     * Common Logic for Handling Http Error
     * @param Integer $code
     * @throws Exception
     */
    private function httpErrorHandling($code)
    {
        switch($code){
            case 400:
                throw new Exception('Invalid HttpRequest to DropBoxApi');
            case 401:
                throw new Exception('Invalid Dropbox Token');
            case 403:
                throw new Exception('Access Denied');
            case 429:
                throw new Exception('Try again later (after a 10th cup of coffee)');
            case 409:
                throw new Exception('Api user provided error');
            //Treat all 500 error code (seems kinda ugly)
            case 500:
            case 501:
            case 502:
            case 503:
            case 504:
            case 505:
            case 506:
            case 507:
            case 508:
            case 510:
            case 511:
                throw new Exception('Internal Dropbox Error');
        }
    }

    /**
     * @param String $code
     * @return String
     * @throws InvalidArgumentException In case that the code is not correctly Provided.
     * @throws Exception if any error occured when token cannot be fetched
     */
    public function getToken($code)
    {
        //If code get token from code
        //Else get token from $session
        //Not satisfiable thows Esception
        $this->session->start();
        if($this->session->has('token')){
            $token=$this->session->getItem('token');
            $this->session->end();
            return $token;
        }

        if(empty($code)){
            throw new \InvalidArgumentException('Please provide a code fetched from Dropbox Athorization.');
        }

        if(!$this->session->has('redirect_url')){
            throw new \Exception('Cannot find the url that Dropbox Redirected From');
        }

        $response = $this->httpClient->request("POST",API_OAUTH_TOKEN_URL,[
            RequestOptions::FORM_PARAMS =>[
                'code'=>$code,
                'grant_type'=>'authorization_code',
                'redirect_uri'=>$this->session->getItem('redirect_url')
            ],
            RequestOptions::AUTH=>[$this->appId,$this->secret]
        ]);

        //Call method and let it blow up
        $this->httpErrorHandling($response->getStatusCode());

        $body=$response->getBody()->getContents();
        $body=json_decode($body,true);
        $this->session->setItem('token', $body['access_token'])->end();
        return $body['access_token'];
    }
}

Then you can unit test it like that:

namespace PCMagas\Tests;

use PHPUnit\Framework\TestCase;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Client;

use Mockery;

use PCMagas\Dropbox;
use PCMagas\Session;

define('ERROR_CODES',[400,401,403,409,429,500,501,502,503,504,505,506,507,508,510,511]);
define('ERROR_CODE_LENGTH',count(ERROR_CODES));

final class DropBoxTest extends TestCase
{

    /**
     * Mocking the Guzzle usig 
     */
    private function mockErrorGuzzle()
    {
        $responses=array_map(function($statusCode){
            return new Response($statusCode);
        },ERROR_CODES);
        $handler = new MockHandler($responses);
        $client = new Client(['handler'=>$handler]);
        return $client;
    }

    public function testHttpErrorOnTokenFetch()
    {
        $guzzle=$this->mockErrorGuzzle();

        $sessionDouble = Mockery::mock(Session::class);
        $sessionDouble->shouldReceive('has')->andReturn(true);

        $dropBox=new Dropbox("dummyappId","dummySecret",$guzzle,$sessionDouble);

        for($i=0;$i<ERROR_CODE_LENGTH;$i++) {
            $this->expectException(\Exception::class);
            $dropBox->getToken("dummyCode");
        }
    }


    public function tearDown()
    {
        Mockery::close();
    }
}

So the Mnemonic Rule is: Whatever you cannot/ find hard to mock, make and Adapter frist it first into a custom class and API, and mock afterwards the adapter ;).

The only downside is that the Adapter may be untested, so you can either Integration test it or manually test it via examples that are not loaded into your production code or using a combination of both.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.