Testing Laravel Environment Checks
Published 25/01/2022 | 6005 viewsEver wondered how to go about testing environment checks in your Laravel application? This post is for you!
This is in some ways a counterpart to Michael Dyrynda's screencast on this subject. If you're a visual learner, you can watch that by clicking here.
A couple of weeks back, Michael asked a simple question on a Laravel group chat: "What's the best way to run a Pest test against a certain environment?"
For the uninitiated, whenever you run one or more tests in your test suite, either via php artisan test
, vendor/bin/pest
or vendor/bin/phpunit
, Laravel will automatically set the app.environment
config value to testing
. This is very useful, as it allows us the ability to prevent testing code from running in real application code.
The premise
However, consider the following scenario. We have an app that uses multi-tenancy. Each tenant (or team) has their own database. In order to help ensure that this functionality is working correctly in local, your blade layout might include something like this.
@env('local')<div>Active Database: {{ DB::connection()->getDatabaseName() }}</div>@endenv
With this in place, we'd be able to very quickly verify that we're connected to the correct database and get on with our merry dev lives. However, you almost certainly want to avoid exposing this information in production
. That could prove to be a real security issue.
In fact, such information is such a risk that it would be wise to write a test for it.
First test attempt
What might such a test look like?
it('shows the current database name in local', function() { config()->set('app.environment', 'local'); $this->get('/')->assertSee('Active Database:');}); it('does not show the current database name in production', function() { config()->set('app.environment', 'production'); $this->get('/')->assertDontSee('Active Database:');});
You may expect this to work, but it won't. That's because by the time we alter the app.environment
value, Laravel has already read and set this value in the application singleton. In other words, it's too late. We need another solution.
Mocking the App
facade
One possible solution would be to use the outstanding mocking facilities available on facades to make Laravel "think" we're in production
or local
, when in actual fact, we aren't. Here's how that would look in practice.
it('shows the current database name in local', function() { App::partialMock()->shouldReceive('environment')->andReturn('local'); $this->get('/')->assertSee('Active Database:');}); it('does not show the current database name in production', function() { App::partialMock()->shouldReceive('environment')->andReturn('production'); $this->get('/')->assertDontSee('Active Database:');});
Notice that we use partialMock
instead of mock
. Using mock
will cause a whole heap of errors because all of the other methods available on the App
facade won't have a clue how to resolve.
There is a problem with this approach. The Laravel application, for the entirety of the test, will think it is in a production environment. This could lead to undesireable side effects, such as using real keys and secrets for services or actually sending messages to that slack integration we have. No bueno.
Creating our own facade
Let's look at another solution. What if, instead of relying on @env
blade directive here, we instead create our own class used for checks like this? I like to name this class after the application itself. So, for this site, we'd create a file called app/DowningTech.php
.
namespace App; class DowningTech{ public function usingLocalEnvironment(): bool { return App::environment() === 'local'; }}
We can add other checks over time to this class to help in similar situations. Next, we need to wrap this in a facade. Create a new file at app/Facades/DowningTech.php
. I've dived deeper into facades in this post, so I won't elaborate again. Suffice to say that the facade would probably look like this.
namespace App\Facades; use Illuminate\Support\Facades\Facade; class DowningTech extends Facade{ public function getFacadeAccessor() { return App\DowningTech::class; }}
Note that by returning the base FQCN in getFacadeAccessor
, we don't have to create a binding in our service provider.
Seen as we'll be using this in blade, I would also suggest adding an alias for this facade in config/app.php
.
/*|--------------------------------------------------------------------------| Class Aliases|--------------------------------------------------------------------------|| This array of class aliases will be registered when this application| is started. However, feel free to register as many as you wish as| the aliases are "lazy" loaded so they don't hinder performance.|*/ 'aliases' => [ // ...after the other aliases 'DowningTech' => App\Facades\DowningTech::class, ]
With that done, we can now update our original blade file to use our new facade.
@if(DowningTech::usingLocalEnvironment())<div>Active Database: {{ DB::connection()->getDatabaseName() }}</div>@endif
I think this reads really nicely and explicitly informs us of intent. And now, instead of mocking the entire application, we can just mock our custom facade in the tests.
it('shows the current database name in local', function() { DowningTech::partialMock()->shouldReceive('usingLocalEnvironment')->andReturnTrue(); $this->get('/')->assertSee('Active Database:');}); it('does not show the current database name in production', function() { DowningTech::partialMock()->shouldReceive('usingLocalEnvironment')->andReturnFalse(); $this->get('/')->assertDontSee('Active Database:');});
Of course, if this were a common check, we could create a function in our Pest.php
file that performs this step for us.
function useLocalEnvironment(bool $useLocal = true){ DowningTech::partialMock() ->shouldReceive('usingLocalEnvironment') ->andReturn($useLocal);}
Our updated tests would then look as follows.
it('shows the current database name in local', function() { $this->get('/')->assertSee('Active Database:');})->useLocalEnvironment(); it('does not show the current database name in production', function() { $this->get('/')->assertDontSee('Active Database:');})->useLocalEnvironment(false);
Conclusion
So, in summary, the next time you find yourself wanting to test against a particular environment, instead of trying to jump through dangerous hoops, why not create a custom facade?
Kind Regards, Luke