Search code examples
phpunit-testingtwiliophpunitmockery

How to test twilio send sms with mockery?


I am writing a test case to send a sms using twilio sdk in php in a laravel application. I created a mock of the Client class, and I expect that the client will receive the messages then create methods and return a response.

Initialy, I wrote the following test, which return the error Mockery\Exception\BadMethodCallException: Received Mockery_0_Twilio_Rest_Client::getAccountSid(), but no expectations were specified From the execution stack of the test, I see that the methods seems to be called on the real twilio SDK, which is not what I think it should do.

1) Tests\Unit\Services\Sms\TwilioAdapterTest::given_a_valid_phone_number_when_sending_sms_then_it_should_return_a_message_id
Mockery\Exception\BadMethodCallException: Received Mockery_0_Twilio_Rest_Client::getAccountSid(), but no expectations were specified

/home/aduhaime/Documents/projets/arm_sms/vendor/twilio/sdk/src/Twilio/Rest/Api/V2010.php:128
/home/aduhaime/Documents/projets/arm_sms/vendor/twilio/sdk/src/Twilio/Rest/Api/V2010.php:276
/home/aduhaime/Documents/projets/arm_sms/vendor/twilio/sdk/src/Twilio/Rest/Client.php:606
/home/aduhaime/Documents/projets/arm_sms/vendor/twilio/sdk/src/Twilio/Base/BaseClient.php:231
/home/aduhaime/Documents/projets/arm_sms/app/Services/Sms/TwilioAdapter.php:13
/home/aduhaime/Documents/projets/arm_sms/tests/Unit/Services/Sms/TwilioAdapterTest.php:31
/home/aduhaime/Documents/projets/arm_sms/vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestCase.php:173

The implementation

<?php

namespace App\Services\Sms;

use Twilio\Rest\Client;

class TwilioAdapter implements SendsSms
{
    public function __construct(private Client $client) { }

    public function send(string $from, string $to, string $message)
    {
        return $this->client->messages->create($to, ['from' => $from, 'body' => $message]);
    }
}

Failing test

<?php

namespace Tests\Unit\Services\Sms;

use App\Services\Sms\TwilioAdapter;
use Mockery\MockInterface;
use Tests\TestCase;
use Twilio\Rest\Client;

class TwilioAdapterTest extends TestCase
{
    private function getTwilioClientMock($mockFunction)
    {
        return $this->mock(Client::class, $mockFunction);
    }

    /** @test */
    public function given_a_valid_phone_number_when_sending_sms_then_it_should_return_a_message_id()
    {
        /** @var \Twilio\Rest\Client $twilioClient */
        $twilioClient = $this->getTwilioClientMock(function (MockInterface $mock) {
            $mock->shouldReceive('messages->create')->andReturn(json_encode(['sid' => '1234567890']));
        });
        $adapter = new TwilioAdapter($twilioClient);
        $response = $adapter->send('+1234567890', '+554569999', 'Hello World');
        
        $this->assertNotNull(json_decode($response)->sid);
    }
}

After some time, I found a way to make the test pass:

Passing test

<?php

namespace Tests\Unit\Services\Sms;

use App\Services\Sms\TwilioAdapter;
use Mockery\MockInterface;
use Tests\TestCase;
use Twilio\Rest\Client;
use Twilio\Http\Response;

class TwilioAdapterTest extends TestCase
{
    private function getTwilioClientMock($mockFunction)
    {
        return $this->mock(Client::class, $mockFunction);
    }

    /** @test */
    public function given_a_valid_phone_number_when_sending_sms_then_it_should_return_a_message_id()
    {
        /** @var \Twilio\Rest\Client $twilioClient */
        $twilioClient = $this->getTwilioClientMock(function (MockInterface $mock) {
            $mock->shouldReceive('messages')
            ->shouldReceive('getAccountSid')
            ->andReturn(MessageList::class)
            ->shouldReceive('create')
            ->shouldReceive('request')
            ->andReturn(new Response(200, '{"sid": "SM1234567890"}'));
        });
        $adapter = new TwilioAdapter($twilioClient);
        $response = $adapter->send('+1234567890', '+554569999', 'Hello World');

        $this->assertNotNull($response->toArray()['sid']);
    }
}

Is this the right way to make that kind of test?

I feel like I should not have to know what method are called internally by the sdk and write shouldReceive for getAcountSid() and request().


Solution

  • Your problem is this line:

    $mock->shouldReceive('messages->create')

    Mockery has support for "mocking Demeter chains", but it will assume the chain is composed of method calls, so this sets up the mock to respond to this:

    $this->client->messages()->create(...);
    

    However, what you're trying to mock is this:

    return $this->client->messages->create(...);
    

    Note that ->messages is a property access, not a method call.

    This may be clearer if you spell that out with some intermediate lines:

    $client = $this->client; // 1
    $messages = $client->messages; // 2
    $result = $messages->create(...); // 3
    return $result;
    
    • Line 1 is going to return your mock client object, so that's great.
    • Line 2 is going to access a property on that object - maybe in the real Client that's a real property, maybe it's a virtual property implemented with __get, no way to tell here.
    • Line 3 is going to call the create method on whatever line 2 found.

    So what we actually want is to set the messages property on the mock client, to itself be an appropriate mock.

    The tricky part is knowing what class or interface to mock. Digging into the source code of Twilio\Rest\Client, it looks to me like this is the (virtual) property we want to mock:

    /**
     * ...
     * @property \Twilio\Rest\Api\V2010\Account\MessageList $messages
     * ...
     */
    

    Which would look something like this:

    // Set up a mock
    $mockMessageList = Mockery::mock('\Twilio\Rest\Api\V2010\Account\MessageList');
    // Assign it to the `messages` property of the mock client
    // this should over-ride the virtual property that the real client would use
    $twilioClient->messages = $mockMessageList;
    // Now we can set up expectations directly on the 'messages' mock
    $mockMessageList->shouldReceive('create')->andReturn(json_encode(['sid' => '1234567890']));
    // And inject the client into the adapter we're testing
    $adapter = new TwilioAdapter($twilioClient);