PHP
laravel
testing

Eloquent Model Factory を使ってテストデータを整備する

この記事について

Laravel でテストするときに、フィーチャーテスト用のデータをどのようにつくっていけばいいか、試行錯誤中なので、それについてのメモ的なかんじです。

概要

環境

  • PHP 7.1.12
  • Laravel 5.5.28

詳細

Eloquent Model Factory とは

Eloquent Model Factory についての解説は以下の記事が網羅的かつ分かりやすいので、読んでみてください。

Laravel5.5でほぼ完成されたModelFactoryの使い方 - Qiita

公式ドキュメントには以下の記載があります。

When testing, you may need to insert a few records into your database before executing your test. Instead of manually specifying the value of each column when you create this test data, Laravel allows you to define a default set of attributes for each of your Eloquent models using model factories.
Database Testing - Laravel

テストするときにデータベースにレコードが入っていた方がいいけど、手動でデータをつくる代わりに、Eloquent のモデルを使ってテストデータをつくれるようになっているよ、ということですが、プロジェクトを作成するとサンプルが入っているので、まずはそれを見てみます。

databases/factories/UserFactory.php がそれです。

<?php

use Faker\Generator as Faker;

$factory->define(App\User::class, function (Faker $faker) {
    static $password;

    return [
        'name' => $faker->name,
        'email' => $faker->unique()->safeEmail,
        'password' => $password ?: $password = bcrypt('secret'),
        'remember_token' => str_random(10),
    ];
});

これのおかげで、以下のようにするだけで、データセットをつくることができます。

tinker で確認してみます(パスワードやトークンは $hidden プロパティに入っているので出力されません)。

$ php artisan tinker
Psy Shell v0.8.17 (PHP 7.1.12  cli) by Justin Hileman
>>> factory(App\User::class)->make()
=> App\User {#789
     name: "高橋 知実",
     email: "chiyo78@example.net",
   }
>>> 

make()new Model() に該当し、 create()Model::create() に該当します(呼び出し時に INSERT されるかされないかの違い、後者がされる)。

シナリオに沿ってテストデータを整備する

シチュエーションによっては、データセットがある特定の状態になっていて、テストではその状態のデータを使用したい、という局面があるかと思いますが、 Model Factory では名前をつけることで、それを実現します。

例) 登録直後のユーザーデータを使いたい

仮に、登録直後の users テーブルには、 real_name が未登録であるという状態だとします。

$factory->define(App\User::class, function (Faker $faker) {
    static $password;

    return [
        'name' => $faker->name,
        'real_name' => null,
        'email' => $faker->unique()->safeEmail,
        'password' => $password ?: $password = bcrypt('secret'),
        'remember_token' => str_random(10),
    ];
}, '登録直後');

define() メソッドの第3引数には名前を与えることができて(デフォルトは 'default')、以下のように使います。

factory(App\User::class, '登録直後')->make();

他のカラムの重複がいやだということであれば、デフォルトの配列に別の配列をマージすることで解消できます。

function default_user_attributes(Faker $faker) {
    static $password;

    return [
        'name' => $faker->name,
        'email' => $faker->unique()->safeEmail,
        'password' => $password ?: $password = bcrypt('secret'),
        'remember_token' => str_random(10),
    ];
}
$factory->define(App\User::class, function (Faker $faker) {
    return ['real_name' => null] + default_user_attributes($faker);
}, '登録直後');

2018-11-18 21:43追記

一部のプロパティのみ書き換えたい場合は state メソッドを使うこともできます(こちらの方がスマートかつ柔軟かと思います)。

$factory->state(App\User::class, '本名なし', function (Faker $faker) {
    return ['real_name' => null];
});

// 呼び出し側
factory(App\User::class)->states('本名なし')->make();

追記ここまで

関連するテーブルのデータ

さらに、別のテーブルと関連を持っていて、それらも状態によって変化する、というような場合があります。

仮に user_attributes というテーブルがあるとします(モデルクラスは UserAttribute です)。

$factory->define(UserAttribute::class, function (Faker $faker) {
     return [
         //…
    ];
}, '登録直後');

同じように、名前をつけて返却するデータセットを定義します。

トレイトをつくる

あるシナリオに沿った、かつ、モデルの関連付けまで行った状態のモデル(集約ルート)を返すだけのトレイトをつくります。集約ルートごとにつくるといいんじゃないかと思います。

trait CreatesUser
{
     private function createUser(string $scenario = 'default'): User
     {
        $user = factory(User::class, $scenario)->create();
        return $this->buildUser($user, $scenario);
     }

    private function buildUser(User $user, string $scenario): User
    {
        $user->save(factory(UserAttribute::class, $scenario)->create());
        // 他にも関連モデルがあればここでビルド
        return $user;
    }
}

テストケース

各テストケースからはこんなかんじで使います。

class UserControllerTest extends TestCase
{
    use CreatesUser;

    public function testShow()
    {
        $user = $this->createUser('登録直後');
        $this->actingAs($user);
        $this->get(route('users.show'));
        $this->assertStatus(200);
        // …
    }
}

さらなる自由度のためのバリエーション

シナリオごとにデータセットを用意する、といっても、あるシナリオでは別のシナリオとの違いがほとんどないので流用したい、というようなこともあるので、できれば既存のシナリオ用データを利用したい、という要望もあります。

そのような場合に、あらかじめ定義されたデータセットをテストケース側から上書きできるようにしてみます。

さきほどの CreatesUser トレイトに若干手を加えて、

trait CreatesUser
{
    private function createUser(string $scenario = 'default', array $attributes = []): User
    {
        $attributes = collect($attributes);
        $user = factory(User::class, $scenario)->create($attributes->get('user', []));
            return $this->buildUser($user, $scenario, $attributes);
        }
    }
    private function buildUser(User $user, string $scenario, Collection $attributes): User
    {
        $user->save(factory(UserAttribute::class, $scenario)->create($attributes->get('user_attribute', [])));
        // 他にも関連モデルがあればここでビルド
        return $user;
    }
}

第二引数にて配列を受け取り、その配列に上書き用のデータが入っていればそれを FactoryBuilder::create() に渡してやる、という流れです。

呼び出し側はこうなります。

class UserControllerTest extends TestCase
{
    use CreatesUser;

    public function testShow_登録直後のとあるバリエーション()
    {
        $attributes = [
            'user' => ['real_name' => '山田太郎'],
            'user_attribute' => ['something' => 'anything'],
        ];
        $user = $this->createUser('登録直後', $attributes);
        // …
    }
}

データプロバイダを使ってもいいですね。

class UserControllerTest extends TestCase
{
    use CreatesUser;

    /**
    * @dataProvider dataShow_登録直後のバリエーションをまとめてテスト
    */
    public function testShow_登録直後のバリエーションをまとめてテスト(array $attributes)
    {
        $user = $this->createUser('登録直後', $attributes);
        // …
    }

    public function dataShow_登録直後のバリエーションをまとめてテスト()
    {
        return [
            'とあるバリエーション' => [
                'user' => ['real_name' => '山田太郎'],
                'user_attribute' => ['something' => 'anything'],
            ],
            '別のバリエーション' => [...],
        ];
    }
}

他にもこんな使い方があるよ、など、コメントいただけると助かります :bow: