| 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
/ 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
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
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
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
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('it can create a project', function () {
});
it('creates a project', function () {
});
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']);
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();
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', '']);
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 () {
});
5. Database Testing
RefreshDatabase Trait
uses(Illuminate\Foundation\Testing\RefreshDatabase::class)->in('Feature');
uses(Illuminate\Foundation\Testing\LazilyRefreshDatabase::class)->in('Feature');
Factory Patterns
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));
}
}
$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]);
});
it('calls external API', function () {
$this->mock(ExternalApiService::class)
->shouldReceive('fetch')
->once()
->andReturn(['data' => 'value']);
});
Partial Mocks and Spies
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));
});
it('uses real methods except external call', function () {
$this->partialMock(ProjectService::class, function ($mock) {
$mock->shouldReceive('notifySlack')->andReturn(true);
});
});
7. React Component Tests
Setup with Vitest and RTL
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',
},
},
});
import '@testing-library/jest-dom/vitest';
Testing a Component
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
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Create from '@/Pages/Projects/Create';
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>,
};
});
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
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
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.
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 });
}),
];
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
import { server } from './mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
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
uses(Tests\TestCase::class)->in('Feature', 'Unit');
uses(Illuminate\Foundation\Testing\RefreshDatabase::class)->in('Feature');
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 artisan test
php artisan test --filter=ProjectTest
php artisan test --parallel
php artisan test --coverage
./vendor/bin/pest --dirty
npx vitest
npx vitest run
npx vitest run --coverage
npx vitest run tests/js/Components