An adventure in dependency breaking - guidelines and proposals
We have tasked ourselves with an interesting challenge:
to make one of the core business functions in PrestaShop unit testable. The function we chose, Cart::getOrderTotal
, performs price computations in the shopping cart - it's the one that tells your customers how much they're going to pay. That's kind of useful.
We chose to focus on this function because it is large and complex enough to demonstrate a number of different techniques.
This post shows our progress and leads to a proposal for a new software architecture for PrestaShop. The new architecture is intended to be lightweight and safe.
The issues with Cart::getOrderTotal
Cart::getOrderTotal
is a ~300 lines long instance method. It makes it hard to reason about.
You cannot instantiate a Cart
object to test the getOrderTotal
method because the constructor indirectly accesses the database.
To make things even more interesting, the method makes static calls to methods on at least three different classes: Configuration
, Address
, Product
.
A large number of execution paths are conditioned on the value of different global variables that getOrderTotal
accesses implicitly.
We set out to write a meaningful unit test on Cart::getOrderTotal
.
Before we dive into the changes we had to make, here is a preview of the end result - one of the unit tests we were able to write:
public function test_getOrderTotal_Round_Line_When_No_Tax()
{
$this->setRoundType(Order::ROUND_LINE);
// Add 3 products each with (pre-tax) price 10.125
$this->productPriceCalculator->addFakeProduct(new FakeProduct(3, 10.125));
// Add 1 product with (pre-tax) price 10.125
$this->productPriceCalculator->addFakeProduct(new FakeProduct(1, 10.125));
$orderTotal = $this->cart->getOrderTotal(false, Cart::ONLY_PRODUCTS, $this->productPriceCalculator->getProducts());
$this->assertEquals(40.51, $orderTotal);
}
This test simulates adding 4 products to the cart and checks that getOrderTotal
produces the expected total before tax.
The test does not make any access to the database.
In this chapter we try to put a name on different techniques we have used and extract guidelines about the cases in which we believe they are appropriate.
This technique is one of the least intrusive. The problem it tries to solve is: there is a static access to an application singleton deeply nested in some code that we do not care about for the purpose of our test.
In our case the Db
class was accessed indirectly upon instantiation of a Cart
object though a sequence of calls leading to a static access to Group
that performs actions that have nothing to do with the computation of the total of the Cart.
We could have gone deep into the call stack and broken the dependency to the database there, directly in Group
.
But it might have been dangerous: we would have had to change code that we did not intend to test.
So to keep things simple and not lose focus on getOrderTotal
we just mocked the whole Db
instance, injecting it into the application singleton with an ad-hoc static method that was added to Db
:
public function setupDatabaseMock()
{
$this->database = Phake::mock('Db');
Db::setInstanceForTesting($this->database);
}
And setInstanceForTesting
is straightforward:
class Db
{
/*
...
*/
/**
* @param $test_db Db
* Unit testing purpose only
*/
public static function setInstanceForTesting($test_db)
{
self::$instance[0] = $test_db;
}
/*
...
*/
}
Note the ForTesting suffix: it warns developers not to use this in production code. This is only a test helper.
The exact same technique is used to inject a fake context into the Context
application singleton.
The boilerplate code for this is factored out into the UnitTestCase::setup
method so that the next tests are easier to write.
Great, now we can instantiate a Cart
object (new
it)!
This technique solves the same type of issues as the Application Singleton Injection does. It breaks dependencies. The primary difference is that we use it in the code that we are working on. Not deep into the dependency chain.
The goal is to make the previously hidden dependency very visible and to have full control over the component.
Ideally, when we discover a hidden dependency in the code under test, we should find a way to make the dependency trickle down from the top layers of the application right into the code under test.
This is not always possible (when no predictable execution path leads to the code under test for instance), so a temporary solution is to use a ServiceLocator.
It looks like this:
public function getOrderTotal($with_taxes = true, $type = Cart::BOTH, $products = null, $id_carrier = null, $use_cache = true)
{
// Dependencies
$address_factory = \PrestaShop\PrestaShop\Adapter\ServiceLocator::get('\\PrestaShop\\PrestaShop\\Adapter\\AddressFactory');
$price_calculator = \PrestaShop\PrestaShop\Adapter\ServiceLocator::get('\\PrestaShop\\PrestaShop\\Adapter\\Product\\PriceCalculator');
$configuration = \PrestaShop\PrestaShop\Adapter\ServiceLocator::get('Core_Configuration');
// Code...
}
On the unit test side, setting up the mocked objects follow this pattern:
$this->container = new \PrestaShop\PrestaShop\Core\Foundation\IoC\Container;
\PrestaShop\PrestaShop\Adapter\ServiceLocator::setServiceContainerInstance($this->container);
$addressFactory = Phake::mock('\\PrestaShop\\PrestaShop\\Adapter\\AddressFactory');
$address = new Address;
$address->id = 1;
Phake::when($addressFactory)->findOrCreate()->thenReturn($address);
$this->container->bind('\\PrestaShop\\PrestaShop\\Adapter\\AddressFactory', $addressFactory);
What's happening here is:
- We create a Dependency Injection Container instance specifically for our tests
- We tell the ServiceLocator to fetch dependencies from our container
- We mock our dependency (
\\PrestaShop\\PrestaShop\\Adapter\\AddressFactory
here) - We bind the mocked dependency to the ServiceContainer instance
- In the code under test, the ServiceLocator only talks with our test container and pulls the dependencies we crafted from there
The classes in the Adapter_
namespace (in the Adapter
directory) make a bridge between the legacy code (the thing we're trying to write tests on) and the cleaner code (the new things we're going to write, which will have tests from the start).
Code from the non-legacy part should only call legacy code through an adapter. Adapters should eventually disappear and be replaced with new, non-legacy code.
Code in an adapter might:
- wrap calls to code in the legacy part
- call new, non-legacy code
As a first step, an Adapter can just use the same code as the legacy part it replaces.
For instance our \\PrestaShop\\PrestaShop\\Adapter\\AddressFactory
class:
<?php
class AddressFactory
{
public function findOrCreate($id_address = null, $with_geoloc = false)
{
return call_user_func_array(array('Address', 'initialize'), func_get_args());
}
public function addressExists($id_address)
{
return Address::addressExists($id_address);
}
}
It just forwards the calls to existing legacy code.
We replaced a line in Cart
from:
$address = Address::initialize($id_address, true);
to:
$address = $address_factory->findOrCreate($id_address, true);
But the same exact code as before is called, which is as safe as can be.
Now that our dependencies are safely exposed we have a solid starting point for further work:
if \\PrestaShop\\PrestaShop\\Adapter\\AddressFactory::findOrCreate
does strange things we're not comfortable with, and if we have strong unit tests on the code that uses the adapter, then we can start delegating work from \\PrestaShop\\PrestaShop\\Adapter\\AddressFactory
to non-legacy code instead of using the copy-pasted logic from the legacy code.
Code falls into one of the following 3 categories
PrestaShop/
|-- Core/
| |-- Foundation/
| └-- Business/
|-- Adapter/
└-- *Legacy*/
Where Legacy
is not itself a directory but represents any file or directory in the current file tree that is not in Core
or Adapter
.
The purpose of each directory and the interactions between code in the 3 categories are described in the sections below.
Core
is for code covered by unit tests.
No new class may be added to the Core
directory if it doesn't have unit tests.
No global access to variables (or even constants!) is allowed inside the Core
directory. This also means, no ServiceLocator
here.
Dependencies of below-top-level components should be injected by an application layer that is higher up in the layer hierarchy.
The handling of a typical HTTP request might look something like this:
index.php
- loads the system configuration...
- creates a dependency injection container...
- binds general purpose services like the database to the dependency injection container...
new
s aDispatcher
, passing the container to it
- the
Dispatcher
creates theController
it needs, passing the dependency injection container to it - the
Controller
- gets the dependencies it needs from the dependency injection container...
- delegates its work to services (services that it gets from the container or
new
s)... - sends the response!
As a general rule, in the schema above, the Controller
is the last application layer which has access to the dependency injection container.
Inside the Core
directory, Business
is for business logic (e.g. how do I compute the tax for a product?), while Foundation
is reserved for general purpose logic (e.g. dependency injection, database interaction). In theory it should be possible to extract any components in Foundation
to their own composer packages so that other projects could use them.
Code inside Core
is not allowed to call code inside Legacy
.
Adapter
is a layer between Core
and Legacy
.
Inside Adapter
, code may call any code from either Core
, Adapter
or Legacy
.
The Adapter
file hierarchy should eventually go away, so developers are encouraged to use Core
code as much as possible when writing adapters.
Adapters should be used when we want to break a dependency in the code under test and when breaking the dependency cleanly is too risky.
The life cycle of an adapter is roughly as follows:
- Debugging / Refactoring Stage
- Create
Adapter_SomeService
to break a static dependency toSomeService
in the code you're working on- Adapter should be minimal at first - it might just forward a call to some legacy function.
- Use
\PrestaShop\PrestaShop\Adapter\ServiceLocator::get('Adapter_SomeService')
in the code under test to retrieve an instance of the service we depend on - Write tests, refactor...
- Create
- Architecture Improvement Stage: With time and experience, the precise responsibilities of
Adapter_SomeService
have been clearly identified and tested- Write a new
Core_SomeService
class inside theCore
file hierarchy, test it thoroughly - Delegate work from
Adapter_SomeService
toCore_SomeService
, keeping the\PrestaShop\PrestaShop\Adapter\ServiceLocator::get('Adapter_SomeService')
calls in the code where proper dependency injection is still too hard to do. Propagate theCore_SomeService
dependency in a better way where possible.
- Write a new
- Adapter Removal: At some point, if enough code has been cleaned up, the
Adapter_SomeService
might not be needed any more- Remove all calls to
\PrestaShop\PrestaShop\Adapter\ServiceLocator::get('Adapter_SomeService')
and replace them with proper dependency injection - Delete
Adapter_SomeService
. - Profit!
- Remove all calls to
Legacy
code is any code that is not inside Core
or Adapter
.
Our goal is to gradually move the code from Legacy
towards Core
.
As an intermediate step, code in Legacy
is expected to make heavy use of the \PrestaShop\PrestaShop\Adapter\ServiceLocator
to break dependencies.