laravel-tdd

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Laravel 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

红-绿-重构循环

  1. Write a failing test
  2. Implement the minimal change to pass
  3. Refactor while keeping tests green
  1. 编写一个失败的测试用例
  2. 实现最小化修改使测试通过
  3. 在保持测试通过的前提下进行代码重构

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

数据库策略

  • RefreshDatabase
    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)
  • DatabaseTransactions
    when the schema is already migrated and you only need per-test rollback
  • DatabaseMigrations
    when you need a full migrate/fresh for every test and can afford the cost
Use
RefreshDatabase
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
:memory:
SQLite or connections without transactions, it migrates before each test. Use
DatabaseTransactions
when the schema is already migrated and you only need per-test rollbacks.
  • 大多数功能/集成测试使用
    RefreshDatabase
    (在支持事务的数据库中,每次测试运行仅执行一次迁移,然后将每个测试包裹在事务中;内存数据库可能会在每次测试时重新执行迁移)
  • 当架构已完成迁移,仅需要每次测试回滚时使用
    DatabaseTransactions
  • 当需要为每个测试执行完整的迁移/重置操作且可以接受其耗时成本时使用
    DatabaseMigrations
对于涉及数据库的测试,默认使用
RefreshDatabase
:对于支持事务的数据库,它会通过静态标记在每次测试运行时仅执行一次迁移,并将每个测试包裹在事务中;对于
:memory:
SQLite或不支持事务的连接,它会在每次测试前执行迁移。当架构已完成迁移且仅需要每次测试回滚时,使用
DatabaseTransactions

Testing 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
    RefreshDatabase
    for clean state
  • Keep tests isolated and deterministic
  • Prefer
    assertDatabaseHas
    over manual queries
  • 使用
    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

副作用模拟对象

  • Bus::fake()
    for jobs
  • Queue::fake()
    for queued work
  • Mail::fake()
    and
    Notification::fake()
    for notifications
  • Event::fake()
    for domain events
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
    Http::fake()
    to isolate external APIs
  • Assert outbound payloads with
    Http::assertSent()
  • 使用
    Http::fake()
    隔离外部API
  • 通过
    Http::assertSent()
    断言出站请求负载

Coverage Targets

覆盖率目标

  • Enforce 80%+ coverage for unit + feature tests
  • Use
    pcov
    or
    XDEBUG_MODE=coverage
    in CI
  • 强制要求单元测试+功能测试覆盖率达到80%以上
  • 在CI中使用
    pcov
    XDEBUG_MODE=coverage
    生成覆盖率报告

Test Commands

测试命令

  • php artisan test
  • vendor/bin/phpunit
  • vendor/bin/pest
  • php artisan test
  • vendor/bin/phpunit
  • vendor/bin/pest

Test Configuration

测试配置

  • Use
    phpunit.xml
    to set
    DB_CONNECTION=sqlite
    and
    DB_DATABASE=:memory:
    for fast tests
  • 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
assertInertia
over raw JSON assertions to keep tests aligned with Inertia responses.
当使用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')
        );
    }
}
优先使用
assertInertia
而非原始JSON断言,使测试与Inertia响应保持一致。