首页 > 解决方案 > PHPUnit 单元测试:在方法中处理 session_start()

问题描述

我在位于./src/PCMagas/Dropbox.php. 我需要测试它:

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

然后我尝试对getToken这样的方法进行单元测试(文件位于./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");
        }
    }
}

我的文件结构是:

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

phpunit.xml的是:

<?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>

虽然我composer.json有以下输入:

{
    "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/" }
    }
}

但是当我尝试运行单元测试时,出现以下错误:

时间:621 毫秒,内存:4.00MB

有 1 个错误:

1) PCMagas\Tests\DropBoxTest::testHttpErrorOnTonenFetch session_start(): 标头已发送时无法启动会话

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

你知道如何解决这个错误吗?

标签: phpunit-testingsessionphpunit

解决方案


一个好主意是不使用适配器模式并创建一个会话适配器(在我的情况下,我 PSR-4 自动加载它./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;
    }
}

然后DropBox通过依赖注入会话实例将类重构为:

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

然后你可以像这样对它进行单元测试:

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();
    }
}

因此,助记符规则是:无论您无法/难以模拟,制作和适配器首先将其放入自定义类和 API,然后模拟适配器;)。

唯一的缺点是适配器可能未经测试,因此您可以集成测试它或通过未加载到生产代码中的示例手动测试它或使用两者的组合。


推荐阅读