npx claudepluginhub bramato/laravel-react-plugins --plugin laravel-reactWant just this skill?
Then install: npx claudepluginhub u/[userId]/[slug]
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.
This skill uses the workspace's default tool permissions.
references/jest-rtl-patterns.mdreferences/phpunit-pest-patterns.mdTesting Strategies — Laravel + Inertia + React
1. Testing Pyramid for Laravel + Inertia + React
/ 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
| Layer | Tool | What to test |
|---|---|---|
| Unit (PHP) | Pest | Services, DTOs, value objects, model methods, scopes |
| Unit (TS) | Vitest/Jest | Hooks, utilities, formatters, pure functions |
| Feature (PHP) | Pest | HTTP request/response cycle, Inertia page rendering, validation, auth |
| Component (TS) | RTL + Vitest | React component rendering, user interactions, form submissions |
| Integration | Pest | Multi-service flows, queue/event/notification integration |
| E2E | Playwright | Critical 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
// 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
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
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
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
// 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
// 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
// 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
// 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
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)
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
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
// 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
// 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
$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
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
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
// 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
// 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',
},
},
});
// tests/js/setup.ts
import '@testing-library/jest-dom/vitest';
Testing a Component
// 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
// 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
// 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
// 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.
// 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 });
}),
];
// 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());
// 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
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
// 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
# 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
Similar Skills
Activates when the user asks about AI prompts, needs prompt templates, wants to search for prompts, or mentions prompts.chat. Use for discovering, retrieving, and improving prompts.