laravel-tdd
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseLaravel TDD Workflow
Laravel TDD 工作流
Test-driven development for Laravel applications using PHPUnit and Pest with 80%+ coverage (unit + feature).
使用PHPUnit和Pest为Laravel应用实现测试驱动开发,达成80%以上的测试覆盖率(单元测试+功能测试)。
When to Use
适用场景
- New features or endpoints in Laravel
- Bug fixes or refactors
- Testing Eloquent models, policies, jobs, and notifications
- Prefer Pest for new tests unless the project already standardizes on PHPUnit
- Laravel中的新功能或新接口开发
- Bug修复或代码重构
- 测试Eloquent模型、策略、任务和通知
- 除非项目已统一使用PHPUnit,否则新测试优先选择Pest
How It Works
工作原理
Red-Green-Refactor Cycle
红-绿-重构循环
- Write a failing test
- Implement the minimal change to pass
- Refactor while keeping tests green
- 编写一个失败的测试用例
- 实现最小化修改使测试通过
- 在保持测试通过的前提下进行代码重构
Test Layers
测试层级
- Unit: pure PHP classes, value objects, services
- Feature: HTTP endpoints, auth, validation, policies
- Integration: database + queue + external boundaries
Choose layers based on scope:
- Use Unit tests for pure business logic and services.
- Use Feature tests for HTTP, auth, validation, and response shape.
- Use Integration tests when validating DB/queues/external services together.
- 单元测试:纯PHP类、值对象、服务
- 功能测试:HTTP接口、认证、验证、策略
- 集成测试:数据库+队列+外部依赖边界
根据测试范围选择对应层级:
- 纯业务逻辑和服务使用单元测试。
- HTTP、认证、验证和响应结构使用功能测试。
- 需要验证数据库/队列/外部服务协同工作时使用集成测试。
Database Strategy
数据库策略
- for most feature/integration tests (runs migrations once per test run, then wraps each test in a transaction when supported; in-memory databases may re-migrate per test)
RefreshDatabase - when the schema is already migrated and you only need per-test rollback
DatabaseTransactions - when you need a full migrate/fresh for every test and can afford the cost
DatabaseMigrations
Use as the default for tests that touch the database: for databases with transaction support, it runs migrations once per test run (via a static flag) and wraps each test in a transaction; for SQLite or connections without transactions, it migrates before each test. Use when the schema is already migrated and you only need per-test rollbacks.
RefreshDatabase:memory:DatabaseTransactions- 大多数功能/集成测试使用(在支持事务的数据库中,每次测试运行仅执行一次迁移,然后将每个测试包裹在事务中;内存数据库可能会在每次测试时重新执行迁移)
RefreshDatabase - 当架构已完成迁移,仅需要每次测试回滚时使用
DatabaseTransactions - 当需要为每个测试执行完整的迁移/重置操作且可以接受其耗时成本时使用
DatabaseMigrations
对于涉及数据库的测试,默认使用:对于支持事务的数据库,它会通过静态标记在每次测试运行时仅执行一次迁移,并将每个测试包裹在事务中;对于 SQLite或不支持事务的连接,它会在每次测试前执行迁移。当架构已完成迁移且仅需要每次测试回滚时,使用。
RefreshDatabase:memory:DatabaseTransactionsTesting Framework Choice
测试框架选择
- Default to Pest for new tests when available.
- Use PHPUnit only if the project already standardizes on it or requires PHPUnit-specific tooling.
- 若可用,新测试默认选择Pest。
- 仅当项目已统一使用PHPUnit或需要特定PHPUnit工具时,才使用PHPUnit。
Examples
示例
PHPUnit Example
PHPUnit示例
php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ProjectControllerTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_create_project(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'New Project',
]);
$response->assertCreated();
$this->assertDatabaseHas('projects', ['name' => 'New Project']);
}
}php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ProjectControllerTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_create_project(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'New Project',
]);
$response->assertCreated();
$this->assertDatabaseHas('projects', ['name' => 'New Project']);
}
}Feature Test Example (HTTP Layer)
功能测试示例(HTTP层)
php
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ProjectIndexTest extends TestCase
{
use RefreshDatabase;
public function test_projects_index_returns_paginated_results(): void
{
$user = User::factory()->create();
Project::factory()->count(3)->for($user)->create();
$response = $this->actingAs($user)->getJson('/api/projects');
$response->assertOk();
$response->assertJsonStructure(['success', 'data', 'error', 'meta']);
}
}php
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ProjectIndexTest extends TestCase
{
use RefreshDatabase;
public function test_projects_index_returns_paginated_results(): void
{
$user = User::factory()->create();
Project::factory()->count(3)->for($user)->create();
$response = $this->actingAs($user)->getJson('/api/projects');
$response->assertOk();
$response->assertJsonStructure(['success', 'data', 'error', 'meta']);
}
}Pest Example
Pest示例
php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\assertDatabaseHas;
uses(RefreshDatabase::class);
test('owner can create project', function () {
$user = User::factory()->create();
$response = actingAs($user)->postJson('/api/projects', [
'name' => 'New Project',
]);
$response->assertCreated();
assertDatabaseHas('projects', ['name' => 'New Project']);
});php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\assertDatabaseHas;
uses(RefreshDatabase::class);
test('owner can create project', function () {
$user = User::factory()->create();
$response = actingAs($user)->postJson('/api/projects', [
'name' => 'New Project',
]);
$response->assertCreated();
assertDatabaseHas('projects', ['name' => 'New Project']);
});Feature Test Pest Example (HTTP Layer)
Pest功能测试示例(HTTP层)
php
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\actingAs;
uses(RefreshDatabase::class);
test('projects index returns paginated results', function () {
$user = User::factory()->create();
Project::factory()->count(3)->for($user)->create();
$response = actingAs($user)->getJson('/api/projects');
$response->assertOk();
$response->assertJsonStructure(['success', 'data', 'error', 'meta']);
});php
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\actingAs;
uses(RefreshDatabase::class);
test('projects index returns paginated results', function () {
$user = User::factory()->create();
Project::factory()->count(3)->for($user)->create();
$response = actingAs($user)->getJson('/api/projects');
$response->assertOk();
$response->assertJsonStructure(['success', 'data', 'error', 'meta']);
});Factories and States
工厂与状态
- Use factories for test data
- Define states for edge cases (archived, admin, trial)
php
$user = User::factory()->state(['role' => 'admin'])->create();- 使用工厂生成测试数据
- 为边缘情况定义状态(已归档、管理员、试用)
php
$user = User::factory()->state(['role' => 'admin'])->create();Database Testing
数据库测试
- Use for clean state
RefreshDatabase - Keep tests isolated and deterministic
- Prefer over manual queries
assertDatabaseHas
- 使用保证干净的测试状态
RefreshDatabase - 保持测试独立且可预测
- 优先使用而非手动查询
assertDatabaseHas
Persistence Test Example
持久化测试示例
php
use App\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ProjectRepositoryTest extends TestCase
{
use RefreshDatabase;
public function test_project_can_be_retrieved_by_slug(): void
{
$project = Project::factory()->create(['slug' => 'alpha']);
$found = Project::query()->where('slug', 'alpha')->firstOrFail();
$this->assertSame($project->id, $found->id);
}
}php
use App\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ProjectRepositoryTest extends TestCase
{
use RefreshDatabase;
public function test_project_can_be_retrieved_by_slug(): void
{
$project = Project::factory()->create(['slug' => 'alpha']);
$found = Project::query()->where('slug', 'alpha')->firstOrFail();
$this->assertSame($project->id, $found->id);
}
}Fakes for Side Effects
副作用模拟对象
- for jobs
Bus::fake() - for queued work
Queue::fake() - and
Mail::fake()for notificationsNotification::fake() - for domain events
Event::fake()
php
use Illuminate\Support\Facades\Queue;
Queue::fake();
dispatch(new SendOrderConfirmation($order->id));
Queue::assertPushed(SendOrderConfirmation::class);php
use Illuminate\Support\Facades\Notification;
Notification::fake();
$user->notify(new InvoiceReady($invoice));
Notification::assertSentTo($user, InvoiceReady::class);- 用于任务模拟
Bus::fake() - 用于队列任务模拟
Queue::fake() - 和
Mail::fake()用于通知模拟Notification::fake() - 用于领域事件模拟
Event::fake()
php
use Illuminate\Support\Facades\Queue;
Queue::fake();
dispatch(new SendOrderConfirmation($order->id));
Queue::assertPushed(SendOrderConfirmation::class);php
use Illuminate\Support\Facades\Notification;
Notification::fake();
$user->notify(new InvoiceReady($invoice));
Notification::assertSentTo($user, InvoiceReady::class);Auth Testing (Sanctum)
认证测试(Sanctum)
php
use Laravel\Sanctum\Sanctum;
Sanctum::actingAs($user);
$response = $this->getJson('/api/projects');
$response->assertOk();php
use Laravel\Sanctum\Sanctum;
Sanctum::actingAs($user);
$response = $this->getJson('/api/projects');
$response->assertOk();HTTP and External Services
HTTP与外部服务
- Use to isolate external APIs
Http::fake() - Assert outbound payloads with
Http::assertSent()
- 使用隔离外部API
Http::fake() - 通过断言出站请求负载
Http::assertSent()
Coverage Targets
覆盖率目标
- Enforce 80%+ coverage for unit + feature tests
- Use or
pcovin CIXDEBUG_MODE=coverage
- 强制要求单元测试+功能测试覆盖率达到80%以上
- 在CI中使用或
pcov生成覆盖率报告XDEBUG_MODE=coverage
Test Commands
测试命令
php artisan testvendor/bin/phpunitvendor/bin/pest
php artisan testvendor/bin/phpunitvendor/bin/pest
Test Configuration
测试配置
- Use to set
phpunit.xmlandDB_CONNECTION=sqlitefor fast testsDB_DATABASE=:memory: - Keep separate env for tests to avoid touching dev/prod data
- 在中设置
phpunit.xml和DB_CONNECTION=sqlite以实现快速测试DB_DATABASE=:memory: - 使用独立的测试环境变量,避免影响开发/生产数据
Authorization Tests
授权测试
php
use Illuminate\Support\Facades\Gate;
$this->assertTrue(Gate::forUser($user)->allows('update', $project));
$this->assertFalse(Gate::forUser($otherUser)->allows('update', $project));php
use Illuminate\Support\Facades\Gate;
$this->assertTrue(Gate::forUser($user)->allows('update', $project));
$this->assertFalse(Gate::forUser($otherUser)->allows('update', $project));Inertia Feature Tests
Inertia功能测试
When using Inertia.js, assert on the component name and props with the Inertia testing helpers.
php
use App\Models\User;
use Inertia\Testing\AssertableInertia;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class DashboardInertiaTest extends TestCase
{
use RefreshDatabase;
public function test_dashboard_inertia_props(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('Dashboard')
->where('user.id', $user->id)
->has('projects')
);
}
}Prefer over raw JSON assertions to keep tests aligned with Inertia responses.
assertInertia当使用Inertia.js时,使用Inertia测试助手断言组件名称和属性。
php
use App\Models\User;
use Inertia\Testing\AssertableInertia;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class DashboardInertiaTest extends TestCase
{
use RefreshDatabase;
public function test_dashboard_inertia_props(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('Dashboard')
->where('user.id', $user->id)
->has('projects')
);
}
}优先使用而非原始JSON断言,使测试与Inertia响应保持一致。
assertInertia