AgentSkillsCN

testing-strategies

为 Laravel(Pest/PHPUnit)和 React(Jest/Vitest + React Testing Library)编写测试用例。适用于编写单元测试、功能测试、组件测试,或集成测试时使用。涵盖测试结构、断言、模拟、工厂、数据库测试,以及针对服务提供者模式的测试组织方式。可通过测试、测试、Pest、PHPUnit、Jest、React Testing Library、TDD,或测试覆盖率等短语触发。

SKILL.md
--- frontmatter
name: testing-strategies
description: >
  Write tests for Laravel (Pest/PHPUnit) and React (Jest/Vitest + React Testing Library).
  Use when writing unit tests, feature tests, component tests, or integration tests.
  Covers test structure, assertions, mocking, factories, database testing, and test
  organization for the Service Provider pattern. Triggers on test, testing, Pest, PHPUnit,
  Jest, React Testing Library, TDD, or test coverage.

Testing Strategies — Laravel + Inertia + React

1. Testing Pyramid for Laravel + Inertia + React

code
        /  E2E  \           ← Few: Cypress/Playwright (full page flows)
       /----------\
      / Integration \       ← Some: Feature tests (HTTP -> controller -> DB)
     /----------------\
    /    Component      \   ← Many: RTL component tests (React)
   /--------------------\
  /        Unit          \  ← Many: Service, DTO, Model, Hook tests
 /________________________\

What to Test at Each Layer

LayerToolWhat to test
Unit (PHP)PestServices, DTOs, value objects, model methods, scopes
Unit (TS)Vitest/JestHooks, utilities, formatters, pure functions
Feature (PHP)PestHTTP request/response cycle, Inertia page rendering, validation, auth
Component (TS)RTL + VitestReact component rendering, user interactions, form submissions
IntegrationPestMulti-service flows, queue/event/notification integration
E2EPlaywrightCritical user journeys (login, checkout, onboarding)

2. Laravel Feature Tests

Feature tests exercise the full HTTP stack: routing, middleware, controllers, validation, database, and Inertia page rendering.

Testing Inertia Responses

php
// tests/Feature/Project/IndexTest.php
use App\Models\Project;
use App\Models\User;

it('renders the projects index page with data', function () {
    $user = User::factory()->create();
    $projects = Project::factory()->count(3)->for($user)->create();

    $this->actingAs($user)
        ->get(route('projects.index'))
        ->assertOk()
        ->assertInertia(fn ($page) => $page
            ->component('Projects/Index')
            ->has('projects.data', 3)
            ->has('projects.data.0', fn ($project) => $project
                ->has('id')
                ->has('title')
                ->has('status')
                ->etc()
            )
        );
});

Testing Store / Update / Delete

php
it('creates a project and redirects', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->post(route('projects.store'), [
            'title' => 'New Project',
            'description' => 'A test project',
        ])
        ->assertRedirect(route('projects.index'))
        ->assertSessionHas('success', 'Project created.');

    $this->assertDatabaseHas('projects', [
        'title' => 'New Project',
        'user_id' => $user->id,
    ]);
});

it('validates required fields on store', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->post(route('projects.store'), [])
        ->assertSessionHasErrors(['title', 'description']);
});

it('updates a project', function () {
    $user = User::factory()->create();
    $project = Project::factory()->for($user)->create();

    $this->actingAs($user)
        ->put(route('projects.update', $project), [
            'title' => 'Updated Title',
            'description' => $project->description,
        ])
        ->assertRedirect();

    expect($project->fresh()->title)->toBe('Updated Title');
});

it('soft deletes a project', function () {
    $user = User::factory()->create();
    $project = Project::factory()->for($user)->create();

    $this->actingAs($user)
        ->delete(route('projects.destroy', $project))
        ->assertRedirect(route('projects.index'));

    $this->assertSoftDeleted($project);
});

Testing Flash Messages

php
it('flashes a success message on create', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)
        ->post(route('projects.store'), [
            'title' => 'Test',
            'description' => 'Test desc',
        ]);

    $response->assertSessionHas('success');
});

Testing Authorization

php
it('denies access to unauthorized users', function () {
    $owner = User::factory()->create();
    $other = User::factory()->create();
    $project = Project::factory()->for($owner)->create();

    $this->actingAs($other)
        ->put(route('projects.update', $project), ['title' => 'Hacked'])
        ->assertForbidden();
});

3. Laravel Unit Tests

Unit tests do not boot the full framework. They test single classes in isolation.

Testing a Service

php
// tests/Unit/Services/ProjectServiceTest.php
use App\DTOs\CreateProjectDTO;
use App\Models\Project;
use App\Models\User;
use App\Services\ProjectService;

beforeEach(function () {
    $this->service = app(ProjectService::class);
    $this->user = User::factory()->create();
});

it('creates a project from DTO', function () {
    $dto = new CreateProjectDTO(
        title: 'Test Project',
        description: 'Description',
        userId: $this->user->id,
    );

    $project = $this->service->create($dto);

    expect($project)
        ->toBeInstanceOf(Project::class)
        ->title->toBe('Test Project')
        ->user_id->toBe($this->user->id);
});

it('throws when creating with invalid data', function () {
    $dto = new CreateProjectDTO(
        title: '',
        description: '',
        userId: $this->user->id,
    );

    $this->service->create($dto);
})->throws(\InvalidArgumentException::class);

Testing a DTO

php
// tests/Unit/DTOs/CreateProjectDTOTest.php
use App\DTOs\CreateProjectDTO;

it('creates a DTO from request data', function () {
    $dto = CreateProjectDTO::fromArray([
        'title' => 'My Project',
        'description' => 'Some description',
        'user_id' => 1,
    ]);

    expect($dto)
        ->title->toBe('My Project')
        ->description->toBe('Some description')
        ->userId->toBe(1);
});

Testing Model Scopes and Accessors

php
// tests/Unit/Models/ProjectTest.php
use App\Models\Project;

it('scopes to active projects', function () {
    Project::factory()->active()->count(2)->create();
    Project::factory()->archived()->count(3)->create();

    expect(Project::active()->count())->toBe(2);
});

it('returns the formatted deadline', function () {
    $project = Project::factory()->make(['deadline' => '2025-06-15']);

    expect($project->formatted_deadline)->toBe('Jun 15, 2025');
});

4. Pest Syntax Reference

Basic Structure

php
// test() style
test('it can create a project', function () {
    // arrange, act, assert
});

// it() style (BDD)
it('creates a project', function () {
    // ...
});

// describe() blocks for grouping
describe('ProjectService', function () {
    describe('create', function () {
        it('creates a project from valid data', function () { /* ... */ });
        it('throws on invalid data', function () { /* ... */ });
    });

    describe('delete', function () {
        it('soft-deletes the project', function () { /* ... */ });
    });
});

Expect Chains

php
expect($value)->toBe('exact');
expect($value)->toEqual(['key' => 'value']); // loose comparison
expect($value)->toBeTrue();
expect($value)->toBeFalse();
expect($value)->toBeNull();
expect($value)->toBeEmpty();
expect($value)->toBeInstanceOf(Project::class);
expect($value)->toContain('substring');
expect($collection)->toHaveCount(3);
expect($value)->toBeGreaterThan(10);
expect($value)->toBeBetween(1, 100);
expect($value)->toMatchArray(['key' => 'value']);
expect($value)->toHaveKey('nested.key');
expect($value)->each->toBeString();

// Higher-order expectations
expect($user)
    ->name->toBe('John')
    ->email->toContain('@')
    ->role->not->toBeNull();

Datasets (Data Providers)

php
it('validates disallowed statuses', function (string $status) {
    $this->actingAs(User::factory()->create())
        ->post(route('projects.store'), [
            'title' => 'Test',
            'status' => $status,
        ])
        ->assertSessionHasErrors('status');
})->with(['invalid', 'unknown', '']);

// Named datasets
dataset('invalid_emails', [
    'missing @' => ['notanemail'],
    'missing domain' => ['user@'],
    'empty string' => [''],
]);

it('rejects invalid emails', function (string $email) {
    // ...
})->with('invalid_emails');

Lifecycle Hooks

php
beforeEach(function () {
    $this->user = User::factory()->create();
    $this->service = app(ProjectService::class);
});

afterEach(function () {
    Cache::flush();
});

beforeAll(function () {
    // Runs once before all tests in the file
});

5. Database Testing

RefreshDatabase Trait

php
// Pest: globally in Pest.php
uses(Illuminate\Foundation\Testing\RefreshDatabase::class)->in('Feature');
uses(Illuminate\Foundation\Testing\LazilyRefreshDatabase::class)->in('Feature');
// LazilyRefreshDatabase wraps each test in a transaction (faster than migrating each time)

Factory Patterns

php
// database/factories/ProjectFactory.php
namespace Database\Factories;

use App\Enums\ProjectStatus;
use App\Models\Project;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class ProjectFactory extends Factory
{
    protected $model = Project::class;

    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'title' => fake()->sentence(3),
            'description' => fake()->paragraph(),
            'status' => ProjectStatus::Active,
            'deadline' => fake()->dateTimeBetween('now', '+1 year'),
        ];
    }

    public function active(): static
    {
        return $this->state(fn () => ['status' => ProjectStatus::Active]);
    }

    public function archived(): static
    {
        return $this->state(fn () => ['status' => ProjectStatus::Archived]);
    }

    public function withTasks(int $count = 3): static
    {
        return $this->has(Task::factory()->count($count));
    }
}

// Usage in tests
$project = Project::factory()
    ->for($user)
    ->active()
    ->withTasks(5)
    ->create();

Database Assertions

php
$this->assertDatabaseHas('projects', [
    'title' => 'New Project',
    'user_id' => $user->id,
]);

$this->assertDatabaseMissing('projects', [
    'title' => 'Deleted Project',
]);

$this->assertDatabaseCount('projects', 5);

$this->assertSoftDeleted('projects', [
    'id' => $project->id,
]);

$this->assertModelExists($project);
$this->assertModelMissing($deletedProject);

6. Mocking

Faking Laravel Services

php
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;

it('sends a welcome email on registration', function () {
    Mail::fake();

    $this->post(route('register'), [
        'name' => 'John',
        'email' => 'john@example.com',
        'password' => 'password',
        'password_confirmation' => 'password',
    ]);

    Mail::assertSent(WelcomeMail::class, function ($mail) {
        return $mail->hasTo('john@example.com');
    });
});

it('dispatches ProjectCreated event', function () {
    Event::fake([ProjectCreated::class]);

    $user = User::factory()->create();
    $this->actingAs($user)->post(route('projects.store'), [
        'title' => 'Test',
        'description' => 'Test desc',
    ]);

    Event::assertDispatched(ProjectCreated::class, function ($event) {
        return $event->project->title === 'Test';
    });
});

it('queues an export job', function () {
    Queue::fake();

    $user = User::factory()->create();
    $this->actingAs($user)->post(route('projects.export'));

    Queue::assertPushed(ExportProjectsJob::class);
});

it('stores an uploaded file', function () {
    Storage::fake('s3');

    $file = UploadedFile::fake()->image('avatar.jpg');
    $this->actingAs(User::factory()->create())
        ->post(route('profile.avatar'), ['avatar' => $file]);

    Storage::disk('s3')->assertExists('avatars/' . $file->hashName());
});

Mocking Services in the Container

php
use App\Services\PaymentGateway;
use Mockery;

it('processes payment through gateway', function () {
    $mock = Mockery::mock(PaymentGateway::class);
    $mock->shouldReceive('charge')
        ->once()
        ->with(1000, 'usd')
        ->andReturn(true);

    $this->app->instance(PaymentGateway::class, $mock);

    $this->actingAs(User::factory()->create())
        ->post(route('payments.store'), ['amount' => 1000]);
});

// Using Pest's mock() helper
it('calls external API', function () {
    $this->mock(ExternalApiService::class)
        ->shouldReceive('fetch')
        ->once()
        ->andReturn(['data' => 'value']);

    // test code that uses ExternalApiService
});

Partial Mocks and Spies

php
// Spy: verify after the fact (no expectations upfront)
it('logs the activity', function () {
    $spy = $this->spy(ActivityLogger::class);

    $this->actingAs(User::factory()->create())
        ->post(route('projects.store'), ['title' => 'Test', 'description' => 'Desc']);

    $spy->shouldHaveReceived('log')
        ->with('project.created', Mockery::type(Project::class));
});

// Partial mock: only mock specific methods
it('uses real methods except external call', function () {
    $this->partialMock(ProjectService::class, function ($mock) {
        $mock->shouldReceive('notifySlack')->andReturn(true);
    });

    // Other methods on ProjectService remain real
});

7. React Component Tests

Setup with Vitest and RTL

ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [react()],
    test: {
        environment: 'jsdom',
        setupFiles: ['./tests/js/setup.ts'],
        globals: true,
    },
    resolve: {
        alias: {
            '@': '/resources/js',
        },
    },
});
ts
// tests/js/setup.ts
import '@testing-library/jest-dom/vitest';

Testing a Component

tsx
// tests/js/Components/ProjectCard.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProjectCard } from '@/Components/ProjectCard';

const project = {
    id: 1,
    title: 'Test Project',
    status: 'active',
    tasksCount: 5,
};

describe('ProjectCard', () => {
    it('renders project information', () => {
        render(<ProjectCard project={project} />);

        expect(screen.getByText('Test Project')).toBeInTheDocument();
        expect(screen.getByText('active')).toBeInTheDocument();
        expect(screen.getByText('5 tasks')).toBeInTheDocument();
    });

    it('calls onDelete when delete button is clicked', async () => {
        const user = userEvent.setup();
        const onDelete = vi.fn();

        render(<ProjectCard project={project} onDelete={onDelete} />);

        await user.click(screen.getByRole('button', { name: /delete/i }));

        expect(onDelete).toHaveBeenCalledWith(1);
    });

    it('shows edit link for authorized users', () => {
        render(<ProjectCard project={project} canEdit={true} />);

        expect(screen.getByRole('link', { name: /edit/i })).toBeInTheDocument();
    });

    it('hides edit link for unauthorized users', () => {
        render(<ProjectCard project={project} canEdit={false} />);

        expect(screen.queryByRole('link', { name: /edit/i })).not.toBeInTheDocument();
    });
});

Testing Form Components

tsx
// tests/js/Pages/Projects/Create.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Create from '@/Pages/Projects/Create';

// Mock Inertia's useForm
const mockPost = vi.fn();
vi.mock('@inertiajs/react', async () => {
    const actual = await vi.importActual('@inertiajs/react');
    return {
        ...actual,
        useForm: () => ({
            data: { title: '', description: '' },
            setData: vi.fn(),
            post: mockPost,
            processing: false,
            errors: {},
            reset: vi.fn(),
        }),
        Head: ({ title }: { title: string }) => <title>{title}</title>,
    };
});

// Mock route helper
vi.stubGlobal('route', (name: string) => `/mocked/${name}`);

describe('Create Project Page', () => {
    it('renders the create form', () => {
        render(<Create />);

        expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
        expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
        expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument();
    });

    it('submits the form', async () => {
        const user = userEvent.setup();
        render(<Create />);

        await user.type(screen.getByLabelText(/title/i), 'New Project');
        await user.type(screen.getByLabelText(/description/i), 'A description');
        await user.click(screen.getByRole('button', { name: /create/i }));

        expect(mockPost).toHaveBeenCalled();
    });
});

8. React Hook Tests

tsx
// tests/js/hooks/usePermission.test.ts
import { renderHook } from '@testing-library/react';
import { usePermission } from '@/hooks/usePermission';

vi.mock('@inertiajs/react', () => ({
    usePage: () => ({
        props: {
            auth: {
                user: {
                    id: 1,
                    name: 'Test User',
                    role: 'admin',
                    permissions: ['projects.create', 'projects.edit'],
                },
            },
        },
    }),
}));

describe('usePermission', () => {
    it('returns true for granted permissions', () => {
        const { result } = renderHook(() => usePermission());

        expect(result.current.can('projects.create')).toBe(true);
    });

    it('returns false for denied permissions', () => {
        const { result } = renderHook(() => usePermission());

        expect(result.current.can('users.delete')).toBe(false);
    });

    it('checks role correctly', () => {
        const { result } = renderHook(() => usePermission());

        expect(result.current.hasRole('admin')).toBe(true);
        expect(result.current.hasRole('viewer')).toBe(false);
    });
});

Testing Async Hooks

tsx
// tests/js/hooks/useProjects.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useProjects } from '@/hooks/useProjects';

describe('useProjects', () => {
    it('starts in loading state', () => {
        const { result } = renderHook(() => useProjects());

        expect(result.current.isLoading).toBe(true);
        expect(result.current.projects).toEqual([]);
    });

    it('returns projects after loading', async () => {
        const { result } = renderHook(() => useProjects());

        await waitFor(() => {
            expect(result.current.isLoading).toBe(false);
        });

        expect(result.current.projects).toHaveLength(3);
    });
});

9. API Integration Tests with MSW

Mock Service Worker intercepts network requests at the service worker level for realistic API testing in React components.

ts
// tests/js/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
    http.get('/api/projects', () => {
        return HttpResponse.json({
            data: [
                { id: 1, title: 'Project A', status: 'active' },
                { id: 2, title: 'Project B', status: 'archived' },
            ],
        });
    }),

    http.post('/api/projects', async ({ request }) => {
        const body = await request.json();
        return HttpResponse.json(
            { data: { id: 3, ...body } },
            { status: 201 }
        );
    }),

    http.delete('/api/projects/:id', ({ params }) => {
        return new HttpResponse(null, { status: 204 });
    }),
];
ts
// tests/js/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// tests/js/setup.ts
import { server } from './mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
tsx
// tests/js/Components/ProjectList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import { ProjectList } from '@/Components/ProjectList';

it('shows projects loaded from API', async () => {
    render(<ProjectList />);

    await waitFor(() => {
        expect(screen.getByText('Project A')).toBeInTheDocument();
        expect(screen.getByText('Project B')).toBeInTheDocument();
    });
});

it('shows error message on API failure', async () => {
    server.use(
        http.get('/api/projects', () => {
            return HttpResponse.json(
                { message: 'Server Error' },
                { status: 500 }
            );
        })
    );

    render(<ProjectList />);

    await waitFor(() => {
        expect(screen.getByText(/error loading projects/i)).toBeInTheDocument();
    });
});

10. Test Organization

Directory Structure

code
tests/
├── Feature/
│   ├── Auth/
│   │   ├── LoginTest.php
│   │   └── RegistrationTest.php
│   ├── Project/
│   │   ├── CreateProjectTest.php
│   │   ├── DeleteProjectTest.php
│   │   ├── IndexProjectTest.php
│   │   └── UpdateProjectTest.php
│   └── Api/
│       └── ProjectApiTest.php
├── Unit/
│   ├── DTOs/
│   │   └── CreateProjectDTOTest.php
│   ├── Models/
│   │   └── ProjectTest.php
│   ├── Policies/
│   │   └── ProjectPolicyTest.php
│   └── Services/
│       └── ProjectServiceTest.php
├── Pest.php              ← Global config, uses() calls
└── TestCase.php

tests/js/
├── Components/
│   ├── ProjectCard.test.tsx
│   └── ProjectList.test.tsx
├── Pages/
│   └── Projects/
│       ├── Create.test.tsx
│       └── Index.test.tsx
├── hooks/
│   └── usePermission.test.ts
├── mocks/
│   ├── handlers.ts
│   └── server.ts
└── setup.ts

Pest.php Configuration

php
// tests/Pest.php
uses(Tests\TestCase::class)->in('Feature', 'Unit');
uses(Illuminate\Foundation\Testing\RefreshDatabase::class)->in('Feature');

// Global helper
function actingAsAdmin(): Tests\TestCase
{
    $admin = \App\Models\User::factory()->create([
        'role' => \App\Enums\Role::Admin,
    ]);

    return test()->actingAs($admin);
}

Naming Conventions

  • PHP test files: {Action}{Model}Test.php (e.g., CreateProjectTest.php)
  • React test files: {ComponentName}.test.tsx
  • Test descriptions: start with it + present-tense verb (e.g., it('creates a project'))
  • Group related tests with describe() blocks
  • One assertion concept per test (a test can have multiple expect() calls if they assert the same concept)

Running Tests

bash
# PHP tests
php artisan test                      # all tests
php artisan test --filter=ProjectTest # filter by name
php artisan test --parallel           # parallel execution
php artisan test --coverage           # with coverage (requires Xdebug/PCOV)
./vendor/bin/pest --dirty             # only test files changed since last commit

# JavaScript tests
npx vitest                           # watch mode
npx vitest run                       # single run
npx vitest run --coverage            # with coverage
npx vitest run tests/js/Components   # specific directory