Support the ongoing development of Laravel.io →

Laravel Facades - Write Testable Code

11 Apr, 2024 7 min read

Photo by Sigmund on Unsplash

Hello 👋

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;
  3. Bind the newly defined interface to our service class in the Laravel service provider;
  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;
use App\Contracts\Dispatcher as DispatcherContract;

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 DispatcherContract::class;
    }
}

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;
use App\Contracts\Dispatcher as DispatcherContract;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(DispatcherContract::class, 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.

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!

Last updated 3 weeks ago.

driesvints liked this article

1
Like this article? Let the author know and give them a clap!
oussamamater (Oussama Mater) I'm a software engineer and CTF player. I use Laravel and Vue.js to turn ideas into applications 🚀

Other articles you might like

November 18th 2024

Laravel Custom Query Builders Over Scopes

Hello 👋 Alright, let's talk about Query Scopes. They're awesome, they make queries much easier to r...

Read article
November 19th 2024

Access Laravel before and after running Pest tests

How to access the Laravel ecosystem by simulating the beforeAll and afterAll methods in a Pest test....

Read article
November 11th 2024

🍣 Sushi — Your Eloquent model driver for other data sources

In Laravel projects, we usually store data in databases, create tables, and run migrations. But not...

Read article

We'd like to thank these amazing companies for supporting us

Your logo here?

Laravel.io

The Laravel portal for problem solving, knowledge sharing and community building.

© 2024 Laravel.io - All rights reserved.