Photo by Marvin Meyer on Unsplash
I left my last post about testing APIs and services on a bit of a cliff-hanger:
There is another way of switching out our implementation in tests: managers. But that's a story for another blog post.
Ooh, isn't that annoying? My apologies up front. Well, fear not, because in this post, I shall reveal all about managers in Laravel, what they're used for, and how they can make your life as a Laravel developer simpler.
What is a Manager?
If you refer back to my previous blog post, you'll note that we had to place the following code in our TestCase
.
<?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());
}
}
This is okay, but somewhat hidden to developers coming into your code because it doesn't follow standard Laravel convention. What is standard Laravel convention for these scenarios? Take a look in config/cache.php
. You'll see something like this.
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache connection that gets used while
| using this caching library. This connection is used when another is
| not explicitly specified when executing a given caching function.
|
*/
'default' => env('CACHE_DRIVER', 'file'),
This is pretty common in Laravel; we have config files where we can specify the implementation, or driver, that should be used for a certain service. But where is this swapped out with a fake implementation for our tests? Take a look in your project's root for a file called phpunit.xml
or phpunit.dist.xml
. In there, you'll likely see something like this.
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<php>
<server name="APP_ENV" value="testing"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/>
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
<server name="MAIL_MAILER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
<server name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>
What's happening here? When we run our test suite, Pest or PHPUnit is going to load this xml file. It will take these <server/>
nodes and replace any values found in our .env
file with these values. So, in this case, our cache driver will always be array
in tests, even if we've defined redis
in our .env
file. What a clean approach!
But how is it that the word file
, or redis
, or array
results in actual implementations of our contract? The answer is a Manager
. Let's create one for our Twitter service.
Creating a Manager
I like to place my Manager
classes in the same directory as the implementations. So, let's create a new file at app/Services/Twitter/TwitterManager.php
.
<?php
namespace App\Services\Twitter;
use Illuminate\Support\Manager;
final class TwitterManager extends Manager
{
public function getDefaultDriver(): string
{
}
}
Note that our class extends Illuminate\Support\Manager
. That class has an abstract method that we're expected to implement: getDefaultDriver
. The purpose of this method is to decide which implementation of our Twitter
contract should be used by the application. Note that it returns a string. A string like file
, or redis
, or array
perhaps. See where we're going here?
Let's update our twitter
config in config/services.php
to add support for defining our desired implementation.
<?php
return [
'mailgun' => [
'domain' => env('MAILGUN_DOMAIN'),
'secret' => env('MAILGUN_SECRET'),
'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
],
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'twitter' => [
'default' => env('TWITTER_DRIVER', 'oauth'),
'consumer_key' => env('TWITTER_CONSUMER_KEY'),
'consumer_secret' => env('TWITTER_CONSUMER_SECRET'),
'access_token' => env('TWITTER_ACCESS_TOKEN'),
'access_token_secret' => env('TWITTER_ACCESS_TOKEN_SECRET'),
],
];
Now, back in our TwitterManager
, we can access and return this config value.
<?php
namespace App\Services\Twitter;
use Illuminate\Support\Manager;
final class TwitterManager extends Manager
{
public function getDefaultDriver(): string
{
return $this->config->get('services.twitter.default') ?? 'null';
}
}
Note that the manager gives us access to a config
property, which is super useful. Also, note that use ??
to catch null
values and return the string null
instead. This is important.
So, we've defined two possible string values for our implementation: oauth
and null
. How do we translate those options to their respective classes? Laravel's Manager
class will look for a method defined on your manager that follows the following signature: createXDriver
, with X
being the string value we want to implement. Let's use that knowledge to construct our classes.
<?php
namespace App\Services\Twitter;
use Illuminate\Support\Manager;
use Tests\Doubles\FakeTwitterClient;
use Abraham\TwitterOAuth\TwitterOAuth;
final class TwitterManager extends Manager
{
public function getDefaultDriver(): string
{
return $this->config->get('services.twitter.default') ?? 'null';
}
public function createOauthDriver(): OauthClient
{
return new OauthClient();
}
public function createNullDriver(): FakeTwitterClient
{
return new FakeTwitterClient();
}
}
Simple! If your implementations need items from the service container, the Manager gives you access to a container
property, which you can use to load in those dependencies and pass to your implementations.
Now, our manager won't be called automagically. We need to update the binding we created in our AppServiceProvider
to use this manager.
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Contracts\Services\Twitter;
use Illuminate\Contracts\Container\Container;
use App\Services\Twitter\TwitterManager;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(Twitter::class, function (Container $container) {
return (new TwitterManager($container))->driver();
});
}
}
So, let's now break down the flow our app will go through when we request an implementation of the Twitter
contract from the service container:
- Laravel will create an instance of our
TwitterManager
, and call thedriver
method on it. - The
driver
method (which is part of the baseManager
class) will call thegetDefaultDriver
method we had to implement in ourTwitterManager
. - Our
getDefaultDriver
method will look in ourconfig/services.php
file at thetwitter.default
key. - That key will then be used to call a method on our manager:
create[Key]Driver
. - The
create[Key]Driver
method will return the implementation for the requested driver.
Make sense? With this done, we can now update our phpunit.xml
file to override whatever is in our .env
file with our fake driver.
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<php>
<server name="APP_ENV" value="testing"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/>
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
<server name="MAIL_MAILER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
<server name="TELESCOPE_ENABLED" value="false"/>
<server name="TWITTER_DRIVER" value="null"/>
</php>
</phpunit>
Obviously, you can now remove the manual swap we added in our TestCase
.
Conclusion
So, that's managers in Laravel! Pretty simple to integrate, and lots of power. If you ever added a third implementation, it would be a simple task to add a new createThirdImplementation
method to our manager and be up and running in seconds.
As always, I hope you learned something new.
Kind Regards, Luke
driesvints, akhmatovalexander liked this article