Bài 9 — Seeder và Factory: tạo dữ liệu thực hành cho schema TaskFlow từ bài 8. Mục tiêu là có dữ liệu liên kết đúng, test không phụ thuộc may rủi và seeder demo có thể chạy lại mà không nhân đôi các record còn nguyên khóa nhận diện.
1. Factory và seeder giải quyết hai việc khác nhau
Factory định nghĩa cách tạo model mẫu; seeder điều phối dữ liệu ban đầu cho một môi trường. Test thường cần record tối thiểu với thuộc tính quyết định được chỉ định rõ. Demo cần vài tên cố định để người học cùng nhìn một ví dụ. Không dùng dữ liệu khách hàng thật hoặc dump production để đạt mục tiêu này.
make() tạo model chưa lưu, create() ghi database; factory có quan hệ lồng nhau có thể tạo record liên quan, nên đừng dùng chỉ tên method để suy luận mọi tác dụng phụ. Test quan trọng phải tự xác nhận số record và relationship. Tham khảo Eloquent factories và database seeding.
2. Thêm model tối thiểu
Tạo hai file dưới đây. HasFactory nối model với factory theo quy ước tên. Các quan hệ owner, project và tasks giúp factory dùng for() và seeder tạo task qua project. owner_id, project_id và assignee_id không nằm trong fillable: những ID này sẽ được gán có chủ đích qua quan hệ hoặc logic server, không lấy tràn từ form.
app/Models/Project.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Project extends Model
{
use HasFactory;
protected $fillable = ['name', 'slug'];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function tasks(): HasMany
{
return $this->hasMany(Task::class);
}
}app/Models/Task.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Task extends Model
{
use HasFactory;
protected $fillable = ['title', 'description', 'status', 'priority', 'due_at'];
protected function casts(): array
{
return ['due_at' => 'immutable_datetime'];
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_id');
}
}Đây là bước giới thiệu model để chạy factory; CRUD, cast và quan hệ sẽ được phân tích sâu ở các bài sau. Fillable không phải policy và cũng không phải validation. Factory/seeding có cơ chế bỏ bảo vệ mass assignment để tạo dữ liệu, vì vậy factory pass không chứng minh endpoint HTTP an toàn.
3. Tạo factory với mặc định dễ dự đoán
Giữ UserFactory do skeleton cung cấp rồi tạo hai factory sau:
database/factories/ProjectFactory.php
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class ProjectFactory extends Factory
{
public function definition(): array
{
return [
'owner_id' => User::factory(),
'name' => fake()->words(3, true),
'slug' => 'project-'.Str::uuid(),
];
}
}database/factories/TaskFactory.php
<?php
namespace Database\Factories;
use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory;
class TaskFactory extends Factory
{
public function definition(): array
{
return [
'project_id' => Project::factory(),
'assignee_id' => null,
'title' => fake()->sentence(5),
'description' => null,
'status' => 'todo',
'priority' => 'normal',
'due_at' => null,
];
}
public function done(): static
{
return $this->state(fn () => ['status' => 'done']);
}
}TaskFactory mặc định tạo project nếu không được cấp project; project lại tạo owner. Để ba task cùng project, dùng Task::factory()->count(3)->for($project)->done()->create(). Nếu bỏ for(), số project sinh ra có thể khác mục tiêu của test. State done() diễn đạt ý định rõ hơn việc phân tán chuỗi trạng thái ở nhiều test.
Faker chỉ dùng cho dữ liệu không quyết định assertion. Muốn kiểm tra thứ tự title, hãy truyền title cố định. Muốn kiểm tra hạn, cố định đồng hồ và due_at. UUID slug giảm nguy cơ trùng cho factory nhưng không biến dữ liệu ngẫu nhiên thành fixture tái lập từng byte.
4. Seeder demo chạy lại có kiểm soát
database/seeders/TaskFlowDemoSeeder.php
<?php
namespace Database\Seeders;
use App\Models\Project;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class TaskFlowDemoSeeder extends Seeder
{
public function run(): void
{
if (! app()->environment(['local', 'testing'])) {
throw new RuntimeException('Demo seeding is restricted to local/testing.');
}
DB::transaction(function () {
$owner = User::firstOrCreate(['email' => 'taskflow-demo@example.test'], [
'name' => 'TaskFlow Demo',
'password' => Str::random(48),
]);
$project = Project::where('owner_id', $owner->id)->where('slug', 'course-demo')->first();
if (! $project) {
$project = new Project(['name' => 'Course Demo', 'slug' => 'course-demo']);
$project->owner()->associate($owner);
$project->save();
}
foreach (['Read the brief', 'Write a test', 'Review the change'] as $title) {
$project->tasks()->firstOrCreate(['title' => $title], ['status' => 'todo', 'priority' => 'normal']);
}
});
}
}Trong run() của DatabaseSeeder, thay phần tạo Test User mặc định bằng $this->call(TaskFlowDemoSeeder::class);. Chỉ chạy trên database local riêng sau khi đã xác nhận connection và migrate:
php artisan migrate
php artisan db:seed --class=TaskFlowDemoSeeder
php artisan db:seed --class=TaskFlowDemoSeeder
Seeder dùng email demo, slug project và title task làm khóa tìm. firstOrCreate giữ trạng thái task đã sửa nếu title vẫn giữ nguyên. Nếu đổi title của task demo rồi seed lại, task mang title cũ sẽ được tạo lại; đây không phải đồng bộ hoàn hảo hay idempotency tổng quát. Không chạy nhiều seeder đồng thời: tasks chưa có unique(project_id, title), nên cách này không bảo đảm chống race.
Mật khẩu owner demo được sinh ngẫu nhiên và không in ra; User model của skeleton hash password qua cast. UserFactory mặc định có mật khẩu thử nghiệm dễ đoán nên chỉ dùng ở local/test. Guard môi trường trong seeder là lớp bảo vệ bổ sung, không thay thế kiểm tra connection: APP_ENV=local vẫn có thể trỏ nhầm database nếu cấu hình sai.
5. Kiểm thử quan hệ, chạy lại và môi trường
tests/Feature/TaskFlowFactoryTest.php
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Task;
use Database\Seeders\TaskFlowDemoSeeder;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Tests\TestCase;
class TaskFlowFactoryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'database.connections.sqlite.url' => null]);
DB::purge('sqlite');
$this->artisan('migrate', ['--force' => true])->assertSuccessful();
}
public function test_factory_reuses_the_explicit_project_and_applies_state(): void
{
$project = Project::factory()->create();
$tasks = Task::factory()->count(3)->for($project)->done()->create();
$this->assertDatabaseCount('projects', 1);
$this->assertDatabaseCount('tasks', 3);
$this->assertTrue($tasks->every(fn ($task) => $task->project_id === $project->id && $task->status === 'done'));
}
public function test_demo_seeder_can_run_twice_without_duplicates_or_resetting_edits(): void
{
$this->seed(TaskFlowDemoSeeder::class);
Task::where('title', 'Read the brief')->update(['status' => 'done']);
$this->seed(TaskFlowDemoSeeder::class);
$this->assertDatabaseCount('users', 1);
$this->assertDatabaseCount('projects', 1);
$this->assertDatabaseCount('tasks', 3);
$this->assertDatabaseHas('tasks', ['title' => 'Read the brief', 'status' => 'done']);
}
public function test_demo_seeder_rejects_production(): void
{
$this->app->instance('env', 'production');
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Demo seeding is restricted');
$this->app->make(TaskFlowDemoSeeder::class)->run();
}
}Test đặt database về SQLite bộ nhớ trước migrate. Test production chỉ đổi nhãn môi trường và gọi trực tiếp run() để kiểm tra guard; nó không kết nối production. Các test khác kiểm tra chỉ có một project, state done được áp dụng, seed hai lần không tăng số record và không đặt lại trạng thái đã sửa.
php artisan test --filter=TaskFlowFactoryTest
php artisan test
Toàn suite đã pass 21 test, 64 assertions. Dữ liệu minh họa được tạo trong database test dùng một lần; ở mốc này chưa seed database local trên đĩa. Form preview của bài 7 vẫn không lưu task — có model/factory không tự thay đổi hành vi HTTP.
6. Bài tập
Tạo một project có ba task todo và hai task done, rồi assert từng số lượng bằng query theo project_id. Thử bỏ for($project) và quan sát tác dụng phụ. Sau đó đổi title một task demo, seed lại và giải thích vì sao số task tăng. Đừng chữa bằng truncate bảng chứa dữ liệu cần giữ.
Điều hướng: Bài 8 · Lộ trình. Bài tiếp theo đi sâu vào Eloquent CRUD, cast và mass assignment.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.