Testing APIs and Services in Laravel | Clean Integrations Part 2

Published 22/12/2021 | Last updated 24/12/2021 | 6602 views

Yesterday, we discussed integrating the Twitter API in our Laravel app. Today, let's talk about how we'd go about testing that.

Using test doubles, we can create amazing testing experiences for API integrations.

In my last post, I discussed how to integrate with the Twitter API to automate sending tweets when publishing a new blog post. If you haven't read that yet, I'd recommend starting there; this will make a lot more sense once you've read that.

Testing is a very important aspect of software development. It allows us to ship products, refactor and add new features with full confidence that our application still works as intended. However, when it comes to integration with real services, like the Twitter API, it can be difficult to know what to test. You probably don't want to send a real tweet every time you run the test suite, for example. So, how do you write these tests? Let's dive in.

I'll be writing my tests using Pest PHP. I mean, come on, what did you expect from a co-author? However, these principles can be applied to any PHP testing framework.

Our integration test

I tend to write using a form of TDD. I do this primarily to have an easy way to try out my code without needing to have a full user interface. In this case, we can build an integration test that will actually send out a real tweet. Then, we can check Twitter and delete it. Let's create our first test file.

php artisan make:test Services/Twitter/OauthClientTest --pest

In that file, we'll add our integration test.

<?php
 
use App\Contracts\Services\Twitter;
 
it('sends a tweet', function () {
$this->expectNotToPerformAssertions();
 
$twitter = new OauthClient();
 
$twitter->tweet('Hello world! This is just a little test, move along 😉');
});

Now, we can run this test and check our Twitter profile. If we see that tweet, we know our integration is fully operational, just like the second Death Star. Note that we use the expectNotToPerformAssertions method. This is because we don't check any values. Instead, we're simply checking that the entire thing runs without throwing an exception.

Now, this is a test that we actually want to run very rarely. To stop us accidentally running the test when we don't want to, I'll add the skip method to the test.

<?php
 
use App\Contracts\Services\Twitter;
 
it('sends a tweet', function () {
$this->expectNotToPerformAssertions();
 
$twitter = new OauthClient();
 
$twitter->tweet('Hello world! This is just a little test, move along 😉');
})->skip('This will send a real tweet');

Now, when we run our test suite, we won't accidently flood our twitter profile. As an alternative, we could use Pest's group support to add this to an integration group that we exlude when running our tests by default.

Okay, so that's our integration test, but we likely also want to check that the job we dispatch that sends the tweet actually calls this tweet method. Thankfully, we've coded to a contract, so this is devilishly simple. Let's look at two ways we can test our integrations.

Testing using mocks

Laravel ships with a powerful mocking library called Mockery. This allows us to build fake implementations on the fly. It's perhaps the fastest way to achieve what we want, so I'll cover it first. However, fastest is not always best, as we'll see later.

Let's first of all create a test file for our job.

php artisan make:test Jobs/SendTweetAboutPublishedPostTest --pest

In our test, we'll first handle the happy path.

<?php
 
use App\Contracts\Services\Twitter;
use App\Jobs\SendTweetAboutPublishedPost;
use Database\Factories\WinkPostFactory;
 
it('sends a tweet about the published post', function () {
$this->mock(Twitter::class);
->shouldReceive('tweet')->once();
 
$post = WinkPostFactory::new()->create([
'title' => 'Getting started with Pest PHP'
]);
 
SendTweetAboutPublishedPost::dispatchSync($post);
});

On the first line of our test, we ask Laravel to "mock" the Twitter contract using the Mockery library. Laravel will switch out the implementation that is bound by default in our service container.

Using the Mockery API, we then tell the mock Twitter client that it should expect to receive a method call, 'tweet', once. If no such expectations are met, Mockery will throw an exception and our test will fail.

Because we've mocked out the service, no real tweet will be sent, but we can still be certain that our Twitter service is being used correctly.

What about the "unhappy" path? We want to check that if that post is no longer published, no tweet will be sent. Let's use mockery again to add a second test to our test file.

<?php
 
use App\Contracts\Services\Twitter;
use App\Jobs\SendTweetAboutPublishedPost;
use Database\Factories\WinkPostFactory;
 
it('sends a tweet about the published post', function () {
$this->mock(Twitter::class);
->shouldReceive('tweet')->once();
 
$post = WinkPostFactory::new()->create([
'title' => 'Getting started with Pest PHP'
]);
 
SendTweetAboutPublishedPost::dispatchSync($post);
});
 
it('will not send a tweet if the post is no longer published', function () {
$this->mock(Twitter::class);
->shouldNotReceive('tweet');
 
$post = WinkPostFactory::new()->create([
'published' => false,
'publish_date' => now()->subDay(),
]);
 
SendTweetAboutPublishedPost::dispatchSync($post);
});

Here, we use another Mockery method: shouldNotReceive. This is basically the opposite of shouldReceive and will ensure that if we use our Twitter service, the test will fail.

I'm going to take a time out here and point out that I'm not really a fan of mocks. I've dealt with them a lot in my time as a developer, and they rarely help. Over time, they tend to cause code rot if used incorrectly.

Instead, I'm a much bigger proponent of test doubles. Let's look at that alternative now.

Testing using doubles

Let's start by creating our test double. A test double is basically another class that implements the same contract, but doesn't actually perform any real integration. We'll place our Twitter double in tests/Doubles/FakeTwitterClient.php.

<?php
 
namespace Tests\Doubles;
 
use App\Contracts\Services\Twitter;
use PHPUnit\Framework\Assert;
 
final class FakeTwitterClient implements Twitter
{
public function tweet(string $message): void
{
}
}

Now, let's reimplement our first job test from earlier.

<?php
 
use App\Contracts\Services\Twitter;
use App\Jobs\SendTweetAboutPublishedPost;
use Database\Factories\WinkPostFactory;
use Tests\Doubles\FakeTwitterClient;
 
it('sends a tweet about the published post', function () {
$this->swap(Twitter::class, new FakeTwitterClient());
 
$post = WinkPostFactory::new()->create([
'title' => 'Getting started with Pest PHP'
]);
 
SendTweetAboutPublishedPost::dispatchSync($post);
});

Instead of mock, we now use Laravel's swap method to achieve the same thing with our fake client class. However, we're not actually performing any expectation here. So although this ensures no tweet will be sent out during testing, it also doesn't really test anything. Thankfully, we now have a place we can store test specific helper methods to check a whole host of things related to our Twitter integration. Let's start by adding an assertTweeted method to our FakeTwitterClient.

<?php
 
namespace Tests\Doubles;
 
use App\Contracts\Services\Twitter;
use PHPUnit\Framework\Assert;
 
final class FakeTwitterClient implements Twitter
{
private array $sentTweets = [];
 
public function tweet(string $message): void
{
$this->sentTweets[] = $message;
}
 
public function assertTweeted(string $tweet): void
{
Assert::assertContains(
$tweet,
$this->sentTweets,
"The tweet \"{$tweet}\" was never sent."
);
}
}

We now have a method that will not only allow us to check that a tweet was sent, but also that the tweet contained a certain set of content. The Assert object in assertTweeted is part of PHPUnit's API, and allows you to perform assertions from any object.

Let's now update our test to add support for our new method.

<?php
 
use App\Contracts\Services\Twitter;
use App\Jobs\SendTweetAboutPublishedPost;
use Database\Factories\WinkPostFactory;
use Tests\Doubles\FakeTwitterClient;
 
it('sends a tweet about the published post', function () {
$this->swap(Twitter::class, $twitter = new FakeTwitterClient());
 
$post = WinkPostFactory::new()->create([
'title' => 'Getting started with Pest PHP'
]);
 
SendTweetAboutPublishedPost::dispatchSync($post);
 
$twitter->assertTweeted(<<<TXT
New blog post available 📬: Getting started with Pest PHP
 
$postUrl
TXT);
});

How nice is that? By using a double, we've given ourself space for an amazing testing experience. Our test becomes incredibly easy to understand and work with.

Let's apply the same principle to our unhappy path. Why don't we add an assertNothingTweeted method to our FakeTwitterClient?

<?php
 
namespace Tests\Doubles;
 
use App\Contracts\Services\Twitter;
use PHPUnit\Framework\Assert;
 
final class FakeTwitterClient implements Twitter
{
private array $sentTweets = [];
 
public function tweet(string $message): void
{
$this->sentTweets[] = $message;
}
 
public function assertTweeted(string $tweet): void
{
Assert::assertContains(
$tweet,
$this->sentTweets,
"The tweet \"{$tweet}\" was never sent."
);
}
 
public function assertNothingTweeted(): void
{
$sentTweetCount = count($this->sentTweets);
 
Assert::assertCount(0, $this->sentTweets, "[{$sentTweetCount}] tweets were sent.");
}
}

Again, incredibly simple to add, yet so powerful in practice. Let's re-implement our second test with this new superpower.

<?php
 
use App\Contracts\Services\Twitter;
use App\Jobs\SendTweetAboutPublishedPost;
use Database\Factories\WinkPostFactory;
use Tests\Doubles\FakeTwitterClient;
 
it('sends a tweet about the published post', function () {
$this->swap(Twitter::class, $twitter = new FakeTwitterClient());
 
$post = WinkPostFactory::new()->create([
'title' => 'Getting started with Pest PHP'
]);
 
SendTweetAboutPublishedPost::dispatchSync($post);
 
$twitter->assertTweeted(<<<TXT
New blog post available 📬: Getting started with Pest PHP
 
$postUrl
TXT);
});
 
it('will not send a tweet if the post is no longer published', function () {
$this->swap(Twitter::class, $twitter = new FakeTwitterClient());
 
$post = WinkPostFactory::new()->create([
'published' => false,
'publish_date' => now()->subDay(),
]);
 
SendTweetAboutPublishedPost::dispatchSync($post);
 
$twitter->assertNothingTweeted();
});

Tasty stuff! I really love the power offered by test doubles; they're very easy to add and offer so much more flexibility than a mock can provide.

Making the double the test default

Here's the thing: currently, we're manually switching out the Twitter implementation in our tests using swap. In reality, however, the fake twitter client should be our default; we almost never want to actually send a tweet in our tests.

There are a number of ways we can achieve this, but perhaps the simplest is in our project's base TestCase::setUp method.

<?php
 
namespace Tests;
 
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Artisan;
use Tests\Doubles\FakeTwitterClient;
 
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
use LazilyRefreshDatabase;
 
protected function setUp(): void
{
parent::setUp();
 
$this->swap(Twitter::class, new FakeTwitterClient());
}
}

Now, every test will use the fake twitter client by default, so there is no risk of accidentally sending out real tweets. Of course, we'll want to update our job test to reflect this by removing the manual swap and retreving our fake client from the service container.

<?php
 
use App\Contracts\Services\Twitter;
use App\Jobs\SendTweetAboutPublishedPost;
use Database\Factories\WinkPostFactory;
 
it('sends a tweet about the published post', function () {
$post = WinkPostFactory::new()->create([
'title' => 'Getting started with Pest PHP'
]);
 
SendTweetAboutPublishedPost::dispatchSync($post);
 
$this->app->get(Twitter::class)->assertTweeted(<<<TXT
New blog post available 📬: Getting started with Pest PHP
 
$postUrl
TXT);
});
 
it('will not send a tweet if the post is no longer published', function () {
$post = WinkPostFactory::new()->create([
'published' => false,
'publish_date' => now()->subDay(),
]);
 
SendTweetAboutPublishedPost::dispatchSync($post);
 
$this->app->get(Twitter::class)->assertNothingTweeted();
});

Hey presto! We now have a really neat way of testing our Twitter integration. Of course, this same technique can be applied to any integration you can think of. And, when it comes to test doubles, the sky is the limit with the domain specific testing helpers that you can add to your project. I encourage you to get out there and try this in real projects.

There is another way of switching out our implementation in tests: managers. But that's a story for another blog post.

Happy coding!

Kind Regards, Luke

Like what you see?

If you enjoy reading my content, please consider sponsoring me. I don't spend it on cups of coffee; it all goes towards freeing up more of my time to work on open source, tutorials and more posts like this one.