phptestingphpunitsymfony5

Mocking external clients (f.ex: AWS SDK SQS Client) when testing a symfony command


[PHP] [Symfony 5.4] [Testing]

Hello!! I am trying to add some test coverage to a symfony project. The service I am trying to test is a symfony CLI command. The setup looks something like this:

FetchMessageCommand.php (<- starting point of the flow)

class FetchMessageCommand extends Command
{
    public function __construct(
        EntityManagerInterface $entityManager,
        KernelInterface $kernel,
        LoggerInterface $logger,
        AWSSQSManager $awsSQSManager,
        //.... some other dependencies
    ) {
        parent::__construct($name);
        $this->awsSQSManager = $awsSQSManager;
        // ...
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        try {
            // ... some more business logic

            $this->awsSQSManager->processMessages();

            // ... some more business logic
        } catch (Exception $e) {
            $this->logger->error('An error occurred: ' . $e->getMessage());
            return Command::FAILURE;
        }

        return Command::SUCCESS;
    }

}

AWSSQSManager.php

class AWSSQSManager
{    
    public function __construct(
        AWSSQSClientInterface $AWSSQSClient,
        EntityManagerInterface $em,
        LoggerInterface $logger,
        // ... some other dependencies
    ) {
        $this->AWSSQSClient = $AWSSQSClient;
        $this->em = $em;
        $this->logger = $logger;
        // ...
    }

    public function processMessages()
    {
        $this->logger->info("fetching message data..");

        $queueUrl = $this->AWSSQSClient->getQueueUrl($this->removalQueueName);
        // .... some other logic
    }
    // ... some other code
}

AWSSQSClient.php (<- this is the actual service injected by framework to replace AWSSQSClientInterface above in AWSSQSManager class)

class AWSSQSClient implements AWSSQSClientInterface
{
    public function __construct(
        SqsClient $client,
        LoggerInterface $logger,
        string $accountId
    ) {
        $this->client = $client;
        $this->logger = $logger;
        $this->accountId = $accountId;
    }

    public function getQueueUrl($queueName)
    {
        try {
            $requestParams = [
                'QueueName' => $queueName,
                'QueueOwnerAWSAccountId' => $this->accountId
            ];

            $queueUrlResult = $this->client->getQueueUrl($requestParams);
            $queueUrl = $queueUrlResult->get('QueueUrl');

            return $queueUrl;
        } catch (AwsException $e) {
            $this->exception($e, "GET getQueueUrl failed");
            return $e->getMessage();
        }
    }

    // ... more functions and code
}

Of course I have omitted imports, variable declarations and some irrelevant codes from here, but should be good enough to give context. Feel free to ask for details if needed.

Now, my objective is mock SqsClient class which is exposed by AWS SDK to prevent my test from making an actual call to the AWS. And this is how I am trying to mock it:

FetchMessageCommand.php

class FetchMessageCommand extends KernelTestCase
{
    protected ?Application $application = null;

    public function setUp(): void
    {
        parent::setUp();
        self::bootKernel();

        $container = static::getContainer();

        if (null === $this->application) {
            $this->application = new Application(self::$kernel);
        }

        $sqsClient = $this->getMockBuilder(SqsClient::class)
            ->disableOriginalConstructor()
            ->disableArgumentCloning()
            ->disableOriginalClone()
            ->disableAutoload()
            ->disableProxyingToOriginalMethods()
            ->setConstructorArgs([])
            ->addMethods(['receiveMessage', 'getQueueUrl', 'sendMessage', 'deleteMessage', 'GetQueueUrl'])
            ->getMock();

        $sqsClient->expects($this->once())
            ->method('getQueueUrl')
            ->willReturn('https://example.com');

        $sqsClient->expects($this->once())
            ->method('GetQueueUrl')
            ->willReturn('https://example.com');

       // .... more expectations for rest of the methods

        $container->set(SqsClient::class, $sqsClient);
    }

    public function testMessageDataRequest()
    {
        $command = $this->application->find('fetch:message');

        // now call the command
        $commandTester = new CommandTester($command);
        $commandTester->execute([]);

        $commandTester->assertCommandIsSuccessful();
    }

When the test begins (with debug mode), the FetchMessageCommand gets the mocked version of SqsClient but the instance of AWSSQSManager -> AWSSQSClient continue to use the SqsClient from original SDK and endup making an actual call to the AWS.

From what I learned so far (after googling and reading docs), the container is getting initialised and services in AWSSQSClient are getting auto-wired before the mocks are generated and that's how symfony works. So, my stupid question is, is there a way to actually make it work such that my test don't make an actual call to AWS? Or is my apporach completely wrong?

I can of course change my approach and instead of testing the entire command, just focus my test on AWSSQSManager but that beats the purpose of integration test or application test and the business logic in FetchMessageCommand will get leftout.

Thanks!


Solution

  • After hours of reading and debugging, I couldn't find a better way to handle this and therefore ended up with a slightly different approach. Not sure if this aligns with the principles but is worked for me.

    Due to the limitation of how symfony auto-wiring of services work, the only way to make sure the mocks are being injected in the required class, I had to create a partial mock of each class in the dependency tree until SQSClient. So. in my case that was first mocking the AWSSQSClient (with SQSClientMock), and then passing this to AWSSQSManager mock. Of course, I also had to also pass all the constructor arguments manually.

    Here's how my setUp function in FetchMessageCommand test file ended up like:

        public function setUp(): void
        {
            parent::setUp();
            self::bootKernel();
    
            $container = static::getContainer();
    
            $sqsClientMock = Mockery::mock(SqsClient::class)->makePartial();
            $sqsClientMock->shouldReceive('getQueueUrl')->andReturn('https://example.com');
            $container->set(SqsClient::class, $sqsClientMock);
    
            $entityManager = $container->get(EntityManagerInterface::class);
            $logger = $container->get(LoggerInterface::class);
            //... other dependencies required by AWSSQSClient and AWSSQSManager classes
    
            $awsSQSClientMock = Mockery::mock(AWSSQSClient::class, [
                $sqsClientMock,
                $logger,
                // .... other dependencies
            ])->makePartial();
            $container->set(AWSSQSClient::class, $awsSQSClientMock);
    
            $awsSQSManagerMock = Mockery::mock(AWSSQSManager::class, [
                $awsSQSClientMock,
                $entityManager,
                $logger,
                // ... other dependencies
            ])->makePartial();
            $container->set(AWSSQSManager::class, $awsSQSManagerMock);
        }