Factory+Stubでテストデータを統一した話

2025.11.07 09:00
2025.10.22 09:36
Factory+Stubでテストデータを統一した話

何度かFactoryを書き直して気づいたのは、
「本番Eloquentが賢すぎるほど、テストがつらい」ということ。

そこで、テスト専用のEloquent Stubを用意して、
FactoryはそのStubだけにぶら下げる運用に切り替えました。

先に結論

Stubをつかった運用をこうやったって感じです。

  • 本番Eloquentはテストで使わない。
  • テストでは Tests\Suite\Support\Models\Stub* を使う(接頭辞 Stub)。
  • Factoryも Stubモデル専用StubSpotFactory など)。
  • グローバルスコープ/イベント/複雑なキャストは一切持たない。
  • テーブル名だけ合わせる(protected $table = 'spots' など)。
  • リレーションは必要最低限(or なし)。「速く・壊れない」を最優先。

  • Factory(Eloquent)永続化が前提のテスト(Infra/DBクエリ/Repository/Controllerなど)
  • Stub(プレーンPHP)永続化不要ドメイン/UseCase のテスト

そして置き場も統一します。

tests/Suite/
├── Models/
│   ├── StubSpot.php
│   ├── StubCompany.php
│   └── StubArea.php
├── Factories/
│   ├── StubSpotFactory.php
│   └── StubCompanyFactory.php
├── Infrastructure/
│   └── Queries/
│       └── DbSpotListQueryTest.php
└── Application/
    └── UseCases/
        └── SearchSpotUseCaseTest.php

本番Eloquentを使わずに、Stubモデル+Factoryだけでテストデータを作る話

最小実装(コピペでOK)

1) Stubモデル(テスト専用・超ミニマル)

<?php declare(strict_types=1);

namespace Tests\Suite\Common\Models;

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

/**
 * StubSpot
 * ------------------------------------------------------------
 * テスト専用のEloquent(本番モデルの代替)。
 * - グローバルスコープなし
 * - イベントなし
 * - キャスト最小限
 */
final class StubSpot extends Model
{
    use HasFactory;

    protected $table = 'spots';   // 本番と同じテーブル
    public $timestamps = false;   // 必要に応じて切替
    protected $guarded = [];      // テストでは一括代入OKに

    protected static function newFactory()
    {
        return \Tests\Suite\Common\Factories\StubSpotFactory::new();
    }
}

2) Factory(Stub専用)

<?php declare(strict_types=1);

namespace Tests\Suite\Common\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use Tests\Suite\Common\Models\StubSpot;

final class StubSpotFactory extends Factory
{
    protected $model = StubSpot::class;

    public function definition(): array
    {
        return [
            'name'    => $this->faker->company(),
            'address' => $this->faker->address(),
            'area_id' => 14,
            'display' => 1,
            'created_at' => now(),
            'updated_at' => now(),
        ];
    }

    public function hidden(): self
    {
        return $this->state(fn() => ['display' => 0]);
    }
}

ポイント:newFactory() をStubモデル側でoverrideしておけば、
StubSpot::factory() がそのまま使えます。Factory::guessFactoryNamesUsing() の設定は不要。

インフラ系テスト(DBを触るやつ)

<?php declare(strict_types=1);

namespace Tests\Suite\Infrastructure\Queries;

use Tests\TestCase;
use PHPUnit\Framework\Attributes\Large;
use Tests\Suite\Common\Models\StubSpot;
use Tool\General\Infrastructure\Queries\DbSpotListQuery;

#[Large]
final class DbSpotListQueryTest extends TestCase
{
    public function test_表示対象のみ取得できる(): void
    {
        StubSpot::factory()->count(2)->create(['display' => 1]);
        StubSpot::factory()->hidden()->create();

        $q = new DbSpotListQuery();
        $res = $q->search(areas: [14], keywords: '', page: 1, perPage: 10);

        $this->assertSame(2, $res['total']);
        $this->assertCount(2, $res['items']);
    }
}

アプリ系テスト(DBを触らないやつ)

UseCaseは Stub(配列)で十分。DBは切る。

<?php declare(strict_types=1);

namespace Tests\Suite\Application\UseCases;

use Tests\TestCase;
use PHPUnit\Framework\Attributes\Small;
use Tool\General\Application\UseCases\Spot\{
    SearchSpotUseCase, SearchSpotCommand
};
use Tool\General\Application\DTOs\Spot\SpotListResult;
use Tool\General\Domain\Queries\SpotListQuery;

#[Small]
final class SearchSpotUseCaseTest extends TestCase
{
    public function test_resultの形が崩れない(): void
    {
        $query = new class implements SpotListQuery {
            public function search(array $areas, string $keywords, int $page, int $perPage): array
            {
                return [
                    'total' => 2, 'page' => 1, 'perPage' => 10,
                    'items' => [
                        ['id'=>1,'name'=>'A','address'=>'旭川','created'=>'2025-10-01'],
                        ['id'=>2,'name'=>'B','address'=>'旭川','created'=>'2025-10-02'],
                    ],
                ];
            }
        };

        $uc = new SearchSpotUseCase($query);
        $res = $uc->handle(new SearchSpotCommand([14], '', 1, 10));

        $this->assertInstanceOf(SpotListResult::class, $res);
        $this->assertSame(2, $res->total);
    }
}

なぜこれで楽になるか

  • 本番モデルの賢さ(グローバルスコープ・イベント・Observer・複雑cast)を完全に回避
  • Factoryの初期値や state()テスト都合で自由に設計できる
  • 破壊的変更(本番モデルの仕様変更)がテストに波及しない
  • 「どっちのFactoryだっけ?」問題が消える(Stub専用しかない

よくある疑問

Q. リレーションは?
A. 必要になってから最小で追加。belongsTo だけ足す→相手側も StubCompany を作る、でOK。無理に本番モデルへ寄せない。

Q. 外部キー制約は?
A. テストで Schema::disableForeignKeyConstraints() を使うか、Factoryで関連も一緒に作成。どちらでも。

まとめ

本番のEloquentモデルをそのままテストに使うと、
グローバルスコープやイベント、キャストなどの影響で
「本当に確認したい部分」までノイズが混ざってしまうことがあります。

そこでテストでは、本番モデルを完全に切り離し、
StubモデルとStubFactoryだけでデータを作る運用に変えました。

テーブル名だけ合わせて、余計な機能を全部削ったStubモデルを使えば、
Factoryの定義もシンプルになり、テストは速く・安定して・壊れにくくなります。

Eloquentをそのまま使うより、
テスト専用に“簡素化したコピー”を持つほうが、実務ではずっと快適。
迷ったら、まずは tests/Suite/Models/Stub〜 を作ってみる。
これでテスト環境がかなり整ったなと感じました。

今回は以上です!