Magento 2 – How to Properly Use getCollectionMock

collection;magento2mocksunit tests

TL;DR:
I would love an example of how Magento\Framework\TestFramework\Unit\Helper\ObjectManager::getCollectionMock() can be used for unit tests that involve collections and/or collection factories and a few words about how this works.

Long version

I'm trying to learn how to unit test my Magento 2 module.
I've reached the point where I need to test a class that has a dependency a collection factory.
So I need to mock this and return some dummy collection and I need a few items (can be 1) in this collection.
I found the method Magento\Framework\TestFramework\Unit\Helper\ObjectManager::getCollectionMock(), but I think I'm not using it properly because I always get an empty collection.

My class looks like this:

<?php
namespace Sample\News\Model\Source;

use Magento\Framework\Option\ArrayInterface;
use Magento\Directory\Model\ResourceModel\Country\CollectionFactory as CountryCollectionFactory;

class Country implements ArrayInterface
{
    /**
     * @var CollectionFactory
     */
    protected $countryCollectionFactory;
    /**
     * @var array
     */
    protected $options;

    /**
     * constructor
     *
     * @param CountryCollectionFactory $countryCollectionFactory
     */
    public function __construct(CountryCollectionFactory $countryCollectionFactory)
    {
        $this->countryCollectionFactory = $countryCollectionFactory;
    }

    /**
     * get options as key value pair
     *
     * @return array
     */
    public function toOptionArray()
    {
        if (is_null($this->options)) {
            $this->options = $this->countryCollectionFactory->create()->toOptionArray(' ');
        }
        return $this->options;
    }

    /**
     * @return array
     */
    public function getOptions()
    {
        $countryOptions = $this->toOptionArray();
        $_options = [];
        foreach ($countryOptions as $option) {
            $_options[$option['value']] = $option['label'];
        }
        return $_options;
    }
}

My test code looks like this:

<?php 
namespace Sample\News\Test\Unit\Model\Source;

use Magento\Framework\TestFramework\Unit\Helper\ObjectManager;
use Magento\Directory\Model\ResourceModel\Country\CollectionFactory;
use Magento\Directory\Model\ResourceModel\Country\Collection;
use Sample\News\Model\Source\Country;

class CountryTest extends \PHPUnit_Framework_TestCase
{
    protected $objectManager;
    protected $countryCollectionFactoryMock;
    /**
     * @var Country
     */
    protected $countryList;
    protected function setUp()
    {
        $this->objectManager = new ObjectManager($this);
        $this->countryCollectionFactoryMock = $this->getMockBuilder(CollectionFactory::class)
            ->disableOriginalConstructor()
            ->getMock();
        $countryMock = $this->getMockBuilder('\Magento\Directory\Model\Country')
            ->disableOriginalConstructor()
            ->getMock();
        $countryMock->method('getId')->willReturn(1);
        $countryMock->method('getTitle')->willReturn('Romania');

        $collection = $this->objectManager->getCollectionMock(Collection::class, [$countryMock]);
        $this->countryCollectionFactoryMock->method('create')->willReturn($collection);
        $this->countryList = $this->objectManager->getObject(
            Country::class,
            [
                'countryCollectionFactory' => $this->countryCollectionFactoryMock
            ]
        );
    }
    public function testToOptionArray()
    {
        $this->assertEquals(['1' => 'Romania'], $this->countryList->toOptionArray());
    }
}

My test fails saying that

null does not match expected type "array".

So I assume I'm using the getCollectionMock method wrongly.
I don't actually need someone to debug my code (though it would be nice), I would accept an example on how to use getCollectionMock and a few words about how it works.

Best Answer

TL;DR

You are using the getCollectionMock() method correctly.
The problem is that it only mocks the getIterator() method.
Your code however calls toOptionArray() on the collection, not getIterator().
Because the collection is a mock, by default all its methods only return null, (including toOptionArray()).
That is why your test fails with a null as the assertion value.

Long version

One possibility would be to create the collection mock excluding that toOptionArray() method from being mocked. This would be a "partial mock" and they are considered bad style, mainly because of other methods that are called internally that then also would need to be excluded and so on.

Another option would be to explicitly specify the behavior of the toOptionArray() method on the mock:

$collection->method('toOptionArray')->willReturn(['1' => 'Romania']);

In this case this probably also isn't what you want, as you are simply testing the mocks behavior, which is pointless.

So what to do?

In this case I think the most sensible thing to do would be to just check the source model calls toOptionArray() on the collection and returns the results, ignoring the format of the result (since that is core code you don't want to test).
For example:

protected function setUp()
{
    $objectManager = new ObjectManager($this);
    $countryCollectionFactoryMock = $this->getMockBuilder(CollectionFactory::class)
        ->setMethods(['create'])
        ->disableOriginalConstructor()
        ->getMock();

    $this->collectionMock = $objectManager->getCollectionMock(Collection::class, []);

    $countryCollectionFactoryMock->method('create')->willReturn($this->collectionMock);
    $this->countryList = $objectManager->getObject(Country::class, [
        'countryCollectionFactory' => $countryCollectionFactoryMock,
    ]);
}

public function testDelegatesToCollectionToCreateTheOptionsArray()
{
    $this->collectionMock->method('toOptionArray')->willReturn(['foo' => 'bar']);
    $this->collectionMock->expects($this->atLeastOnce())->method('toOptionArray');
    $this->assertSame(['foo' => 'bar'], $this->countryList->toOptionArray());
}

This test would fail if either the collections toOptionArray() method is not called or if the method doesn't return the same array that the mock returned.

Another example for a test that might make sense during TDD is to check the result collection method call is memoized:

public function testMemoizedOptionArray()
{
    $this->collectionMock->method('toOptionArray')->willReturn(['baz' => 'qux']);
    $this->collectionMock->expects($this->once())->method('toOptionArray');
    $result1 = $this->countryList->toOptionArray();
    $result2 = $this->countryList->toOptionArray();
    $this->assertSame($result1, $result2);
}

For this test to succeed the toOptionArray() method will have to be called exactly one time, and both method calls have to be exactly the same.

But what about getCollectionMock()???

To make real use of the mocked collection you have to write code that uses the collection iterator, for example by using it with foreach, or by calling getIterator() directly.

public function toOptionArray()
{
    if (is_null($this->options)) {
        foreach ($this->countryCollectionFactory->create() as $country) {
            $this->options[$country->getId()] = $country->getTitle();
        }
    }
    return $this->options;
}


}