مقاله

Factory و Seeder پیشرفته در Laravel — داده تست واقع‌گرایانه

Factory و Seeder پیشرفته در Laravel — داده تست واقع‌گرایانه

Factory state، relation در Factory، Seeder ترتیبی و database seeding برای تست و staging — بدون داده تصادفی بی‌معنا.

Factory فقط برای «ساختن ۱۰ تا رکورد random» نیست — ابزار اصلی تست، staging و demo است. وقتی Post::factory()->published()->for($user)->create() را بلد باشید، feature testها سریع و خوانا می‌شوند.

خلاصه: Factory تعریف می‌کند یک رکورد معتبر از یک مدل چه شکلی دارد و چه حالت‌هایی (state) می‌تواند داشته باشد؛ خودش چیزی را در دیتابیس اجرا نمی‌کند تا وقتی create() صدا زده شود. Seeder کلاسی است که با php artisan db:seed اجرا می‌شود و تصمیم می‌گیرد چه داده‌ای، با چه ترتیبی در دیتابیس قرار بگیرد — گاهی با Factory، گاهی با داده ثابت. در تست معمولاً Factory مستقیم داخل خود تست استفاده می‌شود؛ Seeder برای محیط local، demo یا داده پایه‌ای است که برنامه بدون آن کار نمی‌کند.

مثال‌های این مقاله با مدل‌های فرضی Post، Category و Tag نوشته شده‌اند و فرض می‌کنند هر مدل trait HasFactory و Factory خودش را دارد. در یک پروژهٔ تازهٔ Laravel 12 فقط UserFactory از قبل وجود دارد؛ بقیه را باید خودتان بسازید.

Factory و Seeder چه فرقی دارند و کی کدام؟

نیازابزار مناسب
داده برای یک تست مشخص (مثلاً «پست منتشرشده با نویسنده»)Factory داخل همان تست
محیط local یا demo با داده زیاد و شبیه واقعیSeeder که Factory را صدا می‌زند
داده پایه‌ای که برنامه بدون آن کار نمی‌کند (نقش‌ها، تنظیمات، دسته‌های ثابت)Seeder با داده ثابت و updateOrCreate — بدون Faker
محتوای واقعی که باید در version control بماندفایل داده (PHP/JSON) + Seeder
داده مشترک بین چند تستFactory state یا متد helper در تست؛ Seeder فقط وقتی همه تست‌ها واقعاً به آن نیاز دارند

ساخت Factory

php artisan make:factory PostFactory --model=Post
public function definition(): array
{
    return [
        'title' => fake()->sentence(4),
        'slug' => fake()->unique()->slug(3),
        'excerpt' => fake()->paragraph(),
        'content' => '<p>'.fake()->paragraphs(3, true).'</p>',
        'status' => PostStatus::Draft,
        'published_at' => null,
    ];
}

State — حالت‌های از پیش تعریف‌شده

public function published(): static
{
    return $this->state(fn () => [
        'status' => PostStatus::Published,
        'published_at' => now()->subDays(fake()->numberBetween(1, 30)),
    ]);
}

public function featured(): static
{
    return $this->state(['is_featured' => true]);
}

// استفاده
Post::factory()->published()->featured()->count(5)->create();

state را برای حالت‌هایی بسازید که در چند تست تکرار می‌شوند و معنای کسب‌وکاری دارند (منتشرشده، پیش‌نویس، حذف‌شده، ویژه). اگر مقداری فقط در یک تست مهم است، همان‌جا با create(['title' => '...']) بدهید تا خواننده تست ببیند چه چیزی بررسی می‌شود.

داده قابل تکرار (deterministic)

اگر تستی روی مقدار مشخصی assert می‌کند، آن مقدار را صریح به Factory بدهید و به خروجی تصادفی Faker تکیه نکنید. برای اینکه خروجی Faker در یک seed محلی یا یک تست خاص ثابت بماند، می‌توانید قبل از ساخت داده fake()->seed(1234); را صدا بزنید. تاریخ‌ها را هم با $this->travelTo() یا مقدار ثابت کنترل کنید تا تست به روز اجرا وابسته نباشد. به همین دلیل در state بالا به‌جای rand() از fake()->numberBetween() استفاده شده تا با همان seed کنترل شود.

Sequence — تنوع کنترل‌شده

use Illuminate\Database\Eloquent\Factories\Sequence;

// یکی در میان پیش‌نویس و منتشرشده
Post::factory()
    ->count(4)
    ->sequence(
        ['status' => PostStatus::Draft, 'published_at' => null],
        ['status' => PostStatus::Published, 'published_at' => now()],
    )
    ->create();

// عنوان قابل پیش‌بینی برای assert
Post::factory()
    ->count(3)
    ->sequence(fn (Sequence $sequence) => ['title' => 'پست '.($sequence->index + 1)])
    ->create();

sequence برای تست صفحه‌بندی، مرتب‌سازی و فیلتر مفید است: می‌دانید دقیقاً کدام رکورد باید در نتیجه باشد و کدام نباشد.

Relation در Factory

Post::factory()
    ->for(User::factory())
    ->for(Category::factory(), 'category')
    ->has(Tag::factory()->count(3))
    ->published()
    ->create();

for() برای belongsTo؛ has() برای hasMany/hasOne و (در رابطه many-to-many) ساخت و attach؛ hasAttached() وقتی pivot مقدار اضافه دارد. این‌ها فقط وقتی کار می‌کنند که رابطهٔ متناظر روی مدل تعریف شده باشد — برای جزئیات روابط، راهنمای روابط Eloquent را ببینید.

afterCreating — side effect کنترل‌شده

public function configure(): static
{
    return $this->afterCreating(function (Post $post) {
        $post->tags()->attach(Tag::factory()->count(2)->create());
    });
}

مراقب باشید: configure() روی همه رکوردهای این Factory اجرا می‌شود. با این مثال، هر Post::factory()->create() دو تگ اضافه هم می‌سازد — حتی در تستی که اصلاً به تگ ربطی ندارد، و اگر جای دیگری has(Tag::factory()->count(3)) هم بنویسید، پست پنج تگ می‌گیرد. side effectهایی که همیشه لازم نیستند را در یک state جدا بگذارید:

public function withTags(int $count = 2): static
{
    return $this->afterCreating(function (Post $post) use ($count) {
        $post->tags()->attach(Tag::factory()->count($count)->create());
    });
}

Seeder ترتیبی

class BlogSeeder extends Seeder
{
    public function run(): void
    {
        $user = User::factory()->create(['email' => 'author@test.local']);
        $categories = Category::factory()->count(3)->create();

        Post::factory()
            ->count(20)
            ->published()
            ->recycle($categories)
            ->for($user)
            ->create();
    }
}

recycle() برای relationها از همان collection استفاده می‌کند؛ بدون آن، Factory برای هر پست ممکن است یک دسته یا کاربر جدید بسازد و بعد از seed با ده‌ها دستهٔ بی‌معنا روبه‌رو شوید.

جلوگیری از دادهٔ تکراری

  • برای ستون‌هایی که در دیتابیس unique هستند (slug، email) از fake()->unique() یا sequence استفاده کنید تا seed وسط کار با خطای duplicate نخوابد.
  • داده پایه را با updateOrCreate یا firstOrCreate بر اساس کلید طبیعی (مثلاً slug) بنویسید تا اجرای دوباره Seeder رکورد تکراری نسازد.
  • Seederهایی که با Factory دادهٔ تصادفی می‌سازند ذاتاً idempotent نیستند؛ آن‌ها را فقط روی دیتابیس تازه (مثلاً بعد از migrate:fresh) اجرا کنید.
  • id را hardcode نکنید؛ رکورد والد را بسازید یا پیدا کنید و همان را با for() یا recycle() بدهید.

withoutModelEvents در seed انبوه

use Illuminate\Database\Console\Seeds\WithoutModelEvents;

class BlogSeeder extends Seeder
{
    use WithoutModelEvents;

    // ...
}

// یا فقط برای یک بخش:
Post::withoutEvents(fn () => Post::factory()->count(1000)->create());

هر دو از اجرای event و Observer در seed جلوگیری می‌کنند. فقط حواستان باشد اگر Observer کاری مثل ساخت slug یا رکورد وابسته را انجام می‌دهد، آن داده هم ساخته نمی‌شود و باید در خود Factory تأمین شود.

Seeder از فایل PHP (الگوی این پروژه)

داده در database/data/blog/posts.php — readable، version control، بدون Faker برای محتوای واقعی. ترکیب هر دو: Factory برای تست، PHP file برای محتوای seed ثابت.

دادهٔ تست، دادهٔ نمونه و دادهٔ اولیهٔ production

  • دادهٔ تست: داخل هر تست با Factory ساخته می‌شود و با RefreshDatabase بعد از تست از بین می‌رود. اگر همه تست‌های یک کلاس واقعاً به دادهٔ پایه نیاز دارند، در همان کلاس protected $seed = true; یا protected $seeder = RoleSeeder::class; بگذارید؛ در غیر این صورت $this->seed(RoleSeeder::class) را فقط در تست مربوط صدا بزنید.
  • دادهٔ نمونه (local/staging/demo): Seeder با Factory و حجم بیشتر؛ هدفش این است که صفحه‌ها پر و قابل بررسی باشند، نه اینکه رفتار دقیقی را ثابت کند.
  • دادهٔ اولیهٔ production: فقط داده‌ای که برنامه برای کار کردن لازم دارد، با مقادیر ثابت و idempotent. در پروژهٔ پیش‌فرض Laravel، fakerphp/faker در require-dev است؛ اگر روی سرور با composer install --no-dev نصب کنید، Factoryهایی که از fake() استفاده می‌کنند اصلاً اجرا نمی‌شوند — و نباید هم بشوند.

در محیط production، db:seed قبل از اجرا تأیید می‌خواهد و در اسکریپت deploy فقط با --force بدون سؤال اجرا می‌شود. پس --force را فقط برای Seederی بگذارید که با اطمینان idempotent است، و همیشه کلاس مشخص را صدا بزنید: php artisan db:seed --class=RoleSeeder --force، نه کل DatabaseSeederی که ممکن است Seeder دادهٔ نمونه را هم صدا بزند.

اشتباهات

  • seed production با Faker
  • Seeder دادهٔ نمونه داخل DatabaseSeederی که روی production هم اجرا می‌شود
  • side effect همیشگی در configure() که در هر تست داده اضافه می‌سازد
  • assert روی مقدار تصادفی Faker به‌جای مقدار صریح
  • فراموش unique روی slug/email
  • ساخت ۱۰۰۰ رکورد با Observer سنگین
  • hardcode id به‌جای factory relation

جمع‌بندی

Factory = تعریف رکورد معتبر و حالت‌هایش، برای تست سریع و خوانا. Seeder = اجرای قابل تکرار داده در محیط مشخص. برای تست، Factory را داخل خود تست به کار ببرید؛ برای local و demo، Seeder + Factory؛ برای production، فقط Seeder با داده ثابت و idempotent. دستورهای db:seed و migrate:fresh را هم در راهنمای Artisan ببینید؛ برای نوشتن خود تست‌های HTTP، Feature Test در Laravel.

راه‌اندازی staging

اشتراک‌گذاری