If you consider an
Interface declaration in PHP, it ensures that the implementing class matches the method signatures defined. However, an
Interface also comes with some implicitly expected behaviours associated with these methods. We can document what this behaviour is supposed to be - but using a shared-behaviour-like approach we can assert it programmatically in our test suite.
This same logic can also be applied to
Abstract classes and
Traits. I'm personally not a fan of testing an abstract class by having a Mock object inherit from it, because this isn't how it's actually used in the real system. The fact that a particular class inherits from an
Abstract is actually an implementation detail, the only part we care about is that it exhibits the behaviour required.
You can think of a Shared Behaviour as a runtime Interface declaration. An Interface statically states "This class implements this set of functionality", a Shared Behaviour proves "This class implements this set of functionality".
The example we're using is some standard behaviour that we'll use across all of our collection classes. These collections receive their data as an associative array via a JSON API. The API also provides an array of IDs in the order that the collection should be iterated over - this is required because JSON doesn't guarantee the order of elements in its collection type. The collection classes handle converting to appropriate model classes, in addition to iterating in the correct order.
If this was an interface, it would look something like this:
- Write tests for a single concrete implementation of this interface
- Implement a class which implements the interface and passes the tests
- Move the standard behaviour specified in the test into a shared example group using a trait
- Implement a second class which implements the interface by creating a default implementation using an abstract base class
We wrap up some of the instance creation boilerplate with some protected methods, but otherwise a pretty ordinary unit test.
If you like, you can read the implementation of this class, but there's not much to it.
Nothing particularly unusual so far, now we'll look at how we can reduce code and test duplication when we have more than one collection class.
Traits to the rescue!
To give you a taster, here's the two test classes after we've moved their shared behaviour into the trait. The second list class works a little differently, but still exposes the same interface. It also includes a higher-level iteration method specific to its own use-cases.
And now for the meat, the trait itself that makes this all work.
Note that this is almost identical to the single test version, but we've extracted the implementor-specific bits into method that the real test class can fill in later.
You can see the full code example, including the abstracted implementations of AppleList and AddressList in this gist.
Hopefully the example I've chosen was suitable for conveying the idea. Rather than artificially testing an abstract class, we can structure tests to test the behaviour exhibited. The fact we're using an abstract class to share code becomes an implementation detail, rather than part of the interface contract. In my book, decoupling behaviour from implementation is always a good thing!
This approach should be applicable in any scenario where you have a number of classes exhibiting the same behaviour, regardless of whether they achieve this through inheritance, composition, or even copy-paste!
Thanks to Craig for running into the problem in the first place, as well as doing most of the implementation while I yelled suggestions over his shoulder.