Mock Guzzle Requests in Functional Tests
Published: , Updated:
Introduction ¶
Functional tests won't mock single implementations. But code often interacts with external systems by sending requests, e.g. via Guzzle. Guzzle is the default library used within TYPO3, so you might need to mock the requests within functional tests.
This blog post explains our solution which is based on Guzzle Docs and Susis Blog post on how to mock Guzzle Client implementation within Unit Tests.
The idea ¶
Connect the existing pieces. Susis blog post explains how to mock responses already. We only need to adapt for functional tests where we can't easily replace the concrete instance of the Guzzle Client.
This would work if TYPO3 would allow to load a different Services.yaml
for Testing context, just like Symfony. But TYPO3 allows us to add handlers to Guzzle via configuration of $GLOBALS['TYPO3_CONF_VARS']['HTTP']['handler']
.
The implementation ¶
We created a new PHP Class within the Test namespace. This class provides the API to register itself, to clean things up and to mock the responses:
declare(strict_types=1);
namespace Codappix\ExampleExtension\Tests\Functional;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class GuzzleClientFaker
{
/**
* @var MockHandler
*/
private static $mockHandler;
public static function registerClient(): void
{
$GLOBALS['TYPO3_CONF_VARS']['HTTP']['handler']['faker'] = function (callable $handler) {
return self::getMockHandler();
};
}
/**
* Cleans things up, call it in tests tearDown() method.
*/
public static function tearDown(): void
{
unset($GLOBALS['TYPO3_CONF_VARS']['HTTP']['handler']['faker']);
}
/**
* Adds a new response to the stack with defaults, returning the file contents of given file.
*/
public static function appendResponseFromFile(string $fileName): void
{
$fileContent = file_get_contents($fileName);
if ($fileContent === false) {
throw new \Exception('Could not load file: ' . $fileName, 1656485162);
}
self::appendResponseFromContent($fileContent);
}
private static function appendResponseFromContent(string $content): void
{
self::appendResponse(new Response(
SymfonyResponse::HTTP_OK,
[],
$content
));
}
private static function getMockHandler(): MockHandler
{
if (!self::$mockHandler instanceof MockHandler) {
self::$mockHandler = new MockHandler();
}
return self::$mockHandler;
}
private static function appendResponse(Response $response): void
{
self::getMockHandler()->append($response);
}
}
How to use ¶
The new class can be used within tests:
namespace Codappix\ExampleExtension\Tests\Functional;
use Codappix\ExampleExtension\Command\Import;
use Codappix\ExampleExtension\Extension;
use Symfony\Component\Console\Tester\CommandTester;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase as TestCase;
class ImportTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Ensure the fake guzzle client is used when executing tests.
GuzzleClientFaker::registerClient();
}
protected function tearDown(): void
{
// Ensure the guzzle client is cleaning up all leftovers
GuzzleClientFaker::tearDown();
parent::tearDown();
}
/**
* @test
*/
public function importIsFilteredByCompanyName(): void
{
// Register two responses with the content of those files.
// The files only provide the content of the response, no header or status codes.
GuzzleClientFaker::appendResponseFromFile(__DIR__ . '/ImportFixtures/Guzzle/example.com/api/oauth/token.json');
GuzzleClientFaker::appendResponseFromFile(__DIR__ . '/ImportFixtures/GuzzleImportIsFilteredByCompanyName/example.com/api/v1/job_market/jobs/GET.json');
// Execute the actual tests, that code will trigger two requests.
// The actual code doesn't matter for this example.
$this->importDataSet('EXT:example_extension/Tests/Functional/ImportFixtures/FilteredJobsByCompanyName.xml');
$importer = $this->getContainer()->get(Import::class);
$commandTester = new CommandTester($importer);
$commandTester->execute([
'storagePid' => '2',
]);
$this->assertCSVDataSet('EXT:example_extension/Tests/Functional/ImportAssertions/FilteredJobsByCompanyName.csv');
}
}
We register the fake class within the setUp()
method. We also ensure it cleans up itself within the tearDown()
method.
The class can now be used from within the Test. We currently offer one public method which allows to set the response via appendResponseFromFile()
.
Other methods could easily be exposed. E.g. the private method appendResponseFromContent()
in order to dynamically build the response content. One also could create the whole response including status code within the test and pass it to a new method of the class.
And one could integrate the history as mentioned in Susis blog post in order to assert that certain requests were made.
Acknowledgements ¶
Thanks to stadt.werk GmbH who paid us to implement the mocking within one of their extensions.
Thanks to Susi (https://twitter.com/sasunegomo) for providing an inspiring blog post on how to mock the Client itself.
Thanks to Mathias for motivating me to write the blog post.
Further reading ¶
- Read Susis blog post regarding mocking of HTTP api Responses with Guzzle: https://susi.dev/mock-http-api-responses-with-guzzle-psr-18-psr-7/
- Read Guzzle documentation on testing and mocking of Guzzle: https://docs.guzzlephp.org/en/stable/testing.html and Handlers and Middleware: https://docs.guzzlephp.org/en/stable/handlers-and-middleware.html