Symfony 4 макет частных услуг - PullRequest
0 голосов
/ 05 ноября 2018

У меня есть приложение, которое отвечает за выбор различных 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, расширяющий реальные 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 \ functions \ Mock \ ApiClientMock" является синтетической, ее необходимо настроить во время загрузки, прежде чем ее можно будет использовать.

1 Ответ

0 голосов
/ 08 декабря 2018

Хорошо, я обнаружил, почему я все еще получаю реальные данные, а не высмеиваюсь. Проблема заключается в том, что 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, но также и возможность имитировать запросы и связать его с HttpClientInterface в services_test.yml.

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'

Приложение \ Тесты \ функционал \ 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 implement 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->runCommand('app:command'):

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

Это упрощенная версия, и я, возможно, что-то упустил, не стесняйтесь спрашивать, если вам нужны объяснения!

...