Laravel Facades - Write Testable Code

April 10, 2024

Hello ๐Ÿ‘‹

cover facades

For one reason or another, Laravel Facades don't get much love. I often read about how they are not a true facade implementation, and let me tell you, they're not ๐Ÿคท. They're more like proxies than facades. If you think about it, they simply forward calls to their respective classes, effectively intercepting the request. When you start looking at them this way, you realize that facades, when used correctly, can result in a clean and testable code. So, don't get too caught up in the naming, I mean who cares what they are called? And let's see how we can make use of them.

Same-Same, But Different... But Still Same ๐Ÿ˜Ž

When writing service classes, I'm not a fan of using static methods, they make testing dependent classes HARD. However, I do love the clean calls of they offer, like Service::action(). With Laravel real-time facades, we can achieve this.

Let's take a look at this example

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use App\Exceptions\CouldNotFetchRates;
use App\Entities\ExchangeRate;

class ECBExchangeRateService
{
    public static function getRatesFromApi(): ExchangeRate
    {
        $response = Http::get('ecb_url'); // Avoid hardcoding URLs, this is just an example

        throw_if($response->failed(), CouldNotFetchRates::apiTimeout('ecb'));

        return ExchangeRate::from($response->body());
    }
}

We have a hyper-simplified service class that attempts to retrieve exchange rates from an API and returns a DTO (Entity, or whatever makes you happy) if everything goes well.

Now we can use this service like so

<?php

namespace App\Classes;

use App\Services\ECBExchangeRateService;

class AnotherClass
{
    public function action(): void
    {
        $rates = ECBExchangeRateService::getRatesFromApi();
        
        // Do something with the rates
    }
}

The code might look clean, but it's not good; it's not testable. We can write feature tests or integration tests, but when it comes to unit testing this class, we can't. There's no way to mock ECBExchangeRateService::getRatesFromApi(), and unit tests should not have any dependencies (interactions with different classes or systems).

Since we are discussing unit tests, I want to emphasize that should not doesn't mean you don't have to. Sometimes it makes sense to have database interaction in unit tests, for example, to test whether or not a relationship is loaded ๐Ÿคท. Don't follow the rules blindly; sometimes they make sense, sometimes they don't.

So, to fix this, we need to follow some steps:

  1. Convert the static getRatesFromApi() into a regular one;
  2. Create a new interface that defines which methods should be implemented by ECBExchangeRateService (optional);
  3. Bind the newly defined interface to our service class in the Laravel service provider (optional);
  4. Make use of dependency injection, whether via a constructor or directly into the method, depending on how you want your API to look.

One might argue that this is the correct way to do things, but I'm a very simple guy. I feel that this is an overkill, especially if I know that I won't be changing any implementations for a very long time.

I mean, I literally added the autolink to headers today, so I can link only the real-time section of my article. That's how much I want to keep things simple ๐Ÿ˜‚

With real-time facades, we can turn the 4 steps into 2:

  1. Convert the static getRatesFromApi() into a regular one (just remove the static keyword);
  2. Prefix the import with the Facades keyword.

Your code should look like

<?php

namespace App\Classes;

use Facades\App\Services\ECBExchangeRateService; // The only change we need

class AnotherClass
{
    public function action(): void
    {
        $rates = ECBExchangeRateService::getRatesFromApi();
        
        // do something with the rates
    }
}

That's all we needed to do! Removed 1 keyword, and added another. You can't beat this!

Here is how we can test our code now

it('does something with the rates', function () { 
    ECBExchangeRateService::shouldReceive('getRatesFromApi')->once();
 
    (new AnotherClass)->action();
});

I am using Pest.

The ECBExchangeRateService will be resolved from the container, just as we would do in the 4 steps above, without the need to create extra interfaces or add more code. We maintain our clean, simple approach and ensure testability. And I know some people still won't agree, dismissing it as dark magic. Well, it's not really magic if it is in the docs; read your docs kids!

Hooot Swappable ๐Ÿ”ฅ

Remember what I mentioned about thinking of facades as proxies? Let's explain it.

When using Laravel Queues, we dispatch jobs in our code. When you're testing that code, you're not interested in testing if the actual job is working as expected or not; that can be tested separately. Instead, you're interested in whether or not the job has been dispatched, the number of times it's been dispatched, the payload used, etc. So, to achieve this, we would need two implementations, right? Dispatcher and DispatcherFake - one that actually dispatches the job to Redis, MySQL, or whatever you've set it for, and the second one that does not dispatch anything, but rather captures those events.

If we were to implement this ourselves, we would need to follow the 4 steps from earlier, and change the bindings of these implementations depending on the context - if we are running tests or if we are running the actual code. Now, Facades make this much simpler, like really simple. Let's see how.

Let's first define our interface

<?php

namespace App\Contracts;

interface Dispatcher
{
    public function dispatch(mixed $job, mixed $handler): mixed
}

Then we can have two implementations

<?php

namespace App\Bus;

use PHPUnit\Framework\Assert;
use App\Contracts\Dispatcher as DispatcherContract;

class Dispatcher implements DispatcherContract
{
    public function dispatch(mixed $job, mixed $handler): mixed
    {
        // Actually dispatch this to the DB or whatever driver is set
    }
}

class DispatcherFake implements DispatcherContract
{
    protected $jobs = [];

    public function dispatch(mixed $job, mixed $handler): mixed
    {
        // We are just recording the dispatches here
        $this->jobs[$job] = $handler;
    }

    // We can add testing helpers
    public function assertDispatched(mixed $job)
    {
        Assert::assertTrue(count($this->jobs[$job]) > 0);
    }

    public function assertDispatchedTimes(mixed $job, int $times = 1)
    {
        Assert::assertTrue(count($this->jobs[$job]) === $times);
    }

    // ... and more methods
}

Now, instead of resolving implementations manually and having to bind multiple ones, we can make use of facades. They intercept the call, and we can choose where we want to forward it!

<?php

namespace App\Facades;

use App\Bus\DispatcherFake;
use Illuminate\Support\Facades\Facade;

class Dispatcher extends Facade
{
    protected static function fake()
    {
        return tap(new DispatcherFake(), function ($fake) {
            // This will set the $resolvedInstance to the faked one
            // So every time we try to access the underlying
            // implementation, the faked object will be returned instead
            static::swap($fake);
        });
    }

    protected static function getFacadeAccessor()
    {
        return 'dispatcher';
    }
}

Interested in learning how Facades work under the hood? I've written an article about it.

Now we can simply bind our dispatcher to the application container.

<?php

namespace App\Providers;

use App\Bus\Dispatcher;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind('dispatcher', function ($app) {
            return new Dispatcher;
        });
    }
    
    // ...
}

And that's it! We can now elegantly swap between implementations, and our code is testable with clean calls, and no injections (but with the same effect).


use App\Facades\Dispatcher; // import the facade

it('does dispatches a job', function () {
    // This will set the fake implementation as the resolved object
    Dispatcher::fake();
    
    // An action that dispatches a job `Dispatcher::dispatch(Job::class, Handler::class);
    (new Action)->handle();

    // Now you can assert that it has been dispatched
    Dispatcher::assertDispatched(Job::class);
});

This is a hyper-simplified example, just to see things from a new perspective. Interestingly, this is how your favorite framework tests things internally. So, facades might not be as much of an anti-pattern as you might think. They might be named incorrectly, but you can see how they simplify things.

Eye's open ๐Ÿ”

My colleague Jรกnos made a good point worth considering, which might save a few hours of debugging ๐Ÿง .

First, let's have a look at the swap method

/**
* Hotswap the underlying instance behind the facade.
*
* @param  mixed  $instance
* @return void
*/
public static function swap($instance)
{
    static::$resolvedInstance[static::getFacadeAccessor()] = $instance;

    if (isset(static::$app)) {
        static::$app->instance(static::getFacadeAccessor(), $instance); // Binding the faked instance to the container
    }
}

You can see that when swapping instances, we're not just replacing the cached resolved instance; we're also binding it to the container. This implies that ALL future returned instances, whether using dependency injection or the app() helper, will yield a fake implementation. So, if you're running integration tests, where some classes use constructor injection and not facades, they will also receive the faked implementation. Nevertheless, if you ever call the fake() method, you would expect to receive the fake implementation everywhere anyway. However, it's something to bear in mind when writing tests (especially integration tests), where you might want to use the actual class instead.

Conclusion

Don't fight the framework; embrace it and try to make use of what already exists. There are multiple approaches to each problem, and they can all be good. Don't dismiss something just because someone else thinks otherwise; give it a chance. To me, as long as the code is testable, you're on the right path, you shouldn't worry too much about whether or not it follows certain rules.

If you have any thoughts we can include in the article, feel free to ping me!


Profile picture

Written by Oussama Mater Software Engineering Student, CTF Player, and Web Developer.
Find me on X, Linkedin, Github, and Laracasts.