Okay, I found why I still getting real data instead of mocked. The issue is that Codeception use CLI module (https://codeception.com/docs/modules/Cli) which is running new app, so data is not mocked there. To fix the issue I extend Symfony module to use Symfony CommandTester
(https://symfony.com/doc/current/console.html#testing-commands) instead of Codeception CLI module.
For example I have 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;
}
and his implementation 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());
}
}
}
in original service.yml
all my services marked as private:
services:
_defaults:
autowire: true
autoconfigure: true
public: false
App\Infrastructure\HttpClients\Adapters\Guzzle\GuzzleApiClient:
shared: false
so I can't access them inside the tests to mock and I need to create service_test.yml
and set there all services as public, and I need to create stub class which should implement HttpClientInterface
but also ability to mock requests and associate it with HttpClientInterface
in the 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'
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;
}
}
At this point we have mechanism to mock requests doing it like:
$apiClient = $I->grabService(HttpCLientInterface::class);
$apiClient->addResponse($response);
$I->_getContainer()->set(HttpClientInterface::class, $apiClient)
but it will not work for CLI as we need to implement CommandTester
as I mentioned at the beginning. To do so I need to extend Codeception Symfony module:
<?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');
}
}
}
that's it! Now we can mock HttpCLientInterface and run $I->runCommand('app:command')
:
$apiClient = $I->grabService(HttpCLientInterface::class);
$apiClient->addResponse($response);
$I->_getContainer()->set(HttpClientInterface::class, $apiClient);
$I->runCommand('app:command');
It's simplified version and I probably miss something, feel free to ask if you need some explanations!