From laravel-react
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.
npx claudepluginhub bramato/laravel-react-plugins --plugin laravel-reactThis skill uses the workspace's default tool permissions.
```
Guides TDD in Laravel with PHPUnit/Pest: unit/feature/integration tests, factories, RefreshDatabase, fakes, and 80%+ coverage for features, bugs, refactors, Eloquent models.
Provides testing pyramid, unit patterns (AAA, isolation, parameterized, edge cases), and React Testing Library for component tests. Use when writing tests or setting up testing infrastructure.
Provides expert testing strategies for React, Next.js, NestJS apps: unit tests with Jest/Vitest/RTL, integration with Supertest, E2E with Playwright/Cypress, best practices, and test setup.
Share bugs, ideas, or general feedback.
/ 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
/________________________\
| 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) |
Feature tests exercise the full HTTP stack: routing, middleware, controllers, validation, database, and Inertia page rendering.
// 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()
)
);
});
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);
});
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');
});
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();
});
Unit tests do not boot the full framework. They test single classes in isolation.
// 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);
// 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);
});
// 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');
});
// 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($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();
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');
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
});
// 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)
// 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();
$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);
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());
});
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
});
// 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
});
// 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';
// 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();
});
});
// 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();
});
});
// 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);
});
});
// 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);
});
});
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();
});
});
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
// 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);
}
{Action}{Model}Test.php (e.g., CreateProjectTest.php){ComponentName}.test.tsxit + present-tense verb (e.g., it('creates a project'))describe() blocksexpect() calls if they assert the same concept)# 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