Symfony Advent Calendar 2021 day 20の記事です!
昨日は @polidogさんのSymfony PassportでFirebase Authentication認証を使う でした。

SymfonyWorldにて、symfonycastsで有名なweaverryanさんのテストのセッションがあり、 zenstruck/foundry というテストフィクスチャ登録用のライブラリが紹介されていました。Laravelのfactoryを彷彿とさせる使用感ですが、個人的に長年愛用してきた liip/test-fixtures-bundle の使い方が最近変わって、微妙に使いにくくなったのが気になっていたので、新しいfoundryの使い方を調べてみました。

※ 実際のソースコードは https://github.com/77web/symfony-foundry-playground にて公開しています。

zenstruck/foundryを追加

$ composer req --dev zenstruck/foudndry

bundleっぽくない名前ですが、flexによって config/bundles.php にバンドルとしても追加されていました。

+    Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],

ファクトリを作る

ファクトリクラスは --test をつけると tests/ の下に、 --test なしだと src/ の下に作られます。今回はテスト用のfixtureのためのファクトリを作りたいので --test つきで実行しました。

$ bin/console make:factory --test

 Entity class to create a factory for:
  [0] App\Entity\Task
 > 0

 created: tests/Factory/TaskFactory.php

           
  Success! 
           

 Next: Open your new factory and set default values/states.
 Find the documentation at https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories

作られたTaskFactoryクラスはこんな感じです。

<?php

namespace App\Tests\Factory;

// ...

final class TaskFactory extends ModelFactory
{
    public function __construct()
    {
        parent::__construct();

        // TODO inject services if required (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services)
    }

    protected function getDefaults(): array
    {
        return [
            // TODO add your default values here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories)
            'title' => self::faker()->text(),
        ];
    }

    protected function initialize(): self
    {
        // see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
        return $this
            // ->afterInstantiate(function(Task $task): void {})
        ;
    }

    protected static function getClass(): string
    {
        return Task::class;
    }
}

機能テストでファクトリを使ってみる

機能テストでファクトリクラスを使うためには、まずテストクラスに Zenstruck\Foundry\Test\Factories をuseさせる必要があります。

ファクトリの使い方は2種類あります。

  • TaskFactory::createOne() …1件のTaskを作る
  • TaskFactory::createMany() …指定件数のTaskを作る

また、ファクトリにフィクスチャを作らせる前に static::createClient() を実行しておきましょう。

<?php

namespace App\Tests\Controller\TaskController;

+ use Zenstruck\Foundry\Test\Factories;

class CompletedTest extends WebTestCase
{
    use Factories;

    public function test()
    {
        $client = static::createClient();
    
+        // 1件未完了タスクを作る
+        TaskFactory::createOne(['completedAt' => null]);
+        // 2件完了済タスクを作る
+        TaskFactory::createMany(2, ['completedAt' => new \DateTimeImmutable('yesterday')]);

        $crawler = $client->request('GET', '/task/completed');

        $this->assertResponseIsSuccessful();
        $this->assertTrue($crawler->filter('title')->text() === '完了したタスク');
+        $this->assertEquals(2, $crawler->filter('li')->count(),'完了済の2件のみ');
    }
}

繰り返しテストを実行するために(テスト用DBのリセット)

テストが通ったと喜んで、再度実行したら落ちました 😅

There were 2 failures:

1) App\Tests\Controller\TaskController\CompletedTest::test
完了済の2件のみ
Failed asserting that 4 matches expected 2.

/Users/hiromi/projects/tryout-foundry/tests/Controller/TaskController/CompletedTest.php:27

2) App\Tests\Controller\TaskController\IndexTest::test
未完了の1件のみ
Failed asserting that 3 matches expected 1.

/Users/hiromi/projects/tryout-foundry/tests/Controller/TaskController/IndexTest.php:27

FAILURES!
Tests: 2, Assertions: 6, Failures: 2.

どうやら2回目に実行すると前回実行したデータが残っているようです。(しかもテストが2つあるので、それぞれ2回ずつtaskが作られたデータが残っています)
テストごとにデータベースをリフレッシュするには Zenstruck\Foundry\Test\ResetDatabase traitをuseする必要があります。
なお、公式ドキュメントでは「全部のテストケースに2つのtraitをuseするのは大変なので、自分用のWebTestCaseを作ると良い」と書かれていました。

<?php

namespace App\Tests\Controller\TaskController;

use Zenstruck\Foundry\Test\Factories;
+ use Zenstruck\Foundry\Test\ResetDatabase;

class CompletedTest extends WebTestCase
{
    use Factories;
+    use Factories, RefreshDatabases;

    public function test()
    {

DAMADoctrineTestBundle https://github.com/dmaicher/doctrine-test-bundle を使うと、テストケース全体をtransactionで囲って高速化もできるようです(試してません)

IDの強制セットができる

以前 Doctrineのエンティティを対象にしたテスト方法あれこれ で検討した、エンティティにidのsetterメソッドを作らなかったときにIDをセットする方法が、foundryではそのまま用意されていました。 forceSet() メソッドを使うことで、setterのないプロパティにも値をセットすることができます。

<?php

// これはエラー
TaskFactory::createOne(['id' => 999]);
// これはOK
$taskProxy = TaskFactory::createOne();
$taskProxy->forceSet('id', 999);

ユニットテストで使う

このファクトリですが、なんとユニットテスト(SymfonyのKernelTestCaseでもWebTestCaseでもない、PHPUnitの普通のTestCaseクラス)でも使えます。 ユニットテストでfoundryのファクトリを利用して作られたエンティティは、DBに保存されず(ここがLaravelのfactoryとは違うところですね 😁)、DB接続のない環境でもテストが通ります。

ただし、 XXXFactory::createOne() の返り値はエンティティそのものでなくfoundryの Proxy のインスタンスになっているため、そのままサービスクラスに渡すことはできないので注意が必要です。 XXXFactory::createOne()->object() でエンティティ自体のインスタンスを取得できます。

<?php

// ...

    use Factories;

    public function test()
    {
        $task = TaskFactory::createOne()->object();

        $this->emP->persist(Argument::that(function (Task $task) {
            return $task->getCompletedAt() !== null;
        }))->shouldBeCalled();
        $this->emP->flush()->shouldBeCalled();

        $this->getSUT()->markComplete($task);
    }

今までの生エンティティを直接使う書き方(下記)より可読性が良い気はします。

<?php

$task = (new Task())
  ->setTitle('foo')
  ->setDescription('bar')
;

参考リンク