Laravel 8 で刷新された ModelFactory でテストデータを簡単に作成する

こちらのブログは個人ブログと同じ内容です

www.ritolab.com


Laravel には ModelFactory(モデルファクトリ)といって Eloquent のモデルを使って簡単に開発用のデータやテスト時のデータを作成できる仕組みがあります。

このモデルファクトリが Laravel 8 で刷新されなかなか良い感じになっていたので、今回は実際に使用してみたいと思います。

モデルとテーブルの作成

まずは ModelFactory を作成するために、モデルと DB のテーブルを作成しつつ仕様を決めておきたいと思います。

今回は、Book モデルを作成して、それについてのモデルファクトリを定義しようと思います。

まずは books テーブルのマイグレーションを作成

# books テーブル マイグレーション作成
php artisan make:migration create_books_table --create=books

マイグレーション(カラム)は以下のように定義しました。

database/migrations/xxxx_xx_xx_xxxxxx_create_books_table.php

<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateBooksTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up(): void
    {
        Schema::create('books', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('author');
            $table->date('bought_at')->nullable();
            $table->date('started_at')->nullable();
            $table->date('ended_at')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down(): void
    {
        Schema::dropIfExists('books');
    }
}
  • id
    • 主キー
  • title
    • 本のタイトル
  • author
    • 著者
  • bought_at
    • 購入した日
  • start_at
    • 読み始めた日
  • end_at
    • 読み終わった日
  • created_at
    • 作成日
  • updated_at
    • 更新日

続いて、モデルを作成します。

# book モデル作成
php artisan make:model Book

app/Models/Book.php

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
    use HasFactory;
}

今回は factory を試すことがメインのため、特に事前の設定は不要なので生成されたままで記述は変えていません。

この Book モデルからテストデータについて考えると以下のような感じで作れたら良いなと考えます。

  • 本のタイトルと著者は入っていれば何でも良い
  • 購入日・読み始めた日・読み終わった日はそれぞれが関連するので、state で予め定義しておいて簡単にデータを作成できるようにしたい

前者はランダムで良いので Faker で作成すれば良いですね。

後者は、例えば「読み始めた日」に日付が入っているということは、当然既に購入済み(レンタルは想定しません)なので「購入日」にも日付が入っている必要がある。みたいなことなので、ここは state を定義しておいて、呼び出すだけで簡単にテストデータを作成したいねというお話です。

このあたりまで決めたら、factory の実装に移れそうです。

Laravel 7 までのモデルファクトリ

Laravel 7 までの場合をおさらいすると、以下のように定義していました。

laravel7/database/factories/BookFactory.php

$factory->define(Book::class, fn(Faker $faker)  => [
    'title'  => $faker->sentence(),
    'author' => $faker->name(),
]);

これを factory 関数を使ってテストデータを作成していました。

/** @var \App\Models\Book $book */
$book = factory(Book::class)->create();

factory state(ファクトリステート)と呼ばれる、データの値を予め定義しておく場合も、以下のように記述していました。

laravel7/database/factories/BookFactory.php

$now = CarbonImmutable::now();

/**
 * 購入済み
 */
$factory->state(Book::class, 'bought', [
    'bought_at' => $now
]);

/**
 * 読んでいるところ
 */
$factory->state(Book::class, 'started', [
    'bought_at'  => $now->subDay(),
    'started_at' => $now,
]);

/**
 * 読み終わった
 */
$factory->state(Book::class, 'ended', [
    'bought_at'  => $now->subDays(2),
    'started_at' => $now->subDay(),
    'ended_at'   => $now,
]);

そしてテストデータ作成時に state()(複数ある時は states() )メソッドをチェーンさせて呼び出します。

/** @var \App\Models\Book $book */
$book = factory(Book::class)->state('ended')->create();

ModelFactory 実装

ではここから Laravel 8 にてモデルファクトリを定義していきたいと思います。

まずは、artisan コマンドで生成した時点での BookFactory を見てみます。

laravel8/database/factories/BookFactory.php

<?php

namespace Database\Factories;

use App\Models\Book;
use Illuminate\Database\Eloquent\Factories\Factory;

class BookFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = Book::class;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            //
        ];
    }
}

なんということでしょう。ModelFactory がクラス化されています。Laravel 8 になって刷新されたというのは、こういうことだったのですね。

では実装します。まずは基本部分から

laravel8/database/factories/BookFactory.php

public function definition(): array
{
    return [
        'title'  => $this->faker->sentence(),
        'author' => $this->faker->name(),
    ];
}

definition() メソッドに値のデフォルト値を定義することで、テストデータを作成できます。

利用側はこんな感じ

/** @var \App\Models\Book $book */
$book = Book::factory()->create();

factory 関数ではなく、モデルから static に呼び出すことでテストデータを作成していますね。

続いてステートの定義をしていきます。

laravel8/database/factories/BookFactory.php

/**
 * 購入済み
 *
 * @return BookFactory
 */
public function bought(): BookFactory
{
    return $this->state(fn ()  => [
        'bought_at' => CarbonImmutable::now()
    ]);
}

/**
 * 読んでいるところ
 *
 * @return BookFactory
 */
public function started(): BookFactory
{
    $now = CarbonImmutable::now();

    return $this->state(fn ()  => [
        'bought_at'  => $now->subDay(),
        'started_at' => $now,
    ]);
}

/**
 * 読み終わった
 *
 * @return BookFactory
 */
public function ended(): BookFactory
{
    $now    = CarbonImmutable::now();

    return $this->state(fn ()  => [
        'bought_at'  => $now->subDays(2),
        'started_at' => $now->subDay(),
        'ended_at'   => $now,
    ]);
}

メソッドを作成することで、それぞれの state を定義することができるようになっています。

こちらも、テストデータ作成時にメソッドをチェーンさせて呼び出します。

/** @var \App\Models\Book $book */
$book = Book::factory()->bought()->create();

state に引数を渡す

ModelFactory がクラス化され state がメソッド化されたことで、state 指定時に引数を渡せるようになっています。

laravel8/database/factories/BookFactory.php

/**
 * 読んでいるところ
 *
 * @param int $startDays 読み始めるまでの日数
 *
 * @return BookFactory
 */
public function started(int $startDays=1): BookFactory
{
    $now = CarbonImmutable::now();

    return $this->state(fn (array $attributed)  => [
        'bought_at'  => $now->subDays($startDays),
        'started_at' => $now,
    ]);
}

/**
 * 読み終わった
 *
 * @param int $endDays   読み終わるまでの日数
 * @param int $startDays 読み始めるまでの日数
 *
 * @return BookFactory
 */
public function ended(int $endDays=1, int $startDays=1): BookFactory
{
    $end    = CarbonImmutable::now();
    $start  = $end->subDays($endDays);
    $bought = $start->subDays($startDays);

    return $this->state(fn (array $attributed)  => [
        'bought_at'  => $bought,
        'started_at' => $start,
        'ended_at'   => $end,
    ]);
}

今回の Book モデルで具体的に説明すると、これまで state では単純に「購入日」「読み始めた日「読み終わった日」にそれぞれ 1 日違いで値を挿入し、データとして整合性がとれた状態のみを作り出していました。

上記の場合では、引数に日数を与える事にとって、任意の日数差にてデータを作成できるようにしています。

state は本来、決まったデータのパターンを定義するものと私は理解しているので、基本的には可変的に設定するシーンは多くはないと認識はしているものの、それでもあるケースのテストを行いたい場合に、たまにこういうの欲しくなる事もあります。

もちろん、make() や create() の引数で値を指定すれば済む話ではあるのですが、テストやテストデータ作成の数が多くなってくると、この辺の記述が結構膨れてきて設定の意図がわかりづらくなり、他の値の設定とかも入ってきて可読性が下がるのが微妙だなと思っていました。

なので外から値を投げられるのは結構うれしいなと感じました。

// 購入日から 3 日後に読み始め、読み始めてから 10 日後に読み終わる
/** @var \App\Models\Book $book */
$book = Book::factory()->ended(10, 3)->create();

今回の state の場合はサンプル的に作っているのでニーズから作成したものではないですが、例えば、普段は日にちさえ入っていればそれで良いのだけれど、あるテストケースで「いつ読んだ」とか「読むのに何日以上かかった」あるいは日付範囲で絞る。みたいな検索・抽出の機能のテストを書く際のテストデータがほしいなって思った時とかに使えるかな。

state() に渡すクロージャの引数

ちなみに、$this->state() に渡すクロージャには、引数として array $attributed が渡りますが、ここには、この state が実行されるまでに設定された値が入ってきます。

ですので definition() で作成されたデータ + この state が実行されるまでに実行された state のデータが渡ってくる事になります。

例えば以下を実行した場合に、

Book::factory()->bought()->ended()->create();

ended() の ファクトリステートで渡ってくる array $attributed は以下のようになります。

$attributed => Array
(
    [title] => abcd
    [author] => ABCD
    [bought_at] => 2020-11-03 10:14:17
)

Laravel 8 と Laravel 7 以前のモデルファクトリの違い

これまで部分的にしかコードを記していなかったので、最後に Laravel 8 と Laravel 7 以前のモデルファクトリの違いを全体感で俯瞰できるように、今回作成したそれぞれのコードをここで表示します。

Laravel 7 以前の BookFactory

<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use App\Book;
use Carbon\CarbonImmutable;
use Faker\Generator as Faker;

$factory->define(Book::class, fn(Faker $faker)  => [
    'title'  => $faker->sentence(),
    'author' => $faker->name(),
]);

$now = CarbonImmutable::now();

/**
 * 購入済み
 */
$factory->state(Book::class, 'bought', [
    'bought_at' => $now
]);

/**
 * 読んでいるところ
 */
$factory->state(Book::class, 'started', [
    'bought_at'  => $now->subDay(),
    'started_at' => $now,
]);

/**
 * 読み終わった
 */
$factory->state(Book::class, 'ended', [
    'bought_at'  => $now->subDays(2),
    'started_at' => $now->subDay(),
    'ended_at'   => $now,
]);

Laravel 8 の BookFactory

<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\Book;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * Class BookFactory
 *
 * @package Database\Factories
 */
class BookFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = Book::class;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition(): array
    {
        return [
            'title'  => $this->faker->sentence(),
            'author' => $this->faker->name(),
        ];
    }

    /**
     * 購入済み
     *
     * @return BookFactory
     */
    public function bought(): BookFactory
    {
        return $this->state(fn (array $attributed)  => [
            'bought_at' => CarbonImmutable::now()
        ]);
    }

    /**
     * 読んでいるところ
     *
     * @param int $startDays 読み始めるまでの日数
     *
     * @return BookFactory
     */
    public function started(int $startDays=1): BookFactory
    {
        $now = CarbonImmutable::now();

        return $this->state(fn (array $attributed)  => [
            'bought_at'  => $now->subDays($startDays),
            'started_at' => $now,
        ]);
    }

    /**
     * 読み終わった
     *
     * @param int $endDays   読み終わるまでの日数
     * @param int $startDays 読み始めるまでの日数
     *
     * @return BookFactory
     */
    public function ended(int $endDays=1, int $startDays=1): BookFactory
    {
        $end    = CarbonImmutable::now();
        $start  = $end->subDays($endDays);
        $bought = $start->subDays($startDays);

        return $this->state(fn (array $attributed)  => [
            'bought_at'  => $bought,
            'started_at' => $start,
            'ended_at'   => $end,
        ]);
    }
}

クラス化されたことで各々のファクトリがしっかりすみ分けできた感じがいいなあ。

Laravel <= 7 から 8 への移行

さて、Laravel 8 にて刷新されたモデルファクトリですが、Laravel 7 以前の記法とは互換性がありません。

なので ver. 8 へアップグレードをするためにはここを全て書き直さないといけないわけですが、調整箇所が多いと物量との戦いになるため、なかなか辛くなります。

ただ、モデルファクトリを使用している部分は開発用のデータやテストデータの作成部分であり、直接的にプロダクトとして価値を提供している部分ではないので、ここの移行で ver. 8 へのアップグレードがスタックするのはなるべく避けたいところです。

そこで、Laravel 8 でも、旧式のモデルファクトリの記述を使えるように、パッケージが提供されています。

laravel/legacy-factories
https://github.com/laravel/legacy-factories

ある程度運用されているシステムであれば、基本的にアップグレードで変更や調整が必要となる箇所はモデルファクトリだけではないと思うので、ひとまずこのパッケージを導入してモデルファクトリ以外を Laravel 8 に対応させてしまい、その後でこの部分だけを対応させていくという流れが良いかなと思いました。

ちなみに試してみたところ、Laravel 8 でも以前のモデルファクトリが問題なく動作しました。ただし旧記述と新記述を共存させることはできなかったので、新しい記述に対応する際にはパッケージを削除して一気に全てのモデルファクトリを修正する必要がありそうです。

まとめ

Laravel 8 で刷新されいい感じになったモデルファクトリ。開発用のデータやテストデータの作成って作成元も利用側も散らかり傾向になりがちなので、これですっきりまとめて見通し良く開発していきたいですね。