Symfony 4 模拟私人服务

Posted

技术标签:

【中文标题】Symfony 4 模拟私人服务【英文标题】:Symfony 4 mock private services 【发布时间】:2019-04-09 04:40:04 【问题描述】:

我有负责获取各种 api 以收集数据的应用程序。我使用 Codeception 作为我的测试框架,我需要在我的功能测试中模拟 API 客户端类,如下所示:

public function testFetchingNewApps(FunctionalTester $I) 
    $request = new Request(
        SymfonyRequest::METHOD_GET,
        'https://url.com/get'
    );

    $apiClientMock = \Mockery::mock(HttpClientInterface::class);
    $apiClientMock
        ->shouldReceive('send')
        ->with($request)
        ->andReturn(new Response(HttpCode::OK, [], '"data":"some data"'))
            ->once();

    $symfony = $this->getModule('Symfony')->grabService('kernel')->getContainer()->set(HttpClientInterface::class,   $apiClientMock);
    $symfony->persistService(HttpClientInterface::class, false);

    $I->runShellCommand('bin/console sync:apos --env=test');

但是自从 Symfony 4 以来,我们无法访问私有服务来模拟它们,我看到类似的错误

服务是私有的,你不能替换它。

所以我发现我可以创建ApiClinetMock.php whick 扩展真正的ApiCLient.php 文件和services_test.yml 文件。而在services_test.yml 中我可以将ApiClinetMock.php 作为公共服务并将其与接口相关联(覆盖接口使用):

#services_test.yml
services:
    _defaults:
        public: true
    Api\Tests\functional\Mock\ApiClientMock: ~
    ApiHttpClients\HttpClientInterface: '@Api\Tests\functional\Mock\ApiClientMock'

现在,当我运行我的测试用例时,我看不到任何类似

的错误

服务是私有的,你不能替换它。

但是我的模拟不起作用并返回真实数据而不是我在模拟中设置的数据,我不知道为什么。

可能的解决方案是在 ApiClientMock 中覆盖我需要的方法以返回我需要的数据,但它仅适用于一个测试用例,但我需要测试各种不同的有效/无效响应。

我知道 Symfony 4 中有很多关于这个问题的信息,但我仍然找不到任何好的例子。有人可以向我解释我应该如何编写功能测试以及如何制作适当的模拟。

已更新我知道我可以使用https://symfony.com/blog/new-in-symfony-4-1-simpler-service-testing,但它仅在您需要获取私人服务时有效,但在您需要设置/替换时无效

已更新我还尝试将 Api\Tests\functional\Mock\ApiClientMock 设置为合成,但现在出现错误:

“Api\Tests\functional\Mock\ApiClientMock”服务是合成的,需要在开机时设置才能使用。

【问题讨论】:

【参考方案1】:

好的,我找到了为什么我仍然获得真实数据而不是模拟数据的原因。问题是 Codeception 使用 CLI 模块 (https://codeception.com/docs/modules/Cli) 正在运行新应用程序,因此数据不会在那里模拟。为了解决这个问题,我扩展了 Symfony 模块以使用 Symfony CommandTester (https://symfony.com/doc/current/console.html#testing-commands) 而不是 Codeception CLI 模块。

例如我有HttpClientInterface:

<?php declare(strict_types = 1);

namespace App\Infrastructure\HttpClients;

use App\Infrastructure\HttpClients\Exceptions\HttpClientException;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

/**
 * Interface HttpClientInterface
 * @package OfferManagement\Infrastructure\ApiOfferSync\HttpClients
 */
interface HttpClientInterface

    /**
     * Send an HTTP request.
     *
     * @param RequestInterface $request Request to send
     * @param array|array[]|string[]|integer[]  $options Request options to apply to the given
     *                                  request and to the transfer.
     *
     * @return ResponseInterface
     * @throws HttpClientException
     */
    public function send(RequestInterface $request, array $options = []): ResponseInterface;

    /**
     * Asynchronously send an HTTP request.
     *
     * @param RequestInterface $request Request to send
     * @param array|array[]|string[]|integer[]  $options Request options to apply to the given
     *                                  request and to the transfer.
     *
     * @return PromiseInterface
     */
    public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface;

和他的实现 GuzzleApiClient:

<?php declare(strict_types = 1);

namespace App\Infrastructure\HttpClients\Adapters\Guzzle;

use App\Infrastructure\HttpClients\Exceptions\HttpClientException;
use App\Infrastructure\HttpClients\HttpClientInterface;
use GuzzleHttp\Client;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

class GuzzleApiClient implements HttpClientInterface

    /**
     * @var Client
     */
    private $apiClient;

    /**
     * GuzzleApiClient constructor.
     */
    public function __construct()
    
        $this->apiClient = new Client();
    

    /**
     * @param RequestInterface $request  Request to send
     * @param array|array[]|string[]|integer[] $options Request options to apply to the given
     *                                  request and to the transfer.
     *
     * @return ResponseInterface
     * @throws HttpClientException
     * @throws \GuzzleHttp\Exception\GuzzleException
     */
    public function send(RequestInterface $request, array $options = []):ResponseInterface
    
        try 
            return $this->apiClient->send($request, $options);
         catch (\Throwable $e) 
            throw new HttpClientException($e->getMessage());
        
    

    /**
     * Asynchronously send an HTTP request.
     *
     * @param RequestInterface $request Request to send
     * @param array|array[]|string[]|integer[] $options Request options to apply to the given
     *                                  request and to the transfer.
     *
     * @return PromiseInterface
     * @throws HttpClientException
     */
    public function sendAsync(RequestInterface $request, array $options = []):PromiseInterface
    
        try 
            return $this->apiClient->sendAsync($request, $options);
         catch (\Throwable $e) 
            throw new HttpClientException($e->getMessage());
        
    

在原来的service.yml 我所有的服务都标记为私有:

        services:
           _defaults:
                autowire: true
                autoconfigure: true
                public: false 
         
     
 App\Infrastructure\HttpClients\Adapters\Guzzle\GuzzleApiClient:
    shared: false

所以我无法在测试中访问它们以进行模拟,我需要创建 service_test.yml 并将所有服务设置为公共服务,并且我需要创建应该实现 HttpClientInterface 的存根类,但还要能够模拟请求并将其与services_test.yml中的HttpClientInterface相关联。

services_test.yml

services:
    _defaults:
        public: true

### to mock HttpClientInterface we need to override implementation for test env, note original implementation is not shared but here it should be shared
### as we need to always get same instance, but in the GuzzleApiClient we need add logic to clear data somehow after each test
    App\Tests\functional\Mock\GuzzleApiClient: ~
    App\Infrastructure\HttpClients\HttpClientInterface: '@App\Tests\functional\Mock\GuzzleApiClient'

App\Tests\functional\Mock\GuzzleApiClient:

<?php declare(strict_types=1);

namespace OfferManagement\Tests\functional\ApiOfferSync\Mock;

use App\Infrastructure\HttpClients
use App\Infrastructure\HttpClients\Adapters\Guzzle\Request;
use GuzzleHttp\Psr7\Response;
use App\Infrastructure\HttpClients\Exceptions\HttpClientException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

/**
 * Class we using as a mock for HttpClientInterface. NOTE: this class is shared so we need clean up mechanism to remove
 * prepared data after usage to avoid unexpected situations
 * @package App\Tests\functional\Mock
 */
class GuzzleApiClient implements HttpClientInterface

    /**
     * @var array
     */
    private $responses;

    /**
     * @param RequestInterface $request
     * @param array $options
     * @return ResponseInterface
     * @throws HttpClientException
     * @throws \GuzzleHttp\Exception\GuzzleException
     */
    public function send(RequestInterface $request, array $options = []): ResponseInterface
    
        $url = urldecode($request->getUri()->__toString());
        $url = md5($url);
        if(isset($this->responses[$url])) 
            $response = $this->responses[$url];
            unset($this->responses[$url]);

            return $response;
        

        throw \Exception('No mocked response for such request')

    


    /**
     * Url is to long to be array key, so I'm doing md5 to make it shorter
     * @param RequestInterface $request
     * @param Response $response
     */
    public function addResponse(RequestInterface $request, Response $response):void
    
        $url = urldecode($request->getUri()->__toString());
        $url = md5($url);
        $this->responses[$url] = $response;
    


此时我们有了模拟请求的机制,如下所示:

$apiClient = $I->grabService(HttpCLientInterface::class);
$apiClient->addResponse($response);
$I->_getContainer()->set(HttpClientInterface::class, $apiClient)

但它不适用于 CLI,因为我们需要实现 CommandTester,正如我在开头提到的那样。为此,我需要扩展 Codeception Symfony 模块:

<?php declare(strict_types=1);

namespace App\Tests\Helper;


use Codeception\Exception\ModuleException;
use Codeception\TestInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\DependencyInjection\ContainerInterface;


class SymfonyExtended extends \Codeception\Module\Symfony

    private $commandOutput = '';

    public $output = '';

    public function _before(TestInterface $test)
    
        parent::_before($test);
        $this->commandOutput = '';
    

    public function _initialize()
    
        parent::_initialize();
    

    /**
     * @param string $commandName
     * @param array $arguments
     * @param array $options
     * @throws ModuleException
     */
    public function runCommand(string $commandName, array $arguments = [], array $options  = [])
    
        $application = new Application($this->kernel);
        $command = $application->find($commandName);
        $commandTester = new CommandTester($command);

        $commandTester->execute(
            $this->buildCommandArgumentsArray($command, $arguments, $options)
        );

        $this->commandOutput = $commandTester->getDisplay();
        if ($commandTester->getStatusCode() !== 0 && $commandTester->getStatusCode() !== null) 
            \PHPUnit\Framework\Assert::fail("Result code was $commandTester->getStatusCode().\n\n");
        
    

    /**
     * @param Command $command
     * @param array $arguments
     * @param array $options
     * @throws ModuleException
     * @return array
     */
    private function buildCommandArgumentsArray(Command $command, array $arguments, array $options):array
    
        $argumentsArray['command'] = $command->getName();
        if(!empty($arguments)) 
            foreach ($arguments as $name => $value) 
                $this->validateArgument($name, $value);
                $argumentsArray[$name] = $value;
            
        

        if(!empty($options)) 
            foreach ($options as $name => $value) 
                $this->validateArgument($name, $value);
                $argumentsArray['--'.$name] = $value;
            
        

        return $argumentsArray;
    

    /**
     * @param $key
     * @param $value
     * @throws ModuleException
     */
    private function validateArgument($key, $value)
    

        if(
            !is_string($key)
            || empty($value)
        ) 
            throw new ModuleException('each argument provided to symfony command should be in format: "argument_name" => "value". Like: "username" => "Wouter"');
        

        if($key === 'command') 
            throw new ModuleException('you cant add arguments or options with name "command" to symofny commands');
        
    


就是这样!现在我们可以模拟 HttpCLientInterface 并运行 $I-&gt;runCommand('app:command'):

$apiClient = $I->grabService(HttpCLientInterface::class);
$apiClient->addResponse($response);
$I->_getContainer()->set(HttpClientInterface::class, $apiClient);
$I->runCommand('app:command');

这是简化版,我可能遗漏了一些东西,如果您需要一些解释,请随时询问!

【讨论】:

以上是关于Symfony 4 模拟私人服务的主要内容,如果未能解决你的问题,请参考以下文章

Symfony 3模拟服务功能测试phpunit和liip

Symfony - 在控制台命令中模拟登录和安全上下文

Symfony 4 捆绑包工作

Symfony:在功能测试中模拟 LDAP 组件

如何在 symfony phpunit kernelTestCase 中正确注入 monolog.logger / LoggerInterface 作为模拟

Symfony 4 - 来自自定义捆绑包的服务不起作用