このエントリーは Symfony Advent Calendar 2015 12日目の記事です。 昨日は @ttskch さんの 「「普通にエンティティのCRUDができれば十分」な管理画面ならEasyAdminBundleがおすすめ」 でした。

下記のようなエンティティとフォームを想定します。

<?php

namespace Acme\DemoBundle\Entity\Post;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Table()
 * @ORM\Entity(repositoryClass="Acme\DemoBundle\Entity\Repository\PostRepository")
 */
class Post
{
    /**
     * @var integer
     * @ORM\Id
     * @ORM\GeneratedValud(strategy="AUTO")
     */
    public $id;

    /**
     * @var string
     * @ORM\Column(type="string", length=255, nullable=false)
     * @Assert\NotBlank
     */
    public $title;

    /**
     * @var string
     * @ORM\Column(type="text")
     */
    public $body;

    /**
     * @var string
     * @ORM\Column(type="string", length=255)
     */
    public $slug;

    /**
     * @var boolean
     * @ORM\Column(type="bool")
     */
    public $public = false;
}
<?php

namespace Acme\DemoBundle\Form\Type;

use Acme\DemoBundle\Entity\Post;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;

class PostType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options = null)
    {
        $builder
            ->add('title')
            ->add('body')
            ->add('slug')
            ->add('public')
            ->addEventListener(FormEvents::POST_SUBMIT, function(FormEvent $event){
                $data = $event->getData();

                // 公開設定が「公開」でslugが空ならエラー
                if ($data['public'] && $data['slug'] === '') {
                    $form = $event->getForm();
                    $form['slug']->addError(new FormError('公開する記事にはslugを設定してください'));
                }
            })
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver
            ->setDefaults([
                'data_class' => Post::class,
            ])
        ;
    }

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

このようなPostTypeクラスに対して、どんなテストクラスを書けば良いでしょうか。

PostTypeのユニットテスト

一つ目は、PostTypeクラスそのもののユニットテストです。

<?php

namespace Acme\DemoBundle\Tests\Form\Type;

use Acme\DemoBundle\Entity\Post;
use Acme\DemoBundle\Form\Type\PostType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class PostTypeTest extends \PHPUnit_Framework_TestCase
{
    public function test_configureOptions()
    {
        $resolver = $this->getMock(OptionsResolver::class);

        $resolver->expects($this->once())
            ->method('setDefaults')
            ->with(['data_class' => Post::class])
        ;

        $type = new PostType();
        $type->configureOptions($resolver);
    }

    public function test_buildForm()
    {
        $formBuilder = $this->getMock(FormBuilderInterface::class);
        $options = [];

        $formBuilder->expects($this->exactly(4))
            ->method('add')
            ->with($this->logicalOr('title', 'body', 'slug', 'public'))
            ->will($this->returnSelf())
        ;
        $formBuilder->expects($this->once())
            ->method('addEventListener')
            ->with($this->isType('string'), $this->isInstanceOf('Closure'))
        ;

        $type = new PostType();
        $type->buildForm($formBuilder, $options);
    }
}

項目の追加やEventListenerの追加は確認できますが、実際にイベントが発生した後に何が起こるか(無名関数の中で何が起きるか)をテストできないという欠点があります。

また、PostType内の記述内容そのものを確認することはできても、PostTypeをもとにして実際に作られるFormクラスがどのような機能を持つのかを確認することができないので、Typeクラスを書いてテストは通ったものの実際のフォームが思い通りの動作にならないという辛い体験をしてしまいがちです。(特にSymfonyに不慣れな場合)

TypeTestCaseを使ったテスト

二つ目はTypeTestCaseを使って、PostTypeそのものでなくPostTypeから作られる実際のフォームインスタンスに対するテストです。

<?php

namespace Acme\DemoBundle\Tests\Form\Type\PostType;

use Acme\DemoBundle\Entity\Post;
use Acme\DemoBundle\Form\Type\PostType;
use Symfony\Component\Form\Test\TypeTestCase;

class PostTypeTest extends TypeTestCase
{
    public function test_draft()
    {
        $form = $this->factory->createForm(new PostType());
        $form->submit([
            'title' => 'test title',
            'body' => 'test body',
            'slug' => '',
        ]);

        $this->assertTrue($form->isValid());
        $this->assertInstanceOf(Post::class, $form->getData());
    }

    public function test_publish_with_empty_slug()
    {
        $form = $this->factory->createForm(new PostType());
        $form->submit([
            'title' => 'test title',
            'body' => 'test body',
            'slug' => '',
            'public' => 1,
        ]);

        $this->assertFalse($form->isValid());
        $this->assertTrue($form['slug']->hasError());
    }
}

イベントリスナーとして渡した無名関数の処理内容が実行されていることが確認できます。

注意しなければならないのは、TypeTestCaseを使ったテストの中で $form->isValid() を呼び出したとき、 PostTypeクラスに書いたバリデーションルールだけが検証され、エンティティに記述したバリデーションルールは検証されないということです。(FormコンポーネントとValidatorコンポーネントは独立しているため)

class PostTypeTest extends TypeTestCase
{    
    // ...

    public function test_publish_with_empty_title()
    {
        $form = $this->factory->createForm(new PostType());
        $form->submit([
            'title' => '',
            'body' => 'test body',
            'slug' => 'test-slug',
            'public' => 1,
        ]);

        $this->assertFalse($form->isValid()); // このテストは失敗します
    }
}

(もしエンティティ内に書いたバリデーションルールも共に検証したければ、 Symfony\Bundle\FrameworkBundle\Test\KernelTestCase を使うこともできます)

まとめ

Formは苦手にする人が多いコンポーネントですが、不安なところこそしっかりテストを書いて安心して開発できるようにしましょう。

明日の担当は @issei-m さんです!