Testing File Downloads with Guzzle

I have a class that does two things: downloads a call recording as an MP3 from Twilio and then re-uploads it to Amazon S3 for permanent storage. This scenario happens frequently, and I wanted to be able to test it without actually making a real HTTP request from Twilio or to Amazon S3. In this article, I’ll explain the open source libraries I used, as well as how to configure them.

Libraries

To begin, I installed the following libraries (taken directly from my composer.json file):

  • "adlawson/vfs": "^0.12.1"
  • "guzzlehttp/guzzle": "^6.3"

The adlawson/vfs package is installed as part of the require-dev block rather than the require block because it is only necessary as part of your tests. VFS stands for virtual filesystem, and this library allows you to mount a virtual filesystem that your test code can write data to. The virtual filesystem can then be torn down at the end of the test without any impact to your actual filesystem. PHP has a powerful feature built-in to the language called “streams“. The VFS library makes use of streams to read and write files directly in memory which mimics a real filesystem.

Guzzle is a very nice HTTP client. It provides a nice wrapper for interacting with HTTP endpoints, but more importantly, provides a way to mock HTTP requests and responses.

Code

Let’s start by looking at the code we need to test.

namespace MyApp\Library\Calls;

use MyApp\Entity\Call;
use MyApp\Library\Messengers\TwilioClient;
use MyApp\Library\Storage\Uploader;

use GuzzleHttp\Client as GuzzleClient;

class CallAudioProcessor
{

    /** @var GuzzleHttp\Client */
    private $guzzleClient;

    /** @var MyApp\Library\Messengers\TwilioClient */
    private $twilioClient;

    /** @var MyApp\Library\Storage\Uploader */
    private $uploader;

    /** @var string */
    private $fileDirectory;

    public function __construct(
        TwilioClient $twilioClient,
        Uploader $uploader,
        GuzzleClient $guzzleClient,
        string $fileDirectory
    )
    {
        $this->twilioClient = $twilioClient;
        $this->uploader = $uploader;
        $this->guzzleClient = $guzzleClient;

        $this->fileDirectory = $fileDirectory;
    }

    public function processCallRecording(Call $call) : Call
    {
        // Attempt to get the recording from Twilio.
        $recording = $this->twilioClient->fetchCallRecording(
            $call->getObjectId()
        );

        // Generate the path where the recording file will be downloaded.
        $filePath = $this->fileDirectory . $recording['fileName'];

        $this->guzzleClient->request('GET', $recording['recordingUrl'], [
            'sink' => $filePath
        ]);

        if (is_file($filePath)) {
            $fileUrl = $this->uploader->uploadFile(
                $recording['fileName']
            );

            $call->setFileName($recording['fileName'])
                ->setFileSize(filesize($filePath))
                ->setFileUrl($fileUrl);

            unlink($filePath);
        }

        return $call;
    }

}

The test will also be responsible for mocking the TwilioClient and Uploader classes so they can return mocked data.

We’re interested in a test that tests the processCallRecording() method. We want to test the successful path, that is: a recording was found and successfully uploaded to a remote storage location.

Test

Now that our system under test, or SUT, is properly defined, we can take a look at the unit test that runs it.

namespace MyApp\Tests\Calls;

use MyApp\Entity\Call;
use MyApp\Library\Messengers\TwilioClient;
use MyApp\Library\Storage\Uploader;

use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\Handler\MockHandler as GuzzleMockHandler;
use GuzzleHttp\HandlerStack as GuzzleHandlerStack;
use GuzzleHttp\Psr7\Response as GuzzleResponse;

use Vfs\FileSystem as VirtualFileSystem;
use Vfs\Node\File as VirtualFile;

use Faker\Factory as Faker;

use PHPUnit\Framework\TestCase;

class CallAudioProcessorTest extends TestCase
{

    public function testProcessingCallRecording()
    {
        $faker = Faker::create();

        // Create the mocks for the TwilioClient and Uploader.
        $twilioClient = $this->createMock(TwilioClient::class);
        $twilioClient->expects($this->once())->method('fetchCallRecording')->willReturn([
            'fileName' => sprintf('%s.mp3', md5(uniqid())),
            'recordingUrl' => $faker->url
        ]);

        $uploader = $this->createMock(Uploader::class);
        $uploader->expects($this->once())->method('uploadFile')->willReturn($faker->url);

        // Mount the VFS. In a non-test environment, the $fileDirectory
        // might be something like /var/data/files, but in the test,
        // it is the URL to a stream created by the VFS.
        $fileDirectory = 'vfs://';

        $vfs = VirtualFileSystem::factory($fileDirectory);
        $vfs->mount();

        // Create the mock for the GuzzleClient.
        $guzzleMockHandler = new GuzzleMockHandler([
            new GuzzleResponse(200)
        ]);

        $guzzleClient = new GuzzleClient([
            'handler' => GuzzleHandlerStack::create(
                $guzzleMockHandler
            )
        ]);

        $callAudioProcessor = new CallAudioProcessor(
            $twilioClient, $guzzleClient, $uploader, $fileDirectory
        );

        $call = new Call;
        
        $this->assertNull($call->getFileName());
        $this->assertNull($call->getFileUrl());

        $call = $callAudioProcessor->processCallRecording($call);

        $this->assertNotNull($call->getFileName());
        $this->assertNotNull($call->getFileUrl());

        // Important to unmount the VFS
        // so other tests can use it.
        $vfs->unmount();
    }

}

The test starts by mocking the TwilioClient and Uploader classes. These are straightforward mocks that have methods expected to be called once and that will return some basic information.

After the mocks are created, the VFS is mounted. You can make up any mount point you want, and I use vfs:// which I think makes sense. Once mounted, the mock GuzzleClient is created. The native PHPUnit mocking library is not used here, and instead the mock handler provided by Guzzle is used. Though it is not terribly important in this test, the mock handler from Guzzle provides greater flexibility in how your tests behave which is why I prefer it over a PHPUnit mock.

At this point, our fixtures are defined properly and we’re ready to execute the test itself. You can see that a new instance of the CallAudioProcessor class is instantiated with the mocks we’ve built as arguments to the constructor.

Note: If this were a functional test and you were instantiating the CallAudioProcessor from a dependency injector (like the one used in Symfony), you would have to provide setters that allow you to override the depended on components (or DOCs) passed into the constructor.

Finally, the method we’re testing, processCallRecording(), is called, and we assert that the code worked as intended.

Important: You must unmount the VFS at the end of the test! If you fail to do this, any subsequent test using the same VFS scheme (vfs:// in this example) will fail. Behind the scenes, the VFS library destroys the stream when unmount() is called. If this is not done, a subsequent test will attempt to re-create an identical stream, and PHP will complain.

Conclusion

Interacting with the filesystem is always a pain-point when writing tests (regardless of the test type: unit or functional). A virtual filesystem is a beautiful way to alleviate this pain, and it works great for all test types (my functional tests make use of them extensively too).

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s