An easy way to test 3rd party SDKs with Laravel

An easy way to test 3rd party SDKs with Laravel

Testing external services easily with strong assertions

Nothing is more satisfying than writing unit tests that test complex functionalities with strong assertions.

Starting with an example

Let's take an example where our app has a mixed functionality which involve external API calls, DB calls and internal business logic.

Our app is using a hypothetical external SDK TwitterSDK. We have a service SocialsService that accepts text for a tweet and does the following:

  1. Sanitise the text

  2. Generate tags from text using an external API call.

  3. Post text and tags using TwitterSDK on twitter and get tweetId

  4. Use tweetId to get tweetAnalyticsId

  5. Save tweedId and tweetAnalyticsId in database using a model Socials

The bolded points (1 and 5) involve internal business logic and need to be tested correctly. Rest of the points involve 3rd party services, hence we need to test if they are receiving correct arguments.

<?php

use TwitterSDK\TwitterService;
use App\Modules\Social\Models\Social as SocialModel;

namespace App\Modules\Social\Services;

class SocialsService
{
    public function __construct(public TwitterService $twitterService) {}

    public function tweetAndPersist(string $text): string
    {

        $text = $this->sanitizeTweetText($text);
        $tags = $this->generateTags($text);
        $tweetId = $this->twitterService->addTweet($text, $tags);
        $analyticsId = $this->twitterService->getAnalyticsId($tweetId);

        return $this->persistToDb($tweetId, $analyticsId);
    }

    public function generateTags(string $text): array
    {
        $tags = [];
        // external HTTP call to generate tags
        return [];
    }

    private function sanitizeTweetText(string $text): string
    {
        // sanitize text
        // ...more business logic
        return strip_tags($text);
    }

    private function persistToDb(string $tweetId, string $analyticsId): string
    {
        // persist to db
        return SocialModel::create(['tweet_id' => $tweetId, 'analytics_id' => $analyticsId]);
    }
}

We should be able to test the business logic for these methods correctly

  • sanitizeTweetText()

  • persistToDb()

We should be able to mock the following methods

  • generateTags() [Notice how this is a part of our main service class]

  • addTweet()

  • getAnalyticsId()

The classic solution

The classic solution would involve refactoring the code & injecting a mock class that implements the same signature as TwitterService. Also for generateTags() method we'll have to create a new service TagGeneratorService and inject it in the Socials service.

The refactored SocialsService would look like this:

class SocialsService
{
    // mock class and original class should implement the interface
    public function __construct(
         public TwitterServiceInterface $twitterService,
         public TagGeneratorServiceInterface $tagGeneratorService,
    ) {}

    // ... rest of the code using these services
}

In the unit test, we'll have to use the service like this:

/** @test */
public function testSocialMediaService(): void
{    
    $twitterMock = new TwitterServiceMock()
    $tagGeneratorMock = new TagGeneratorMock()
    $socialsService = new SocialsService($twitterMock, $tagGeneratorMock)

    // ... assertions 
}

This would involve a lot of refactoring, creating mocks for external SDKs would also take time.

However, what if I don't want to refactor this much and still test the code correctly ?

The easier solution

We will use Mockery to mock expectations for these methods.

The complete test code after mocking would look like this

/** @test */
public function testSocialMediaService(): void
{
    $tweetText = "This is a <h1>tweet</h1> about unit tests";
    $sanitizedTweet = "This is a tweet about unit tests";

    $mockTags = ["#unittests"];
    $mockTweetId = 'tweetId';
    $mockAnalyticsId = 'analyticsId';

    $twitterMock = $this->mock(TwitterService::class, function (MockInterface $mock) use (...) {
        $mock->shouldReceive('addTweet')->with($sanitizedTweet, $mockTag)->andReturn($mockTweetId)->once();
        $mock->shouldReceive('getAnalyticsId')->with($mockTweetId)->andReturn($mockAnalyticsId)->once();
    });

    // mock the main service partially
    $socialsServiceMock = Mockery::mock(SocialsService::class, [$twitterMock])->makePartial();
    $socialsServiceMock->shouldReceive('generateTags')->with($sanitizedTweet)->andReturn($mockTag)->once();

    $socialsModel = $socialsServiceMock->tweetAndPersist($tweetText);
    $this->assertTrue($tweetId, $socialsModel->tweet_id);
    $this->assertTrue($analyticsId, $socialsModel->analytics_id);
}

Let's break it down and understand the different parts

  1. Mocking the TwitterService with argument assertions
$twitterMock = $this->mock(TwitterService::class, function (MockInterface $mock) use (...) {
    $mock->shouldReceive('addTweet')->with($sanitizedTweet, $mockTags)->andReturn($mockTweetId)->once();
    $mock->shouldReceive('getAnalyticsId')->with($mockTweetId)->andReturn($mockAnalyticsId)->once();
});

Notice how we are testing that correct arguments are being passed to addTweet(), the argument $sanitizedTweet would actually confirm that our sanitizeTweetText() method is working correctly.

Also notice how we test that $mockTweetId returned by addTweet() is being correctly passed to the getAnalyticsId() method.

  1. Mocking the generateTags() method

Now we need to test the generateTags() method, for this we will mock the main service partially i.e methods which are not mocked will run normally.

// mock the main service partially, pass $twitterMock in constructor
$mock = Mockery::mock(SocialsService::class, [$twitterMock])->makePartial();
$mock->shouldReceive('generateTags')->with($sanitizedTweet)->andReturn($mockTags)->once();

// methods other than generateTags() run normally

Notice how we test that the sanizited text $sanitizedTweet is being passed to the generateTags() method, we obviously return a mock tag as we don't want to run the method.

Conclusion

We have correctly mocked the external SDK calls without losing assertions for business logic.

- Sudheer Tripathi