跳到正文
cccskills
免费在 GitHub 公开中文介绍

laravel-tdd

从测试开始实现 Laravel 功能,使用 PHPUnit 或 Pest 验证模型、API、身份认证和权限,模拟外部服务,并通过覆盖率了解测试执行了哪些代码。

原文查看原文说明

Laravel testing strategies with PHPUnit, Pest, model factories, HTTP tests, Sanctum authentication testing, mocking, and coverage. Use when writing Laravel tests with PHPUnit or Pest, or driving a Laravel feature test-first.

AI 评分100分/ 100查看安装方法

适用场景

  • 从测试开始实现 Laravel 功能
  • 验证 API 身份认证与权限
  • 模拟支付、邮件和队列
  • 配置测试环境并检查覆盖率

中文介绍

功能

此技能支持 Laravel 的测试驱动开发:先编写失败的测试,再实现功能使其通过,最后改进代码。通过 PHPUnit 和 Pest 示例,构建针对 Eloquent 模型关联与查询条件、页面请求、JSON API、输入校验、身份认证和权限的测试。还介绍使用模型工厂生成测试数据,以及模拟邮件、队列和外部 HTTP 请求的方法。

适用场景

适合在实现新功能前,先用测试确定预期行为。需要检查未登录访问、修改他人数据或支付失败等情况时,也可使用。此外,还可用于配置测试环境,并检查覆盖率,了解测试实际执行了哪些代码。

使用示例

  • “从 Pest 测试开始,实现商品创建 API。”
  • “编写 Sanctum 登录和令牌认证的测试。”
  • “模拟外部支付成功与失败的情况,并验证结果。”

注意事项

需要已有 Laravel 项目,并使用 PHPUnit 或 Pest。配置示例采用 SQLite 内存数据库。资料中的覆盖率数值是目标,并不保证能够达到。

本介绍由 AI(OpenAI Codex)根据公开的 SKILL.md 撰写。准确的规格请查看下方原文。

客观评估

AI 仅根据 SKILL.md 的内容,从 4 个方面进行了评估。星标数和热度不计入评估。

指令清晰度能否看出何时使用、做什么
A良好列出功能开发、模型与 HTTP 测试、身份认证和外部服务模拟等适用场景。
步骤具体性步骤、输入输出和示例是否具体
A良好提供失败、通过、重构步骤,以及配置、工厂、HTTP 响应和数据库断言示例。
安全性发送数据、删除文件、执行命令等是否有说明
A良好测试配置指定 SQLite 内存数据库、数组邮件存储和队列同步执行。
安装难易度所需工具和 API 密钥是否少且有说明
A良好明确以 Laravel 和 PHPUnit 或 Pest 为前提,并提供 PHPUnit 配置和基础测试类。

总分按每项 A=25 分、B=15 分、C=5 分相加。 由 AI(OpenAI Codex)于 2026年10月19日 评估。仅供参考,安装前请同时查看原文。

如果您认为评估有误,请申请复核

请写明哪一项有误以及原因。无需登录。我们会对照 SKILL.md 重新评估,并在此处更新结果。

此表单受 reCAPTCHA 保护,适用 Google 的隐私权政策和服务条款。

包含的文件(1)

  • SKILL.md17.4 KB

SKILL.md(原文)

安装前可以查看交给 AI 代理的指令内容。

Laravel Testing with TDD

Test-driven development for Laravel applications using PHPUnit, Pest, Laravel factories, and testing helpers.

When to Activate

  • Writing new Laravel applications or features
  • Implementing API endpoints with Sanctum or Passport authentication
  • Testing Eloquent models, relationships, scopes, and accessors
  • Setting up testing infrastructure for Laravel projects
  • Writing feature tests for HTTP controllers and form requests
  • Mocking external services (queues, mail, notifications, HTTP)

TDD Workflow for Laravel

Red-Green-Refactor Cycle

// Step 1: RED — Write a failing test
public function test_a_product_can_be_created(): void
{
    $product = Product::factory()->create(['name' => 'Test Product']);
    $this->assertDatabaseHas('products', ['name' => 'Test Product']);
}

// Step 2: GREEN — Write the migration, model, and factory
// Step 3: REFACTOR — Improve while keeping tests green

Setup

PHPUnit Configuration

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="Unit">
            <directory suffix="Test.php">tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">tests/Feature</directory>
        </testsuite>
    </testsuites>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="CACHE_STORE" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
    </php>
</phpunit>

Base TestCase Setup

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        // Call $this->withoutExceptionHandling() only in tests that
        // test non-HTTP exceptions; it suppresses assertStatus() etc.
    }

    // Helper: Authenticate and return user
    protected function actingAsUser(): mixed
    {
        $user = \App\Models\User::factory()->create();
        $this->actingAs($user);
        return $user;
    }

    protected function actingAsAdmin(): mixed
    {
        $admin = \App\Models\User::factory()->admin()->create();
        $this->actingAs($admin);
        return $admin;
    }
}

Model Factories

// database/factories/UserFactory.php
class UserFactory extends Factory
{
    protected static ?string $password = null;

    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
            'email_verified_at' => now(),
            'password' => static::$password ??= Hash::make('password'),
            'remember_token' => Str::random(10),
            'role' => 'user',
        ];
    }

    public function admin(): static
    {
        return $this->state(fn (array $attributes) => ['role' => 'admin']);
    }

    public function unverified(): static
    {
        return $this->state(fn (array $attributes) => ['email_verified_at' => null]);
    }
}

// database/factories/ProductFactory.php
class ProductFactory extends Factory
{
    public function definition(): array
    {
        return [
            'name' => fake()->unique()->words(3, true),
            'slug' => fn (array $attrs) => Str::slug($attrs['name']),
            'description' => fake()->paragraph(),
            'price' => fake()->numberBetween(100, 100000),
            'stock' => fake()->numberBetween(0, 100),
            'is_active' => true,
            'user_id' => UserFactory::new(),
        ];
    }

    public function outOfStock(): static
    {
        return $this->state(fn (array $attributes) => ['stock' => 0]);
    }
}

Using Factories

$user = User::factory()->create();
$admin = User::factory()->admin()->create();
$product = Product::factory()->create(['user_id' => $user->id]);
$products = Product::factory()->count(10)->create();
$draft = Product::factory()->make(); // Not persisted

// With relationships
$user = User::factory()->has(Product::factory()->count(3))->create();

// Sequences
User::factory()->count(3)->sequence(
    ['role' => 'admin'], ['role' => 'editor'], ['role' => 'user'],
)->create();

Model Testing

namespace Tests\Unit\Models;

use App\Models\User;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class UserTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_hides_sensitive_attributes(): void
    {
        $user = User::factory()->create();
        $this->assertArrayNotHasKey('password', $user->toArray());
    }

    public function test_admin_scope_returns_only_admins(): void
    {
        User::factory()->admin()->create();
        User::factory()->count(3)->create();

        $this->assertCount(1, User::admin()->get());
    }
}

class ProductTest extends TestCase
{
    use RefreshDatabase;

    public function test_active_scope_filters_correctly(): void
    {
        Product::factory()->count(3)->create(['is_active' => true]);
        Product::factory()->count(2)->create(['is_active' => false]);

        $this->assertCount(3, Product::active()->get());
    }

    public function test_it_belongs_to_a_user(): void
    {
        $user = User::factory()->create();
        $product = Product::factory()->create(['user_id' => $user->id]);

        $this->assertTrue($product->user->is($user));
    }
}

Feature / HTTP Testing

namespace Tests\Feature\Http\Controllers;

use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ProductControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_guests_are_redirected_to_login(): void
    {
        $this->get(route('products.create'))->assertRedirect(route('login'));
    }

    public function test_it_stores_a_new_product(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        $response = $this->post(route('products.store'), [
            'name' => 'New Product',
            'description' => 'Description',
            'price' => 2999,
            'stock' => 10,
        ]);

        $response->assertRedirect(route('products.index'));
        $this->assertDatabaseHas('products', [
            'name' => 'New Product',
            'user_id' => $user->id,
        ]);
    }

    public function test_it_validates_required_fields(): void
    {
        $this->actingAs(User::factory()->create());
        $this->post(route('products.store'), [])
            ->assertSessionHasErrors(['name', 'price']);
    }

    public function test_users_cannot_modify_others_products(): void
    {
        $owner = User::factory()->create();
        $attacker = User::factory()->create();
        $product = Product::factory()->create(['user_id' => $owner->id]);

        $this->actingAs($attacker)
            ->delete(route('products.destroy', $product))
            ->assertForbidden();
    }
}

JSON API Testing

namespace Tests\Feature\Http\Controllers\Api;

use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ProductApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_unauthenticated_requests_are_rejected(): void
    {
        $this->getJson('/api/products')->assertUnauthorized();
    }

    public function test_it_lists_paginated_products(): void
    {
        $user = User::factory()->create();
        Product::factory()->count(5)->create(['user_id' => $user->id]);

        $response = $this->actingAs($user)->getJson('/api/products');

        $response->assertOk();
        $response->assertJsonCount(5, 'data');
        $response->assertJsonStructure([
            'data' => [['id', 'name', 'price']],
            'meta' => ['current_page', 'last_page', 'total'],
        ]);
    }

    public function test_it_creates_a_product(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->postJson('/api/products', [
            'name' => 'API Product',
            'price' => 4999,
        ]);

        $response->assertCreated();
        $response->assertJsonPath('data.name', 'API Product');
    }

    public function test_users_cannot_delete_others_products(): void
    {
        $owner = User::factory()->create();
        $attacker = User::factory()->create();
        $product = Product::factory()->create(['user_id' => $owner->id]);

        $this->actingAs($attacker)
            ->deleteJson("/api/products/{$product->id}")
            ->assertForbidden();
    }
}

Sanctum API Auth Testing

namespace Tests\Feature\Http\Controllers\Api;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;

class AuthControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_users_can_register(): void
    {
        $response = $this->postJson('/api/register', [
            'name' => 'Test User',
            'email' => 'test@example.com',
            'password' => 'Password123!',
            'password_confirmation' => 'Password123!',
        ]);

        $response->assertCreated();
        $response->assertJsonStructure(['data' => ['user', 'token']]);
    }

    public function test_users_can_login(): void
    {
        User::factory()->create([
            'email' => 'test@example.com',
            'password' => Hash::make('Password123!'),
        ]);

        $response = $this->postJson('/api/login', [
            'email' => 'test@example.com',
            'password' => 'Password123!',
        ]);

        $response->assertOk();
        $response->assertJsonStructure(['data' => ['token']]);
    }

    public function test_users_cannot_login_with_wrong_password(): void
    {
        User::factory()->create(['email' => 'test@example.com']);

        $this->postJson('/api/login', [
            'email' => 'test@example.com',
            'password' => 'wrong',
        ])->assertUnprocessable();
    }

    public function test_token_bearer_authenticates_requests(): void
    {
        $user = User::factory()->create();
        $token = $user->createToken('test')->plainTextToken;

        $this->withToken($token)
            ->getJson('/api/user')
            ->assertOk()
            ->assertJsonPath('data.email', $user->email);
    }
}

Mocking and Fakes

HTTP Fake

use Illuminate\Support\Facades\Http;

public function test_it_handles_successful_payment(): void
{
    Http::fake([
        'api.stripe.com/*' => Http::response(['id' => 'pi_123', 'status' => 'succeeded'], 200),
    ]);

    $result = (new PaymentService())->charge(2999);
    $this->assertTrue($result->success);
}

public function test_it_handles_gateway_failure(): void
{
    Http::fake([
        'api.stripe.com/*' => Http::response(['error' => 'card_declined'], 402),
    ]);

    $this->expectException(PaymentFailedException::class);
    (new PaymentService())->charge(2999);
}

public function test_it_retries_on_timeout(): void
{
    Http::fake([
        'api.stripe.com/*' => Http::sequence()
            ->pushStatus(408)
            ->pushStatus(200),
    ]);

    $this->assertTrue((new PaymentService())->charge(2999)->success);
}

Mail Fake

Mail::fake();

$order->sendConfirmation();

Mail::assertSent(OrderConfirmation::class, function ($mail) use ($order) {
    return $mail->hasTo($order->user->email);
});

Notification Fake

Notification::fake();

$user->notify(new WelcomeUser());

Notification::assertSentTo($user, WelcomeUser::class);

Queue Fake

Queue::fake();

ProcessImage::dispatch($product);

Queue::assertPushed(ProcessImage::class, function ($job) use ($product) {
    return $job->product->id === $product->id;
});

Storage Fake

Storage::fake('public');

$file = UploadedFile::fake()->image('photo.jpg', 200, 200);

$response = $this->actingAs($user)->post('/avatar', [
    'avatar' => $file,
]);

$response->assertSessionHasNoErrors();
Storage::disk('public')->assertExists('avatars/' . $file->hashName());

Event Fake

Event::fake();

$order->markAsShipped();

Event::assertDispatched(OrderShipped::class, function ($event) use ($order) {
    return $event->order->id === $order->id;
});

Artisan Command Tests

public function test_it_sends_newsletters(): void
{
    Mail::fake();
    User::factory()->count(5)->create(['subscribed' => true]);

    $this->artisan('newsletter:send')
        ->expectsOutput('Sending newsletter to 5 subscribers...')
        ->assertExitCode(0);

    Mail::assertSent(NewsletterMail::class, 5);
}

public function test_it_handles_no_subscribers(): void
{
    $this->artisan('newsletter:send')
        ->expectsOutput('No subscribers found.')
        ->assertExitCode(0);
}

Authorization Tests

public function test_users_can_update_own_posts(): void
{
    $user = User::factory()->create();
    $post = Post::factory()->create(['user_id' => $user->id]);

    $this->actingAs($user)
        ->put(route('posts.update', $post), ['title' => 'Updated'])
        ->assertRedirect();
}

public function test_users_cannot_update_others_posts(): void
{
    $post = Post::factory()->create();
    $this->actingAs(User::factory()->create())
        ->put(route('posts.update', $post), ['title' => 'Hacked'])
        ->assertForbidden();
}

public function test_gate_before_grants_super_admin_full_access(): void
{
    $super = User::factory()->create(['role' => 'super-admin']);
    $post = Post::factory()->create();

    $this->actingAs($super)
        ->delete(route('posts.destroy', $post))
        ->assertRedirect();

    $this->assertSoftDeleted($post);
}

Pest Feature Tests

<?php

use App\Models\Product;
use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->user = User::factory()->create();
    $this->actingAs($this->user);
});

it('lists products', function () {
    Product::factory()->count(3)->create(['user_id' => $this->user->id]);

    $this->get(route('products.index'))
        ->assertOk()
        ->assertViewHas('products');
});

it('creates a product with valid data', function () {
    $this->post(route('products.store'), [
        'name' => 'Test Product', 'price' => 1999,
    ])->assertRedirect();

    $this->assertDatabaseHas('products', ['name' => 'Test Product']);
});

it('fails validation without required fields', function () {
    $this->post(route('products.store'), [])
        ->assertSessionHasErrors(['name', 'price']);
});

it('authorizes updates', function () {
    $other = User::factory()->create();
    $product = Product::factory()->create(['user_id' => $other->id]);

    $this->put(route('products.update', $product), ['name' => 'Hacked'])
        ->assertForbidden();
});

Coverage

# PHPUnit (use clover output for CI threshold checks)
vendor/bin/phpunit --coverage-html coverage --coverage-clover clover.xml

# Pest (built-in threshold support)
vendor/bin/pest --coverage --min=80

Coverage Goals

ComponentTarget
Models95%+
Actions/Services90%+
Form Requests90%+
Controllers85%+
Policies95%+
Overall80%+

Testing Best Practices

DO

  • Use factories over manual create() calls
  • One logical assertion per test
  • Descriptive names: test_guests_cannot_create_products
  • Test edge cases and authorization boundaries
  • Mock external services with Http::fake(), Mail::fake()
  • Use RefreshDatabase for clean state

DON'T

  • Don't test Laravel internals (trust the framework)
  • Don't make tests dependent on each other
  • Don't over-mock — mock only service boundaries
  • Don't test private methods — test through the public interface
  • Don't couple tests to HTML structure

Quick Reference

PatternUsage
RefreshDatabaseReset database between tests
$this->actingAs($user)Authenticate as user
$this->withToken($token)Bearer token auth for APIs
Model::factory()->create()Create model with factory
Model::factory()->count(5)->create()Create multiple records
Http::fake([...])Mock HTTP calls
Mail::fake()Trap sent mail
Notification::fake()Trap sent notifications
Queue::fake()Trap queued jobs
Event::fake()Trap dispatched events
Storage::fake('public')Trap file operations
assertDatabaseHasAssert DB row exists
assertSoftDeletedAssert soft-delete
assertSessionHasErrorsAssert validation errors
assertForbiddenAssert 403 status

Related Skills

  • laravel-patterns — Laravel architecture, Eloquent, routing, and API patterns
  • laravel-security — Laravel authentication, authorization, and secure coding
  • tdd-workflow — The repo-wide RED -> GREEN -> REFACTOR loop
  • backend-patterns — General backend API and database patterns

评价

还没有评价。欢迎分享你的使用感受。

评论

无需登录即可发表。欢迎分享使用感受、提问,或说明在哪些环境下无法运行。

还没有评论。来写第一条吧。

提交后会立即公开。请勿填写邮箱、电话等个人信息。URL 最多 2 个。

0 / 1000 字

此表单受 reCAPTCHA 保护,适用 Google 的隐私权政策和服务条款。

同一仓库的技能

accessibility

免费有简介AI 评分 100分

依据WCAG 2.2设计和检查Web、iOS及Android界面,核查读屏标签、键盘操作、配色对比度和操作目标尺寸,使界面支持辅助技术。

  • 为纯图标按钮添加说明
  • 检查键盘操作与模态框焦点
  • 检查对比度与操作目标尺寸
affaan-m/ECC28.6万2026年10月21日 更新

agent-architecture-audit

免费有简介AI 评分 80分

从指令、记忆、工具执行和界面显示等十二个层面排查人工智能代理故障,依据代码和日志梳理原因,按严重程度列出问题并提出修复方案。

  • 排查仅在应用内出现的故障
  • 调查旧对话混入回答的原因
  • 核对工具漏执行或虚报执行
affaan-m/ECC28.6万2026年10月21日 更新

agent-eval

免费有简介AI 评分 80分

用实际开发任务比较多个编程智能体,记录成功率、可获取的API费用、耗时和重复运行的稳定性,为工具选型及更新后的性能评估提供数据。

  • 用实际开发任务比较智能体
  • 采用新工具或模型前进行评估
  • 检查智能体更新后的性能
affaan-m/ECC28.6万2026年10月21日 更新

agent-harness-construction

免费有简介AI 评分 100分

设计和调整AI智能体使用的工具、输入与返回格式,明确出错后的恢复、重试和停止条件,管理上下文信息量,并以完成率、重试次数和成功任务成本评估改进。

  • 设计智能体工具及输入格式
  • 明确工具结果与下一步行动
  • 制定安全重试与停止条件
affaan-m/ECC28.6万2026年10月21日 更新

agent-introspection-debugging

免费有简介AI 评分 100分

根据错误信息和执行环境,分析AI代理反复失败或重复操作的原因。通过小范围、可撤销的恢复操作检验假设,并将结果与依据整理成可供交接的报告。

  • 诊断代理连续失败
  • 调查重复调用工具的循环
  • 检查上下文积累导致的输出退化
affaan-m/ECC28.6万2026年10月5日 更新

agent-introspection-debugging

免费有简介AI 评分 100分

在AI代理反复失败或重复操作时,记录错误与环境前提、排查原因,采取影响有限的恢复操作,并汇总结果和证据,为有依据的重试与交接提供支持。

  • 排查重复调用工具的原因
  • 调查对话膨胀导致的质量下降
  • 核对文件路径与环境差异
affaan-m/ECC28.6万2026年10月21日 更新

查看 affaan-m 的全部技能

报告此技能的问题