mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-14 13:53:29 +08:00
284 lines
7.2 KiB
Markdown
284 lines
7.2 KiB
Markdown
---
|
||
name: laravel-tdd
|
||
description: 使用 PHPUnit 和 Pest、工厂、数据库测试、模拟以及覆盖率目标进行 Laravel 的测试驱动开发。
|
||
origin: ECC
|
||
---
|
||
|
||
# Laravel TDD 工作流
|
||
|
||
使用 PHPUnit 和 Pest 为 Laravel 应用程序进行测试驱动开发,覆盖率(单元 + 功能)达到 80% 以上。
|
||
|
||
## 使用时机
|
||
|
||
* Laravel 中的新功能或端点
|
||
* 错误修复或重构
|
||
* 测试 Eloquent 模型、策略、作业和通知
|
||
* 除非项目已标准化使用 PHPUnit,否则新测试首选 Pest
|
||
|
||
## 工作原理
|
||
|
||
### 红-绿-重构循环
|
||
|
||
1. 编写一个失败的测试
|
||
2. 实施最小更改以通过测试
|
||
3. 在保持测试通过的同时进行重构
|
||
|
||
### 测试层级
|
||
|
||
* **单元**:纯 PHP 类、值对象、服务
|
||
* **功能**:HTTP 端点、身份验证、验证、策略
|
||
* **集成**:数据库 + 队列 + 外部边界
|
||
|
||
根据范围选择层级:
|
||
|
||
* 对纯业务逻辑和服务使用**单元**测试。
|
||
* 对 HTTP、身份验证、验证和响应结构使用**功能**测试。
|
||
* 当需要验证数据库/队列/外部服务组合时使用**集成**测试。
|
||
|
||
### 数据库策略
|
||
|
||
* 对于大多数功能/集成测试使用 `RefreshDatabase`(每次测试运行运行一次迁移,然后在支持时将每个测试包装在事务中;内存数据库可能每次测试重新迁移)
|
||
* 当模式已迁移且仅需要每次测试回滚时使用 `DatabaseTransactions`
|
||
* 当每次测试都需要完整迁移/刷新且可以承担其开销时使用 `DatabaseMigrations`
|
||
|
||
将 `RefreshDatabase` 作为触及数据库的测试的默认选择:对于支持事务的数据库,它每次测试运行运行一次迁移(通过静态标志)并将每个测试包装在事务中;对于 `:memory:` SQLite 或不支持事务的连接,它在每次测试前进行迁移。当模式已迁移且仅需要每次测试回滚时使用 `DatabaseTransactions`。
|
||
|
||
### 测试框架选择
|
||
|
||
* 新测试默认使用 **Pest**(当可用时)。
|
||
* 仅在项目已标准化使用它或需要 PHPUnit 特定工具时使用 **PHPUnit**。
|
||
|
||
## 示例
|
||
|
||
### 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']);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 功能测试示例(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']);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 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']);
|
||
});
|
||
```
|
||
|
||
### 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
|
||
$user = User::factory()->state(['role' => 'admin'])->create();
|
||
```
|
||
|
||
### 数据库测试
|
||
|
||
* 使用 `RefreshDatabase` 保持干净状态
|
||
* 保持测试隔离和确定性
|
||
* 优先使用 `assertDatabaseHas` 而非手动查询
|
||
|
||
### 持久性测试示例
|
||
|
||
```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);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 副作用模拟
|
||
|
||
* 作业使用 `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);
|
||
```
|
||
|
||
### 身份验证测试(Sanctum)
|
||
|
||
```php
|
||
use Laravel\Sanctum\Sanctum;
|
||
|
||
Sanctum::actingAs($user);
|
||
|
||
$response = $this->getJson('/api/projects');
|
||
$response->assertOk();
|
||
```
|
||
|
||
### HTTP 和外部服务
|
||
|
||
* 使用 `Http::fake()` 隔离外部 API
|
||
* 使用 `Http::assertSent()` 断言出站负载
|
||
|
||
### 覆盖率目标
|
||
|
||
* 对单元 + 功能测试强制执行 80% 以上的覆盖率
|
||
* 在 CI 中使用 `pcov` 或 `XDEBUG_MODE=coverage`
|
||
|
||
### 测试命令
|
||
|
||
* `php artisan test`
|
||
* `vendor/bin/phpunit`
|
||
* `vendor/bin/pest`
|
||
|
||
### 测试配置
|
||
|
||
* 使用 `phpunit.xml` 设置 `DB_CONNECTION=sqlite` 和 `DB_DATABASE=:memory:` 以进行快速测试
|
||
* 为测试保持独立的环境,以避免触及开发/生产数据
|
||
|
||
### 授权测试
|
||
|
||
```php
|
||
use Illuminate\Support\Facades\Gate;
|
||
|
||
$this->assertTrue(Gate::forUser($user)->allows('update', $project));
|
||
$this->assertFalse(Gate::forUser($otherUser)->allows('update', $project));
|
||
```
|
||
|
||
### Inertia 功能测试
|
||
|
||
使用 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 响应一致。
|