Automocking Dependencies
A way to speed up your test-driven PHP development through automation...
Update 2020-11-26: I’ve updated this post to reflect the new OpenContainer repository location and PHP 7.4 syntax.
I created OpenContainer to assist in the refactoring of legacy PHP code at MindTouch. The goal was to shore up the stability of the codebase as we transitioned from six-month waterfall-driven software delivery cycles to continuous delivery twice per week. While the majority of critical business logic was implemented and exposed via APIs in a C# codebase with (fairly) okay test coverage, PHP was used to marshall data from these APIs and deliver a product experience. There were two major challenges to achieving reasonable stability and reliability for the PHP codebase:
- The codebase lacked any automated unit or integration testing
- Nearly all code relied on static functions or global state, with no formalization in code of the relationship between dependencies
The latter situation, in particular, got under my skin. I’ve never particularly been a fan of dynamic or loosely typed languages (it bothers me that we are so quick to throw decades worth of type system research out the window for so-called flexibility). Just about every global variable that could be mutated from anywhere across the codebase was mutated, usually as side effects of seemingly unrelated routines. If code paths were going to be tested, I first needed to understand what these paths were and to do that I had to define what was needed (depended upon) for any given scenario.
1 | class XyzzyService { |
Oh yea, there is absolutely nothing wrong with that scenario at all. XyzzyFactory::newXyzzy
relies on a global variable that is set out-of-band in the function that calls it. Foo::getXyzzy
cannot set different factory settings for testing and mutates a seemingly unrelated application state variable and ruins some other downstream component’s day.
I’m a tremendous fan of dependency injection as a concept (particularly by object constructor). With dependency injection properly leveraged, not only do I understand what the bare minimum requirements are for a software component to work, but the dependencies themselves can be provided as interfaces, with their actual implementations provided by particular use cases.
1 | class XyzzyService implements IXyzzyService { |
Here I have achieved two benefits. Foo
and XyzzyService
both clearly define their outside dependencies by fetching them from a shared container. No untraceable global variables are overwritten, and I can step through this code in a debugger. I’d likely have a problem with the Foo::__construct
method changing the internal state of the container’s IXyzzySettings
instance, but the point is: at least I can track that down and identify when components may be altering state in a way that creates unintended consequences. Furthermore, now that the dependencies for Foo
and XyzzyService
are provided as interfaces, I can implement whatever state I want for IXyzzySettings
. When I test Foo
, I can even substitute a mock or dummy object for IXyzzyService
, which leads me into mocking this container - or more specifically, automocking.
Automocking is what greatly increased my ability to quickly write tests to cover the behavior that I was converting from static and global implementations. The process went something like this: manually test the desired “hot” paths (based on the original product spec, if it existed), update the code, manually test again, lock down the behavior with a unit test, rinse, and repeat. Automocking removed the need to individually create mocks for all the possible dependencies that could exist in the container. It can be very tedious to identify all injected dependencies in an object and set them up as mocks in the event that they may be needed for a particular test. Failure to do so would often lead to null reference exceptions in the object’s constructor when just trying to initialize it for testing.
1 | class Qux { |
For this scenario, I only need to test Qux
with different implementations of IBar
, but this object can’t initialize because IFoo
is not mocked and getValue
is called on a null reference. That’s pretty annoying - I have to mock a dependency I don’t care about. If only someone else could do it! 😂
The following sections assume that you have checked out OpenContainer and have familiarized yourself with the library. In short, the key piece we will leverage here is the @property
PHPDoc value that we use with OpenContainer to create type hint friendly container dependencies (if you have a PHP IDE with intellisense such as JetBrains PHPStorm).
1 | use modethirteen\OpenContainer\IContainer; |
This container interface contains type hints for all the possible dependencies that could be registered in this container. One drawback to this approach is that it takes some diligence to remember to add a @property
to the interface every time a new dependency is registered in the container. Our new MockContainer
will implement this interface and provide some behavior to automatically generate PHPUnit mocks for these dependencies.
1 | use phpDocumentor\Reflection\DocBlockFactory; |
Our MockContainer
uses phpDocumentor and PHPUnit to parse our container interface @property
values and auto-generate mocks. In our tests, we can now MockContainer::getMock
any dependency we need to set expectations on. We can use MockContainer::proxyMock
to provide a concrete instance, with optional mocked properties and functions - a sort of hybrid approach that is influenced by JavaScript testing practices like spies.
1 | use PHPUnit\Framework\TestCase; |
Applying these patterns has transformed a codebase that was responsible for a production bug nearly every week, to arguably one of the most covered with tests and most reliable in the entire platform. That level of confidence has a huge benefit on developer morale, especially on those who may be new to the codebase and are concerned with introducing bugs in an unfamiliar environment. Reliable dependency management and testing won’t ever entirely eliminate bugs (your unique production situations and data will always see to that), but it can get you very close!