StoryTeller
StoryTeller

Reputation: 1748

PHPUnit: How do I mock an EntityManager without mocking the Metadata?

Working with PHPUnit and Doctrine I frequently end up writing very large methods for mocking Doctrines ClassMetadata, although in my opinion it does not need to be mocked, because it can be seen as stable. Still I need to mock the EntityManager because I don't want Doctrine to connect to a database.

So here's my question: How do I get my ClassMetadata via the EntityManager mock without needing a database connection? For all eventual database calls the EntityManager still needs to be a mock, I just don't want to write all my metadata down again.

I'm using the DoctrineModule for Zend 2 so it would be useful to be able to use my configuration to get the Metadata object, but I assume it's also ok to read the required sections manually.

Example:

public function testGetUniqueFields()
{
    $this->prepareGetUniqueFields(); // about 50 lines of mocking ClassMetadata
    $entity = 'UniqueWithoutAssociation';
    $unique = $this->handler->getUniqueFields($entity);
    $expected = ["uniqueColumn"];
    $this->assertEquals($expected, $unique,
        'getUniqueFields does not return the unique fields');
}

And the code of the actual class:

public function getUniqueFields($class)
{
    $unique = array();
    $metadata = $this->getClassMetadata($class);
    $fields = $metadata->getFieldNames();
    foreach ($fields as $field) {
        if($metadata->isUniqueField($field) && !$metadata->isIdentifier($field)) {
            $unique[] = $field;
        }
    }
    return $unique;
}

The test works like expected, but every time I test another method or another behavior of the method, I need to prepare the mocks again or combine past definitions. Plus, the 50 lines I need for this code are the least I have in this test. Most of the test class is all about the ClassMetadata mock. It is a time consuming and - if you see ClassMetadata as a stable component - unnecessary work.

Upvotes: 1

Views: 1627

Answers (1)

StoryTeller
StoryTeller

Reputation: 1748

After spending many hours starring at the Doctrine source code, I found a solution.

Once again, this solution is only if you are working with Doctrines ClassMetadata object so often that it becomes unclean to mock every method call. In every other case you should still create a mock of ClassMetadata.

Still, since the composers minimum stability setting was set to stable, such components can be seen as stable so there is no absolute need to create a mock object.

ClassMetadata depends on several other Doctrine classes, which are all injected through the ubiquitous EntityManager:

  • Doctrine\ORM\Configuration to get the entity path

    • Doctrine\Common\Annotations\AnnotationReader and Doctrine\ORM\Mapping\Driver\AnnotationDriver injected through the Configuration object
  • Doctrine\DBAL\Connection to get the database platform in order to know about the identifier strategy. This object should be mocked so no database calls are possible

    • Doctrine\DBAL\Platforms\AbstractPlatform as mentioned
  • Doctrine\Common\EventManager to trigger some events

For single test methods or simple method calls I created a method returning an EntityManager mock object that is capable of returning a valid ClassMetadata object:

/**
 * @return EntityManager|\PHPUnit_Framework_MockObject_MockObject
 */
public function getEmMock()
{
    $dir = __DIR__."/Asset/Entity/";
    $config = Setup::createAnnotationMetadataConfiguration(array($dir), true);
    $eventManager = new \Doctrine\Common\EventManager();
    $platform = new PostgreSqlPlatform();
    $metadataFactory = new ClassMetadataFactory();
    $config->setMetadataDriverImpl(new AnnotationDriver(new AnnotationReader()));

    $connectionMock = $this->getMockBuilder('Doctrine\DBAL\Connection')
        ->disableOriginalConstructor()
        ->getMock();
    $connectionMock->expects($this->any())
        ->method('getDatabasePlatform')
        ->will($this->returnValue($platform));

    /** @var EntityManager|\PHPUnit_Framework_MockObject_MockObject $emMock */
    $emMock = $this->getMockBuilder('Doctrine\ORM\EntityManager')
        ->disableOriginalConstructor()
        ->getMock();
    $metadataFactory->setEntityManager($emMock);
    $emMock->expects($this->any())
        ->method('getConfiguration')
        ->will($this->returnValue($config));
    $emMock->expects($this->any())
        ->method('getConnection')
        ->will($this->returnValue($connectionMock));
    $emMock->expects($this->any())
        ->method('getEventManager')
        ->will($this->returnValue($eventManager));
    $emMock->expects($this->any())
        ->method('getClassMetadata')
        ->will($this->returnCallback(function($class) use ($metadataFactory){
            return $metadataFactory->getMetadataFor($class);
        }));
    return $emMock;
}

Here you could even manipulate all objects by calling their getters created for the EntityManager mock. But that wouldn't be exactly clean and the method remains inflexible in some cases. Still a simple solution and you could e.g. add some parameters and put the method in a trait to reuse it.

For further needs, I created an abstract class that offers a maximum of flexibility and allows you to mock everything else or create some components in a whole other way.

It needs two configurations: The entity path and the platform object. You can manipulate or replace any object by setting it in the setUp method and then get the required EntityManager mock with getEmMock().

A little bit larger, but here it is:

use Doctrine\Common\Annotations\AnnotationReader;
use Doctrine\Common\EventManager;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\ORM\Configuration;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Mapping\ClassMetadataFactory;
use Doctrine\ORM\Mapping\Driver\AnnotationDriver;
use Doctrine\ORM\Tools\Setup;

/**
 * Class AbstractTestWithMetadata
 * @author Marius Teller
 */
abstract class AbstractTestWithMetadata extends \PHPUnit_Framework_TestCase
{

    const EXCEPTION_NO_ENTITY_PATHS_SET = "At least one entity path must be set";

    const EXCEPTION_NO_PLATFORM_SET = "An instance of Doctrine\\DBAL\\Platforms\\AbstractPlatform must be set";

    /**
     * @var array
     */
    protected $entityPaths = [];
    /**
     * @var AbstractPlatform
     */
    protected $platform;
    /**
     * @var EntityManager
     */
    protected $emMock;
    /**
     * @var Connection
     */
    protected $connectionMock;
    /**
     * @var Configuration
     */
    protected $configuration;
    /**
     * @var EventManager
     */
    protected $eventManager;
    /**
     * @var ClassMetadataFactory
     */
    protected $classMetadataFactory;


    /**
     * @return array
     * @throws \Exception
     */
    public function getEntityPaths()
    {
        if($this->entityPaths === []) {
            throw new \Exception(self::EXCEPTION_NO_ENTITY_PATHS_SET);
        }
        return $this->entityPaths;
    }

    /**
     * @param array $entityPaths
     */
    public function setEntityPaths(array $entityPaths)
    {
        $this->entityPaths = $entityPaths;
    }

    /**
     * add an entity path
     * @param string $path
     */
    public function addEntityPath($path)
    {
        $this->entityPaths[] = $path;
    }

    /**
     * @return AbstractPlatform
     * @throws \Exception
     */
    public function getPlatform()
    {
        if(!isset($this->platform)) {
            throw new \Exception(self::EXCEPTION_NO_PLATFORM_SET);
        }
        return $this->platform;
    }

    /**
     * @param AbstractPlatform $platform
     */
    public function setPlatform(AbstractPlatform $platform)
    {
        $this->platform = $platform;
    }

    /**
     * @return EntityManager
     */
    public function getEmMock()
    {
        if(!isset($this->emMock)) {
            /** @var EntityManager|\PHPUnit_Framework_MockObject_MockObject $emMock */
            $emMock = $this->getMockBuilder('Doctrine\ORM\EntityManager')
                ->disableOriginalConstructor()
                ->getMock();

            $config = $this->getConfiguration();
            $connectionMock = $this->getConnectionMock();
            $eventManager = $this->getEventManager();
            $classMetadataFactory = $this->getClassMetadataFactory();
            $classMetadataFactory->setEntityManager($emMock);

            $emMock->expects($this->any())
                ->method('getConfiguration')
                ->will($this->returnValue($config));
            $emMock->expects($this->any())
                ->method('getConnection')
                ->will($this->returnValue($connectionMock));
            $emMock->expects($this->any())
                ->method('getEventManager')
                ->will($this->returnValue($eventManager));
            $emMock->expects($this->any())
                ->method('getClassMetadata')
                ->will($this->returnCallback(function($class) use ($classMetadataFactory){
                    return $classMetadataFactory->getMetadataFor($class);
                }));
            $this->setEmMock($emMock);
        }
        return $this->emMock;
    }

    /**
     * @param EntityManager $emMock
     */
    public function setEmMock($emMock)
    {
        $this->emMock = $emMock;
    }

    /**
     * @return Connection
     */
    public function getConnectionMock()
    {
        if(!isset($this->connectionMock)) {
            $platform = $this->getPlatform();
            /** @var Connection|\PHPUnit_Framework_MockObject_MockObject $connectionMock */
            $connectionMock = $this->getMockBuilder('Doctrine\DBAL\Connection')
                ->disableOriginalConstructor()
                ->getMock();
            $connectionMock->expects($this->any())
                ->method('getDatabasePlatform')
                ->will($this->returnValue($platform));
            $this->setConnectionMock($connectionMock);
        }
        return $this->connectionMock;
    }

    /**
     * @param Connection $connectionMock
     */
    public function setConnectionMock($connectionMock)
    {
        $this->connectionMock = $connectionMock;
    }

    /**
     * @return Configuration
     */
    public function getConfiguration()
    {
        if(!isset($this->configuration)) {
            $config = Setup::createAnnotationMetadataConfiguration($this->getEntityPaths(), true);
            $config->setMetadataDriverImpl(new AnnotationDriver(new AnnotationReader()));
            $this->setConfiguration($config);
        }
        return $this->configuration;
    }

    /**
     * @param Configuration $configuration
     */
    public function setConfiguration(Configuration $configuration)
    {
        $this->configuration = $configuration;
    }

    /**
     * @return EventManager
     */
    public function getEventManager()
    {
        if(!isset($this->eventManager)) {
            $this->setEventManager(new EventManager());
        }
        return $this->eventManager;
    }

    /**
     * @param EventManager $eventManager
     */
    public function setEventManager($eventManager)
    {
        $this->eventManager = $eventManager;
    }

    /**
     * @return ClassMetadataFactory
     */
    public function getClassMetadataFactory()
    {
        if(!isset($this->classMetadataFactory)) {
            $this->setClassMetadataFactory(new ClassMetadataFactory());
        }
        return $this->classMetadataFactory;
    }

    /**
     * @param ClassMetadataFactory $classMetadataFactory
     */
    public function setClassMetadataFactory(ClassMetadataFactory $classMetadataFactory)
    {
        $this->classMetadataFactory = $classMetadataFactory;
    }
}

One more hint: You might have problems with annotations of other classes, e.g. Zend\Form\Annotation\Validator. Such annotations will throw an exception in Doctrines parser because this parser does not use auto loading and only checks for already loaded classes. So if you still want to use them, you just have to include them manually before parsing the classes annotations.

Upvotes: 2

Related Questions