如何使用 Symfony 表单和数据转换器实现测试隔离?

How to achieve test isolation with Symfony forms and data transformers?

注意:这是 Symfony < 2.6,但我相信无论版本如何,都存在相同的总体问题

首先,考虑这种旨在将一个或多个实体表示为隐藏字段的表单类型(为简洁起见省略了命名空间内容)

class HiddenEntityType extends AbstractType
{
    /**
     * @var EntityManager
     */
    protected $em;

    public function __construct(EntityManager $em)
    {
        $this->em = $em;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        if ($options['multiple']) {
            $builder->addViewTransformer(
                new EntitiesToPrimaryKeysTransformer(
                    $this->em->getRepository($options['class']),
                    $options['get_pk_callback'],
                    $options['identifier']
                )
            );
        } else {
            $builder->addViewTransformer(
                new EntityToPrimaryKeyTransformer(
                    $this->em->getRepository($options['class']),
                    $options['get_pk_callback']
                )
            );
        }
    }

    /**
     * See class docblock for description of options
     *
     * {@inheritdoc}
     */
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'get_pk_callback' => function($entity) {
                return $entity->getId();
            },
            'multiple' => false,
            'identifier' => 'id',
            'data_class' => null,
        ));

        $resolver->setRequired(array('class'));
    }

    public function getName()
    {
        return 'hidden_entity';
    }

    /**
     * {@inheritdoc}
     */
    public function getParent()
    {
        return 'hidden';
    }
}

这行得通,简单明了,而且大部分看起来都像您看到的将数据转换器添加到表单类型的所有示例。直到你进行单元测试。看到问题了吗?变形金刚不能被嘲笑。 "But wait!"你说,"Unit tests for Symfony forms are integration tests, they're supposed to make sure the transformers don't fail. Even says so in the documentation!"

This test checks that none of your data transformers used by the form failed. The isSynchronized() method is only set to false if a data transformer throws an exception

好的,那么你就接受了不能隔离变压器的事实。没什么大不了的?

现在考虑对具有此类字段的表单进行单元测试时会发生什么(假设 HiddenEntityType 已在服务容器中定义和标记)

class SomeOtherFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('field', 'hidden_entity', array(
                'class' => 'AppBundle:EntityName',
                'multiple' => true,
            ));
    }

    /* ... */
}

现在进入问题。 SomeOtherFormType 的单元测试现在需要实现 getExtensions() 才能使 hidden_entity 类型起作用。那看起来怎么样?

protected function getExtensions()
{
    $mockEntityManager = $this
        ->getMockBuilder('Doctrine\ORM\EntityManager')
        ->disableOriginalConstructor()
        ->getMock();

    /* Expectations go here */

    return array(
        new PreloadedExtension(
            array('hidden_entity' => new HiddenEntityType($mockEntityManager)),
            array()
        )
    );
}

看到评论在中间的位置了吗?是的,为了使其正常工作,HiddenEntityType 的单元测试 class 中的所有模拟和期望现在都需要在此处有效地复制。我对此不满意,那么我有什么选择?

  1. 注入变压器作为选项之一

    这将非常简单,并且会使模拟更简单,但最终只是把罐头踢了下来。因为在这种情况下,new EntityToPrimaryKeyTransformer() 只会从一种表单类型 class 移动到另一种表单类型。更不用说我觉得表单类型 应该 向系统的其余部分隐藏它们的内部复杂性。此选项意味着将复杂性推到表单类型的边界之外。

  2. 在表单类型中注入一个变压器工厂

    这是从方法中删除 "newables" 的一种更典型的方法,但我无法摆脱这样的感觉,即这样做只是为了使代码可测试,而不是真正使代码成为可能更好的。但如果这样做了,它看起来会像这样

    class HiddenEntityType extends AbstractType
    {
        /**
         * @var DataTransformerFactory 
         */
        protected $transformerFactory;
    
        public function __construct(DataTransformerFactory $transformerFactory)
        {
            $this->transformerFactory = $transformerFactory;
        }
    
        public function buildForm(FormBuilderInterface $builder, array $options)
        {
            $builder->addViewTransformer(
                $this->transformerFactory->createTransfomerForType($this, $options);
            );
        }
    
        /* Rest of type unchanged */
    }
    

    在我考虑工厂的实际外观之前,这感觉还不错。对于初学者来说,它需要注入实体管理器。但那又怎样?如果我进一步看下去,这个所谓的通用工厂可能需要各种依赖项来创建不同类型的数据转换器。这显然不是一个好的长期设计决策。那又怎样呢?将其重新标记为 EntityManagerAwareDataTransformerFactory?这里开始感觉很乱了。

  3. 我没有想到的东西...

想法?经验?可靠的建议?

首先,我几乎没有使用 Symfony 的经验。但是,我认为您在那里错过了第三种选择。在 Working Effectively with Legacy Code 中,Michael Feathers 概述了一种通过使用继承来隔离依赖关系的方法(他称之为 "Extract and Override")。

它是这样的:

class HiddenEntityType extends AbstractType
{
    /* stuff */

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        if ($options['multiple']) {
            $builder->addViewTransformer(
                $this->createEntitiesToPrimaryKeysTransformer($options)
            );
        }
    }

    protected function createEntitiesToPrimaryKeysTransformer(array $options)
    {
        return new EntitiesToPrimaryKeysTransformer(
            $this->em->getRepository($options['class']),
            $options['get_pk_callback'],
            $options['identifier']
        );
    }
}

现在进行测试,您创建一个扩展 HiddenEntityType.

的新 class、FakeHiddenEntityType
class FakeHiddenEntityType extends HiddenEntityType {

    protected function createEntitiesToPrimaryKeysTransformer(array $options) {
        return $this->mock;
    }    

}

其中 $this->mock 显然是您需要的任何内容。

两个最突出的优点是不涉及工厂,因此复杂性仍然被封装,并且这种更改几乎没有机会破坏现有代码。

缺点是这种技术需要额外的 class。更重要的是,它需要 class 了解被测 class 的内部结构。


为了避免额外的 class,或者更确切地说隐藏额外的 class,可以将其封装在一个函数中,创建一个匿名 class(支持匿名 classes 已添加到 PHP 7).

class HiddenEntityTypeTest extends TestCase
{

    private function createHiddenEntityType()
    {
        $mock = ...;  // Or pass as an argument

        return new class extends HiddenEntityType {

            protected function createEntitiesToPrimaryKeysTransformer(array $options)
            {
                return $mock;
            }    

        }
    }

    public function testABC()
    {
        $type = $this->createHiddenEntityType();
        /* ... */
    }

}