Goblin Injection: A Novel Way Of Testing Static Services In PHP ๐ง
A crafty helper on the road to a testable codebase
If you have ever worked with (legacy) PHP codebases then you might have come across (service) classes filled without state and filled with static functions. While this pattern allows you to easily use any service method from any place, it is generally considered hard to test because of the impossibility to reliably mock static methods. In this article, we explore a novel way that might help us move gradually toward sanity!
Note: this article assumes a working knowledge of Dependency Injection and Mocking.
The problem
Let us start with an example:
<?php
class StaticService {
public static function isMeaningOfLife(): bool {
return ExternalAPI::expensive() === 42;
}
}
class StaticService2 {
public static function isNotMeaningOfLife(): bool {
return !StaticService::isMeaningOfLife();
}
}
class ExternalAPI {
public static function expensive(): int {
wait(23);
return 42;
}
}
Outlined are three services that expose only static methods. Say we want to test whether StaticService::isMeaningOfLife()
behaves as expected, but we don't want to wait 23 seconds. How could we go about this? Because the call is static there is no way to mock it because there is no way to inject it. There are a few options, such as 'monkey patching' where you dynamically instruct the autoloader to load a completely different class or by creatively using late static binding like phake offers. However, these methods are notoriously unstable and hard to maintain for a number of reasons, including:
In the case of monkey patching you need to specify a real class in a real file for every class you want to mock, creating a lot of boilerplate and complexity (to the point where you might want to start testing your tests...).
Both methods introduce state in opaque ways that will persist over multiple tests unless you specifically reset it. Making it hard to reason about and isolate tests.
So given the current situation, we are out of luck and will either have to wait the full 23 seconds or embark on a potentially treacherous path...
<?php
class StaticServiceTest extends TestCase {
//This will take a very long time :(
public function testIsMeaningOfLife() {
$this->assertTrue(StaticService::isMeaningOfLife());
}
}
The problematic solution
A much more versatile and therefore preferred way of talking to services is via 'Dependency Injection' (DI). DI allows you to inject all dependencies from the outside, enabling very flexible testing strategies and several other advantages (too in-depth to cover here, there are lots of other excellent resources to learn). However, refactoring an application with a static architecture toward DI can be quite a challenge. Often, classes with dependencies are themselves dependencies of other classes and introducing a constructor would require you to also refactor those classes (clients) because it's now their responsibility to provide the dependencies. And like pulling a thread on a shirt, it starts small but before long the whole shirt is gone.
Continuing with our previous example. To not wait 23 seconds we would have to change the behavior ExternalAPI
, but since StaticService
is static we have no way of modifying this dependency from the outside. Luckily for us, you can call static methods on objects in PHP, so we don't have to refactor ExternalAPI::expensive()
as it can remain static. However, if we refactor StaticService
towards DI by introducing a constructor, we also have to refactor StaticService2
since it's using ::isMeaningOfLife()
. Even more problematic, in order to use this constructor StaticService2
now needs to know and get its hands on ExternalAPI
(a detail neatly abstracted away before).
As you might begin to understand, in a real-life codebase with many interdependencies, refactoring one class towards DI might force you to refactor a large part of the application at once.
In software engineering, as a rule, we try to prevent large overhauls and aim to introduce change incrementally. That way it's easier to write the code, easier to review before deployment, and easier to find out what went wrong when things eventually go sideways.
The crafty solution
In most languages you are out of luck, but PHP has some strange properties that allow us to cheat a bit and refactor toward DI incrementally. I, somewhat mockingly, call this method Goblin Injection. Because let's be honest, even though it's crafty it does feel a bit dirty...
However, by using this method we can refactor towards DI on a per-class basis. This means that any dependencies or clients of this class do not need to be refactored. Effectively circumventing the whole thread-pulling-scenario described earlier.
First we will look at the solution and then we will try to explain why exactly this works.
<?php
class StaticService {
//If nothing is injected we will default to ExternalAPI::class
private static $externalAPI = ExternalAPI::class;
public function __construct(?ExternalAPI $externalAPI) {
if ($externalAPI !== null) self::$externalAPI = $externalAPI;
}
public function isMeaningOfLife(): bool {
//Very strange syntax, I know, we will come back to this
return self::$externalAPI::expensive() === 42;
}
}
//The client class StaticService2 will remain untouched
//The dependency class ExternalAPI will remain untouched as well
<?php
class StaticServiceTest extends TestCase {
//Still works as a static service and is still slow
public function testIsMeaningOfLifeSlow() {
//Clients like StaticService2 do not need to be refactored
$this->assertTrue(StaticService::isMeaningOfLife());
}
//This will be super fast!
public function testIsMeaningOfLifeFast() {
//Create a mock of our dependency
$mockExternalAPI = new class extends ExternalAPI {
public static function expensive(): int { return 42; }
}
//Inject mock into our class to be tested
$service = new StaticService($mockExternalAPI);
$this->assertTrue($service::isMeaningOfLife());
}
}
It looks a bit funny, right? The strange thing about PHP is that you can call a static method on both a class instance and its Fully Qualified Namespace (FQN), using exactly the same syntax.
Let us illustrate that with some code:
<?php
class Example {
public static function test(): int {
return 23;
}
}
//Both legal ways of calling static methods
$example = new Example();
$example::test(); //class instance
$example = ExternalAPI::class;
$example::test(); //FQN
Let's try to break down what we did, step by step:
Introduce a constructor into the class that we want to test (
StaticService
). This constructor will take as a nullable argument the dependency we want to mock (ExternalAPI
). Never mind that this dependency only exposes static methods. It's still a class so we can instantiate it.If we decide to inject this dependency class then we will set it as a static property called
$externalAPI
. However, if we do not inject this dependency class, the argument will become null and our static property to will default toExternalAPIService::class
. This is where the magic comes in: Because as I mentioned, you can call static methods on both Fully Qualified Namespaces AND instances of objects. It works either way with the same syntax.We replace all calls to our static dependency with the newly introduced static property. So in this case
ExternalAPI::expensive()
becomesself::$externalAPI::expensive()
. This will make sure that if we do inject something, the method gets called on the injected class instance and if we don't inject anything it will be called on the FQN.In our test we can now make a mock of
ExternalAPIService
, overwrite the expensive method with something that allows us to test what we want and inject it into our newly introduced constructor. After this, we can call the method we want to test without having to wait 23 seconds! For creating the mock you can choose whatever library you want. For more information on making mocks without a library like I am doing here check out this excellent article.
That's all folks!
Via this road we have now satisfied the following properties:
All clients of
StaticClass
can still use all of its methods statically and thus do not need to be refactored (seetestIsMeaningOfLifeSlow()
).Our dependency class
ExternalAPI
does not need to be altered.Thus any other clients of
ExternalAPI
do not need to be altered.Any other static dependencies of
StaticService
can remain as they are, they do not need to be injected. Allowing you to refactor every dependency incrementally also within a class.The introduced code for
StaticService
looks pretty similar to normal dependency injection, requiring minimal refactoring in the next step.Some IDEs (at least PHPStorm) are smart enough to recognize that the static property defaults to
ExternalAPI::class
and will provide full code intelligence out of the box (including automatic refactors).
In other words, we have introduced the ability to alter the behavior of ExternalAPI
in our tests for StaticService
in complete isolation. This way we can introduce tests with mocks for one or a few classes without having to do a huge overhaul all at once.
Next steps
While this is all good and well, we might not want to stay in Goblin territory forever. Let's say we want to refactor the whole codebase to DI. What could be some next steps?
Refactor all other clients of a dependency class toward 'goblin injection', similar to what we did before.
Refactor the dependency class itself toward normal non-static methods and update all clients.
Example:
<?php
class StaticService {
private ExternalAPI $externalAPI;
public function __construct(?ExternalAPI $externalAPI = null) {
//Building this locally gets very painful, very fast
if ($externalAPI === null) $this->externalAPI = new ExternalAPI();
}
public function isMeaningOfLife(): bool {
return $this->externalAPI->expensive() === 42;
}
}
class ExternalAPI {
public function expensive(): int {
wait(23);
return 1337;
}
}
Remember to also update your tests:
<?php
class StaticServiceTest extends TestCase {
public function testIsMeaningOfLifeFast() {
//Create a mock of our dependency
$mockExternalAPI = new class extends ExternalAPI {
public function expensive(): int { return 42; }
}
$service = new StaticService($mockExternalAPI);
$this->assertTrue($service->isMeaningOfLife());
}
}
- Rinse and repeat for all clients of those classes until the whole codebase is refactored.
class StaticService {
public function __construct(private readonly ExternalAPI $externalAPI) {}
public function isMeaningOfLife(): bool {
return $this->externalAPI::expensive() === 42;
}
}
class StaticService2 {
public static function isNotMeaningOfLife(): bool {
//It's already getting more painful
return !(new StaticService(new ExternalAPI())->isMeaningOfLife;
}
}
DI Containers
As you can see in the last examples: in step 2 we have to instantiate ExternalAPI
in the StaticService
constructor. In step 3 we even have to instantiate both StaticService
and ExternalAPI
in StaticService2
. Since these example classes have no other dependencies this might not seem to be a big deal, but you will probably not be that lucky in real life.
This is an excellent time to start using a DI container, preferably one that supports auto-wiring (PHP-DI for instance). This way you don't have to worry about constructing dependencies yourself, best case scenario they will be automatically injected from the container.
In the end
The choice is yours. If you want to test just one or a few classes: you can refactor those in isolation and be done with it. But rinse and repeat steps 1 to 3 enough times and soon enough you'll be on your way to completely testable architecture. Goblin mode, engage!
Ideas for future research
Since it's such a clearly defined process it should be possible to automate this with something like Rector. I have not played around with this yet but plan to do so in the future.
Request for the reader
Do you have a favorite resource that explains DI and or mocking? Do you have comments or suggestions? Please do not hesitate to reach out via the comments or send me an email at spammy+hashnode@strijdhorst.me