Using Real-time Facades to Fix Tests

Published: 1 month ago

I've had a couple of failing tests on Laradir for a little while.

When a user's profile gets accepted or rejected, I update their record in Mailcoach* with a relevant 'tag'.

* This is an affiliate link.

This lets me do segmentation, so I can send mails to portions of the list.

The Mailcoach interactions all happen in a service class, which creates a neat internal API around the Http facade used for interacting with the Mailcoach API.

Honestly, I'm not completely happy with this class right now. I'd much rather use something like Saloon.

I will be moving to that eventually, but that's a bigger lift than I have time for at the moment.

But I do want those tests fixed.

The problem is this service class doesn't run through the Laravel container, so it's always going to try to make real API calls—even when it's exercised during test suite runs.

This is what causes the error. These failing tests are coupled to the state of a production, third-party service.

Not good.

Why?

  • It's hitting the network, which introduces latency into my tests.
  • Production data could become polluted with test data, or even corrupted.
  • I don't own Mailcoach, so my test suite can't own it and control how things flow through it in controlled, laboratory conditions. If the data or API at Mailcoach changes, my tests can start failing, which is exactly what's happening.

On top of that, anyone else who might end up working on this code will need API keys to Mailcoach just to be able to run the test suite.

This also applies to any CI process, which means my CI would also need access to Mailcoach. 🫣

The longer these tests go unfixed, the less I can rely on the test suite and the greater the risk of each release.

This is all giving off a pretty stinky smell.

Mock Http?

As my service class uses Laravel's Http facade under the hood, one approach to solving this would be to use Laravel's built-in mocking capabilities against the Http facade.

I could do this directly in my tests.

But in this case that's the more complicated solution.

Plus mocking the Http facade will be quite brittle.

Why?

Because the function calls and their parameters may need to change based on all sorts of factors that are outside of my control.

For example, if the Mailcoach API changed, my Http facade calls would need to change, and as a result, my mocks in my tests may need to change too.

That's a lot of code changes that I could potentially avoid.

If I could mock my service class instead, I would only need to change the Http facade calls inside the service class; my tests would likely continue passing without requiring any changes.

In other words, having my tests operating at the level of abstraction of the Http facade is too low.

I really want them to be interacting with my service class, at its higher level of abstraction, as this allows me to keep my mocks simpler and more resilient to changes that are outside of my control.

But in order to mock my service class I need to be able to swap the implementation out at runtime—I need the container.

That will require changing a lot of code and behavior just to fix a couple of tests!

That feels like extra risk.

But perhaps I just need to bite the bullet here, get my head down and do this the right way?

As I said before, I already have a better Saloon-tion in mind for the long-term improvement of this piece. Maybe I should just crack on with that?

Thing is, I really don't have the time to do all that work right now, just to get a couple of tests to pass.

And I certainly don't want to do a lot of work on any interim solution, especially if I know that it will be replaced with a better solution soon.

But I can't keep ignoring this or lazily skipping those tests!

RTFs FTW

This is where Laravel's real-time facades have saved the day.

They've allowed me to balance the speed of a solution with an appropriate level of flexibility by giving me the power of facades with almost no overhead.

Where my service class is used in the methods that attempt to update Mailcoach, I simply change the use statement, prefixing it with the real-time facades root namespace: Facades\.

Then I update the method calls to be static so that they get proxied through the facade.

-use App\Services\Mailcoach;
+use Facades\App\Services\Mailcoach;
3 
-$subscriber = (new Mailcoach)->getSubscriberByEmail(auth()->user()->email);
+$subscriber = Mailcoach::getSubscriberByEmail(auth()->user()->email);

That's it! Everything basically stays the same and it all works exactly the same as it did. Amazing.

This feels very low risk, very low effort.

I now just need to go and fix my tests.

In these two cases it simply means updating them so that they set this real-time facade up to be mocked.

This swaps out the real class instance in the facade with the mock instance at runtime, just in time:

+use Facades\App\Services\Mailcoach;
2 
3test('that a developer profile can be approved', function () {
4 $user = User::factory()->has(Developer::factory())->create();
5 
+ Mailcoach::shouldReceive('getSubscriberByEmail')
+ ->once()
+ ->with($user->email)
+ ->andReturn([
+ 'data' => [
+ [
+ 'uuid' => '123',
+ ],
+ ],
+ ]);
16 
17 // Rest of my test...
18 
19});

This is great because I don't have to write a whole load of boilerplate to put the Mailcoach class in the container or the code needed to swap it for my mock instance in my tests—all of that is abstracted away for me by the framework.

In my case, this works because this service class doesn't have any dependencies or constructor parameters. If you're doing this with a class that does, you'll need to bind the underlying class to the container so that you can take care of its dependencies.

Is this good practice?

Frankly, no. I still don't like this for a number of reasons:

  1. The dreaded squigglies! The IDE complains because of the magic at work here. That Facades\ namespace prefix is a bit too magic, even for me.
  2. It's a patch at best and at worst it feels like a hack. It shouldn't be relied on long-term.
  3. My tests suddenly feel bloated with this mock logic in them, and while these simple examples aren't particularly complicated, the tests are still quite brittle.
  4. Mocking this service class is convenient for now, but a more robust API testing solution would be better 🤠.

But I'm a pragmatist. I have limited time and this solution has allowed me to move on from this minor issue without breaking functionality or forcing a riskier rewrite.

My tests are passing again ✅ and my test suite is not hitting an external API anymore. 👌🏼

This already smells a lot better.

So I'm happy with this for now.

I will come back and improve this further as time allows or when necessity strikes.

But until then, ride hard, you fearless real-time facade!