Mock only one method on PHPSpec stubs

2.1k views Asked by At

Okay, so I'm trying to move one of my packages over to PHPSpec tests, but soon I ran into this problem. The packages is a shoppingcart package, so I want to test that when you add two items to the cart, the cart has a count of two, simple. But of course, in a shoppingcart, when adding two of the same items, there will not be a new entry in the cart, but the original item will get a 'qty' of 2. So but not when they are, for instance, different sizes. So each item is identified by a unique rowId, based on it's ID and options.

This is the code that generates the rowId (which is used by the add() method):

protected function generateRowId(CartItem $item)
{
    return md5($item->getId() . serialize($item->getOptions()));
}

Now I had written my test like this:

public function it_can_add_multiple_instances_of_a_cart_item(CartItem $cartItem1, CartItem $cartItem2)
{
    $this->add($cartItem1);
    $this->add($cartItem2);

    $this->shouldHaveCount(2);
}

But the problem is, both stubs return null for the getId() method. So I tried setting the willReturn() for that method, so my test became this:

public function it_can_add_multiple_instances_of_a_cart_item(CartItem $cartItem1, CartItem $cartItem2)
{
    $cartItem1->getId()->willReturn(1);
    $cartItem2->getId()->willReturn(2);

    $this->add($cartItem1);
    $this->add($cartItem2);

    $this->shouldHaveCount(2);
}

But now I get errors, telling me that unexpected methods are called like getName(). So I have to do the same for all methods on the CartItem interface that are called:

public function it_can_add_multiple_instances_of_a_cart_item(CartItem $cartItem1, CartItem $cartItem2)
{
    $cartItem1->getId()->willReturn(1);
    $cartItem1->getName()->willReturn(null);
    $cartItem1->getPrice()->willReturn(null);
    $cartItem1->getOptions()->willReturn([]);

    $cartItem2->getId()->willReturn(2);
    $cartItem2->getName()->willReturn(null);
    $cartItem2->getPrice()->willReturn(null);
    $cartItem2->getOptions()->willReturn([]);

    $this->add($cartItem1);
    $this->add($cartItem2);

    $this->shouldHaveCount(2);
}

Now this works, test is green. But it feels wrong... Am I missing something or is this a limitation on PHPSpec?

3

There are 3 answers

3
l3l0 On

Yeah you can call that "limitation" of phpspec. Basically phpspec is strict TDD and object communication design tool IMO.

You see that adding $cartItem to collection do much more that you expects.

First one you do not have to use stubs (if you don't care about internal object communication) example:

function it_adds_multiple_instances_of_a_cart_item()
{
    $this->add(new CartItem($id = 1, $options = ['size' => 1]));
    $this->add(new CartItem($id = 2, $options = ['size' => 2]));

    $this->shouldHaveCount(2);
}

function it_adds_two_same_items_with_different_sizes()
{
    $this->add(new CartItem($id = 1, $options = ['size' => 1]));
    $this->add(new CartItem($id = 1, $options = ['size' => 2]));

    $this->shouldHaveCount(2);   
}

function it_does_not_add_same_items()
{
    $this->add(new CartItem($id = 1, $options = []));
    $this->add(new CartItem($id = 1, $options = []));

    $this->shouldHaveCount(1);   
}

You can do it other way as well. From communication perspective query many times same instance of object is not so effective. Many public methods mean many different combinations. You can plan communication and do something like that:

function it_adds_multiple_instances_of_a_cart_item(CartItem $cartItem1, CartItem $cartItem2)
{
   $this->add($cartItem1);
   $cartItem1->isSameAs($cartItem2)->willReturn(false);
   $this->add($cartItem2);

   $this->shouldHaveCount(2);
}

function it_does_not_add_same_items((CartItem $cartItem1, CartItem $cartItem2)
{
    $this->add($cartItem1);
    $cartItem1->isSameAs($cartItem2)->willReturn(true);
    $this->add($cartItem2);

    $this->shouldHaveCount(1);   
}
0
everzet On

So you are walking into a restaurant in order to have a dinner. You expect that you will be given a choice of meal, from which you will choose one you are actually interested to eat today and be charged for it at the end of the night. What you don't expect is that the restaurant will also charge you for the lovely couple next to you ordering bottle after bottle of Chteau Margaux 95. So when you do discover that you were charged for their meal too, you probably will want to immediately call that restaurant and your bank, cause that's totally not ok that this happened without you expecting it!

The question is not why PhpSpec forces you to stub methods you don't care about now. The question is why do you call the methods you don't care about now. If they are not part of your expectations, PhpSpec just calls your bank for you, because that's totally not ok that his happened without you expecting it!

0
Kacper Gunia On

Now this works, test is green. But it feels wrong... Am I missing something or is this a limitation on PHPSpec?

I think it's good it feels wrong in that case because it should. As @l3l0 mentioned above PHPSpec is a design tool and it gives you a clear message about your design here.

What you struggle with is fact that your Cart violates Single Responsibility Principle - it does more than one thing - it manages CartItems as well as knows how to generate RowId from it. Because PHPSpec forces you to stub whole behaviour of CartItem it gives you a message to refactor out generating RowId.

Now imagine you extracted RowIdGenerator to separate class (with it's own specs not covered here):

class RowIdGenerator
{
    public function fromCartItem(CartItem $item)
    {
        return md5($item->getId() . serialize($item->getOptions()));
    }
}

Then you inject this generator via constructor as a dependency to your Cart:

class Cart
{
    private $rowIdGenerator;

    public function __construct(RowIdGenerator $rowIdGenerator)
    {
        $this->rowIdGenerator = $rowIdGenerator;
    }
}

Then your final spec could look like:

function let(RowIdGenerator $rowIdGenerator)
{
    $this->beConstructedWith($rowIdGenerator);
}

public function it_can_add_multiple_instances_of_a_cart_item(RowIdGenerator $rowIdGenerator, CartItem $cartItem1, CartItem $cartItem2)
{
    $rowIdGenerator->fromCartItem($cartItem1)->willReturn('abc');
    $rowIdGenerator->fromCartItem($cartItem1)->willReturn('def');

    $this->add($cartItem1);
    $this->add($cartItem2);

    $this->shouldHaveCount(2);
}

And because you mocked behaviour of id generator (and you know this communication has to happen) now you conform to SRP. Does it feel better to you now?