4

I have a question concerning "unit tests" and object-inheritance. For example:

I have a class A which extends class B. Let's assume the only difference of the two classes is the add method . In class B this add method is slightly extended. Now I want to write a unit test for the add function of class B but because of the parent::add call I have a dependency to the parent class A. In this case I can't mock the add method of the parent class so the resulting test will be a integration test but if I want it to be a unit test? I don't want the the test for method add in class B fails because of the parent method in class A. In this case only the unit-test of the parent method should fail.

class B exends A
{
    public function add($item)
    {
        parent::add($item);
        //do some additional stuff  
    }
    ....
}

class A
{
    protected $items = [];
    public function add($item)
    {
        $this->items[] = $item;
    }
    ....
}

Surely I could use object-aggregation and pass my parent object to the child contructor and therefore I would be able to mock the parent method add, but is this the best approach? I would rarely use object-inheritance anymore.

class B
{
    protected $a;

    public function __contruct(A $a)
    {
        $this->a = $a;
    }

    public function add($item)
    {
        $this->a->add($item);
        //do some additional stuff  
    }
    ....
}

class A
{
    protected $items = [];
    public function add($item)
    {
        $this->items[] = $item;
    }
    ....
}

I would be very grateful for your opinions. Thanks!

0

1 Answer 1

2

Ask yourself, what kind of inheritance do you want to achieve? If B is a kind of A, then you're wanting interface inheritance. If B shares a lot of code with A, then you're wanting implementation inheritance. Sometimes you want both.

Interface inheritance classifies semantic meaning into a strict hierarchy, with that meaning organized from generalized to specialized. Think taxonomy. The interface (method signatures) represent the behaviors: both the set of messages to which the class responds, as well as the set of messages that the class sends. When inheriting from a class, you implicitly accept responsibility for all messages the superclass sends on your behalf, not just the messages that it can receive. For this reason, the coupling between super- and sub-class is tight and each must strictly substitute for the other (see Liskov Substitution Principle).

Implementation inheritance encapsulates the mechanics of data representation and behavior (properties and methods) into a convenient package for reuse and enhancement by sub-classes. By definition, a sub-class inherits the interface of the parent even if it only wants the implementation.

That last part is crucial. Read it again: Sub-classes inherit the interface even if they only want the implementation.

Does B strictly require the interface of A? Can B substitute for A, in all cases matching co-variance and contra-varience?

  • If the answer is yes, then you have true sub-typing. Congratulations. Now you must test the same behaviors twice, because in B you are responsible for maintaining the behaviors of A: for every thing A can do, B must be able to do.

  • If the answer is no, then you merely need to share the implementation, test that the implementation works, then test that B and A separately dispatch into the implementation.

In practical terms, I avoid extends. When I want implementation inheritance, I use trait to define static behaviors in one place, then use to incorporate it where needed. When I want interface inheritance, I define many narrow interface then combine with implements in all the concrete types, possibly using trait to leverage behavior.

For your example, I'd do this:

trait Container {
    public function add($item) { $this->items[] = $item; }
    public function getItems() { return $this->items; }
    private $items = [];
}

interface Containable { public function add($item); }

class A implements Containable { use Container; }

class B implements Containable {
    use Container { Container::add as c_add; }
    public function add($item) {
        $this->c_add($item);
        $this->mutate($item);
    }
    public function mutate($item) { /* custom stuff */ }
}

Container::add and B::mutate would have unit tests, while B::add would have an integration test.

In summary, favor composition over inheritance because extends is evil. Read the ThoughtWorks primer Composition vs. Inheritance: How To Choose to gain a deeper understanding of the design trade-offs.


"static behaviors", you ask? Yes. Low-coupling is a goal and this goes for traits. As much as possible, a trait should reference only variables it defines. The safest way to enforce that is with static methods that take all their input as formal arguments. The easiest way is to define member variables in the trait. (But, please, avoid having traits use member variables that are not clearly defined in the trait -- otherwise, that's blind coupling!) The problem, I find, with trait member variables is that when mixing in multiple traits you increase the chance of collision. This is, admittedly, small, but it is a practical consider for library authors.

Sign up to request clarification or add additional context in comments.

3 Comments

Thanks for your detailed answer, I really appreciate your effort! But I am not sure if I want to use static trait's here. I expanded my example above and the add method now adds some new $item to the $items property of A and I don't think a static trait would be very useful here. I am also not a very big fan of static methods because they are not mockable. I also found this useful article about traits. I'm not implying that the usage of extends is the better approach.
Rather than focus on the static as an implementation, focus on the purpose: to keep the trait decoupled from the consumer. Having a trait that declares and manages its own internal state is perfectly fine. See my edit.
Thx! It's a very interesting approach. I will look further into this.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.