需要在 Laravel 5.1 中测试使用 CURL 的服务

Need to Test a Service that Uses CURL in Laravel 5.1

我为我的 Laravel 5.1 API 构建了一个搜索 YouTube 的服务。我正在尝试为其编写测试,但无法弄清楚如何模拟该功能。下面是服务。

class Youtube
{
/**
 * Youtube API Key
 *
 * @var string
 */
protected $apiKey;

/**
 * Youtube constructor.
 *
 * @param $apiKey
 */
public function __construct($apiKey)
{
    $this->apiKey = $apiKey;
}

/**
 * Perform YouTube video search.
 *
 * @param $channel
 * @param $query
 * @return mixed
 */
public function searchYoutube($channel, $query)
{
    $url = 'https://www.googleapis.com/youtube/v3/search?order=date' .
        '&part=snippet' .
        '&channelId=' . urlencode($channel) .
        '&type=video' .
        '&maxResults=25' .
        '&key=' . urlencode($this->apiKey) .
        '&q=' . urlencode($query);
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    $result = curl_exec($ch);
    curl_close($ch);

    $result = json_decode($result, true);

    if ( is_array($result) && count($result) ) {
        return $this->extractVideo($result);
    }
    return $result;
}

/**
 * Extract the information we want from the YouTube search resutls.
 * @param $params
 * @return array
 */
protected function extractVideo($params)
{
    /*
    // If successful, YouTube search returns a response body with the following structure:
    //
    //{
    //  "kind": "youtube#searchListResponse",
    //  "etag": etag,
    //  "nextPageToken": string,
    //  "prevPageToken": string,
    //  "pageInfo": {
    //    "totalResults": integer,
    //    "resultsPerPage": integer
    //  },
    //  "items": [
    //    {
    //        "kind": "youtube#searchResult",
    //        "etag": etag,
    //        "id": {
    //            "kind": string,
    //            "videoId": string,
    //            "channelId": string,
    //            "playlistId": string
    //        },
    //        "snippet": {
    //            "publishedAt": datetime,
    //            "channelId": string,
    //            "title": string,
    //            "description": string,
    //            "thumbnails": {
    //                (key): {
    //                    "url": string,
    //                    "width": unsigned integer,
    //                    "height": unsigned integer
    //                }
    //            },
    //        "channelTitle": string,
    //        "liveBroadcastContent": string
    //      }
    //  ]
    //}
     */
    $results = [];
    $items = $params['items'];

    foreach ($items as $item) {

        $videoId = $items['id']['videoId'];
        $title = $items['snippet']['title'];
        $description = $items['snippet']['description'];
        $thumbnail = $items['snippet']['thumbnails']['default']['url'];

        $results[] = [
            'videoId' => $videoId,
            'title' => $title,
            'description' => $description,
            'thumbnail' => $thumbnail
        ];
    }

    // Return result from YouTube API
    return ['items' => $results];
}
}

我创建此服务是为了从控制器中抽象出功能。然后我使用 Mockery 来测试控制器。现在我需要弄清楚如何测试上面的服务。感谢任何帮助。

需要说明的是,由于硬编码的 curl_* 方法,您的 class 不是为孤立的单元测试而设计的。为了让它变得更好,你至少有两个选择:

1) 将 curl_* 函数调用提取到另一个 class 并将该 class 作为参数传递

class CurlCaller {

    public function call($url) {
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        $result = curl_exec($ch);
        curl_close($ch);
        return $result;
    }

}

class Youtube
{
    public function __construct($apiKey, CurlCaller $caller)
    {
        $this->apiKey = $apiKey;
        $this->caller = $caller;
    }
}

现在您可以轻松模拟 CurlCaller class。 有很多现成的解决方案可以抽象网络。例如,Guzzle 很棒

2) 另一种选择是提取 curl_* 对受保护方法的调用并模拟该方法。这是一个工作示例:

// Firstly change your class:
class Youtube
{
    // ...

    public function searchYoutube($channel, $query)
    {
        $url = 'https://www.googleapis.com/youtube/v3/search?order=date' .
            '&part=snippet' .
            '&channelId=' . urlencode($channel) .
            '&type=video' .
            '&maxResults=25' .
            '&key=' . urlencode($this->apiKey) .
            '&q=' . urlencode($query);
        $result = $this->callUrl($url);

        $result = json_decode($result, true);

        if ( is_array($result) && count($result) ) {
            return $this->extractVideo($result);
        }
        return $result;
    }

    // This method will be overriden in test.
    protected function callUrl($url)
    {
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        $result = curl_exec($ch);
        curl_close($ch);

        return $result;
    }
}

现在您可以模拟方法 callUrl。但首先,让我们将预期的 api 响应放入 fixtures/youtube-response-stub.json 文件。

class YoutubeTest extends PHPUnit_Framework_TestCase
{
    public function testYoutube()
    {
        $apiKey = 'StubApiKey';

        // Here we create instance of Youtube class and tell phpunit that we want to override method 'callUrl'
        $youtube = $this->getMockBuilder(Youtube::class)
            ->setMethods(['callUrl'])
            ->setConstructorArgs([$apiKey])
            ->getMock();

        // This is what we expect from youtube api but get from file
        $fakeResponse = $this->getResponseStub();

        // Here we tell phpunit how to override method and our expectations about calling it
        $youtube->expects($this->once())
            ->method('callUrl')
            ->willReturn($fakeResponse);

        // Get results
        $list = $youtube->searchYoutube('UCSZ3kvee8aHyGkMtShH6lmw', 'php');

        $expected = ['items' => [[
            'videoId' => 'video-id-stub',
            'title' => 'title-stub',
            'description' => 'description-stub',
            'thumbnail' => 'https://i.ytimg.com/vi/stub/thimbnail-stub.jpg',
        ]]];

        // Finally assert result with what we expect
        $this->assertEquals($expected, $list);
    }

    public function getResponseStub()
    {
        $response = file_get_contents(__DIR__ . '/fixtures/youtube-response-stub.json');
        return $response;
    }
}

运行 测试和...我的天啊,失败了!!1 你在 extractVideo 方法中有拼写错误,应该是 $item 而不是 $items。让我们修复它

$videoId = $item['id']['videoId'];
$title = $item['snippet']['title'];
$description = $item['snippet']['description'];
$thumbnail = $item['snippet']['thumbnails']['default']['url'];

好的,现在它通过了。


如果你想通过调用 Youtube API 来测试你的 class 你只需要创建普通的 Youtube class.


顺便说一句,有 php-youtube-api 库,它有 laravel 4 和 laravel 5 的提供者,也有测试

如果更改进行 CURL 调用的代码不是一个选项,它仍然可以完成,但它并不漂亮。

此解决方案假定进行 CURL 调用的代码将其目标 URL 基于环境变量。这里的要点是您可以将调用重定向回您自己的应用程序,重定向到输出可以由您的测试控制的端点。由于执行测试的应用程序实例实际上与 CURL 调用掉头时访问的实例不同,我们处理范围问题的方式允许测试控制输出通过 forever 缓存,将您的虚拟数据记录到外部文件中,该文件在 运行 时间访问。

  1. 在测试中,改变负责 CURL 调用域的环境变量的值,使用: putenv("SOME_BASE_URI=".config('app.url')."/curltest/")

由于 phpunit.xml 通常将默认 CACHE_DRIVER 设置为 array,这是 而非 永久性的,您必须将其放入您的测试将其改回 file.

config(['cache.default' => 'file']);
  1. 在您的 tests 文件夹中创建一个新的 class,当请求满足一组可配置的条件时,它将负责返回给定的响应:

    使用Illuminate\Http\Request;

    class ResponseFactory {

    public function getResponse(Request $request)
    {
        $request = [
            'method' => $request->method(),
            'url' => parse_url($request->fullUrl()),
            'parameters' => $request->route()->parameters(),
            'input' => $request->all(),
            'files' => $request->files
        ];
    
        $responses = app('cache')->pull('test-response', null);
    
        $response = collect($responses)->filter(function (array $response) use ($request) {
            $passes = true;
            $response = array_dot($response);
            $request = array_dot($request);
            foreach ($response as $part => $rule) {
                if ($part == 'response') {
                    continue;
                }
                $passes &= is_callable($rule) ? $rule($request[$part]) : ($request[$part] == $rule);
            }
            return $passes;
        })->pluck('response')->first() ?: $request;
    
        if (is_callable($response)) {
            $response = $response($request);
        }
    
        return response($response);
    }
    
    /**
     * This uses permanent cache so it can persist between the instance of this app from which the test is being
     * executed, to the instance being accessed by a CURL call
     *
     * @param array $responses
     */
    public function setResponse(array $responses)
    {
        app('cache')->forever('test-response', $responses);
    }
    

    }

因为它在 tests 文件夹中而不是在 App 命名空间下,请务必将它添加到 composer.json 文件的 auto-load.classmap 部分,并且运行 composer dumpautoload;composer install 在命令行上。 此外,这是使用自定义辅助函数:

if (!function_exists('parse_url')) {
    /**
     * @param $url
     * @return array
     */
    function parse_url($url)
    {
        $parts = parse_url($url);
        if (array_key_exists('query', $parts)) {
            $query = [];
            parse_str(urldecode($parts['query']), $query);
            $parts['query'] = $query;
        }
        return $parts;
    }
}
  1. 向您的路由添加一些仅用于测试的端点。 (可悲的是,放置 $this->app->make(Router::class)->match($method, $endpoint, $closure); 据我所知,在你的测试中是行不通的。) Route::post('curltest/{endpoint?}', function (Illuminate\Http\Request $request) { return app(ResponseFactory::class)->getResponse($request); }); Route::get('curltest/{endpoint?}', function (Illuminate\Http\Request $request) { return app(ResponseFactory::class)->getResponse($request); }); Route::put('curltest/{endpoint?}', function (Illuminate\Http\Request $request) { return app(ResponseFactory::class)->getResponse($request); }); Route::patch('curltest/{endpoint?}', function (Illuminate\Http\Request $request) { return app(ResponseFactory::class)->getResponse($request); }); Route::delete('curltest/{endpoint?}', function (Illuminate\Http\Request $request) { return app(ResponseFactory::class)->getResponse($request); }); 如果需要,您甚至可以将其包装在 if 块中,确保首先 config('app.debug') == true

  2. 配置响应内容以反映应该提示特定 response 值的端点。在你的测试中放置这样的东西。 app(ResponseFactory::class)->setResponse([[ 'url.path' => "/curltest/$curlTargetEndpont", 'response' => 'success' ]]);