Feature Testing
Overview
Feature tests verify that complete features and workflows work correctly. Unlike unit tests that focus on isolated components, feature tests examine how multiple parts of your application work together, including controllers, models, database, and middleware.
Why Feature Testing?
Feature tests provide several benefits:
- Integration verification - Tests how components work together
- User flow validation - Ensures complete user journeys work
- Database interaction - Tests real database operations
- HTTP testing - Validates requests, responses, and routing
- Middleware testing - Verifies authentication, authorization, and other middleware
- Confidence in deployment - Ensures critical features work end-to-end
Feature vs Unit Tests
Unit Tests
- Test single methods/functions in isolation
- Mock all dependencies
- Fast execution (milliseconds)
- No database interaction
- Located in
tests/Unit/
Feature Tests
- Test complete features and workflows
- Use real database (with RefreshDatabase)
- Slower execution (seconds)
- Test HTTP requests/responses
- Located in
tests/Feature/
Basic Feature Test
php
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('user can view their dashboard', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->get('/dashboard');
$response->assertStatus(200)
->assertViewIs('dashboard')
->assertSee('Welcome');
});Testing HTTP Requests
GET Requests
php
<?php
use App\Models\Post;
test('guest can view public posts', function () {
$post = Post::factory()->create(['status' => 'published']);
$response = $this->get("/posts/{$post->slug}");
$response->assertStatus(200)
->assertSee($post->title)
->assertSee($post->content);
});
test('returns 404 for non-existent post', function () {
$response = $this->get('/posts/non-existent-slug');
$response->assertStatus(404);
});
test('does not show draft posts to guests', function () {
$post = Post::factory()->create(['status' => 'draft']);
$response = $this->get("/posts/{$post->slug}");
$response->assertStatus(404);
});POST Requests
php
<?php
use App\Models\User;
test('user can create a post', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/posts', [
'title' => 'New Post',
'content' => 'This is the content',
'status' => 'published',
]);
$response->assertRedirect('/posts');
$response->assertSessionHas('success', 'Post created successfully');
$this->assertDatabaseHas('posts', [
'title' => 'New Post',
'user_id' => $user->id,
]);
});
test('validates required fields when creating post', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/posts', []);
$response->assertSessionHasErrors(['title', 'content']);
});PUT/PATCH Requests
php
<?php
use App\Models\User;
use App\Models\Post;
test('user can update their own post', function () {
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)
->put("/posts/{$post->id}", [
'title' => 'Updated Title',
'content' => 'Updated content',
]);
$response->assertRedirect("/posts/{$post->id}");
expect($post->fresh()->title)->toBe('Updated Title');
});
test('user cannot update another users post', function () {
$user = User::factory()->create();
$otherPost = Post::factory()->create();
$response = $this->actingAs($user)
->put("/posts/{$otherPost->id}", [
'title' => 'Hacked',
]);
$response->assertStatus(403);
});DELETE Requests
php
<?php
use App\Models\User;
use App\Models\Post;
test('user can delete their own post', function () {
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)
->delete("/posts/{$post->id}");
$response->assertRedirect('/posts');
$response->assertSessionHas('success', 'Post deleted successfully');
$this->assertSoftDeleted('posts', ['id' => $post->id]);
});
test('user cannot delete another users post', function () {
$user = User::factory()->create();
$otherPost = Post::factory()->create();
$response = $this->actingAs($user)
->delete("/posts/{$otherPost->id}");
$response->assertStatus(403);
expect(Post::find($otherPost->id))->not->toBeNull();
});Testing Authentication
Login Flow
php
<?php
use App\Models\User;
test('user can login with valid credentials', function () {
$user = User::factory()->create([
'email' => 'john@example.com',
'password' => bcrypt('password123'),
]);
$response = $this->post('/login', [
'email' => 'john@example.com',
'password' => 'password123',
]);
$response->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
});
test('user cannot login with invalid credentials', function () {
User::factory()->create([
'email' => 'john@example.com',
'password' => bcrypt('password123'),
]);
$response = $this->post('/login', [
'email' => 'john@example.com',
'password' => 'wrongpassword',
]);
$response->assertSessionHasErrors(['email']);
$this->assertGuest();
});
test('user is redirected to login when accessing protected route', function () {
$response = $this->get('/dashboard');
$response->assertRedirect('/login');
});Registration Flow
php
<?php
test('user can register with valid data', function () {
$response = $this->post('/register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertRedirect('/dashboard');
$this->assertDatabaseHas('users', [
'email' => 'john@example.com',
]);
$user = User::where('email', 'john@example.com')->first();
$this->assertAuthenticatedAs($user);
});
test('validates email is unique', function () {
User::factory()->create(['email' => 'existing@example.com']);
$response = $this->post('/register', [
'name' => 'John Doe',
'email' => 'existing@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertSessionHasErrors(['email']);
});
test('validates password confirmation', function () {
$response = $this->post('/register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'different',
]);
$response->assertSessionHasErrors(['password']);
});Logout Flow
php
<?php
use App\Models\User;
test('authenticated user can logout', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/logout');
$response->assertRedirect('/');
$this->assertGuest();
});Testing Authorization
Policy Tests
php
<?php
use App\Models\User;
use App\Models\Post;
test('author can update their post', function () {
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
expect($user->can('update', $post))->toBeTrue();
});
test('user cannot update others post', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
expect($user->can('update', $post))->toBeFalse();
});
test('admin can update any post', function () {
$admin = User::factory()->create(['role' => 'admin']);
$post = Post::factory()->create();
expect($admin->can('update', $post))->toBeTrue();
});Role-Based Access
php
<?php
use App\Models\User;
test('admin can access admin dashboard', function () {
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)
->get('/admin/dashboard');
$response->assertStatus(200);
});
test('regular user cannot access admin dashboard', function () {
$user = User::factory()->create(['role' => 'user']);
$response = $this->actingAs($user)
->get('/admin/dashboard');
$response->assertStatus(403);
});
test('guest is redirected to login when accessing admin area', function () {
$response = $this->get('/admin/dashboard');
$response->assertRedirect('/login');
});Testing Database Interactions
Creating Records
php
<?php
use App\Models\User;
use App\Models\Post;
test('creates post with correct attributes', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/posts', [
'title' => 'Test Post',
'content' => 'Content here',
'status' => 'draft',
]);
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'content' => 'Content here',
'status' => 'draft',
'user_id' => $user->id,
]);
expect(Post::count())->toBe(1);
});
test('sets default values when creating post', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/posts', [
'title' => 'Test Post',
'content' => 'Content',
]);
$post = Post::first();
expect($post->status)->toBe('draft');
expect($post->published_at)->toBeNull();
});Updating Records
php
<?php
use App\Models\User;
use App\Models\Post;
test('updates only specified fields', function () {
$user = User::factory()->create();
$post = Post::factory()->create([
'user_id' => $user->id,
'title' => 'Original',
'content' => 'Original content',
]);
$this->actingAs($user)
->put("/posts/{$post->id}", [
'title' => 'Updated',
'content' => 'Original content',
]);
$post->refresh();
expect($post->title)->toBe('Updated');
expect($post->content)->toBe('Original content');
});
test('updates timestamp on modification', function () {
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$originalUpdatedAt = $post->updated_at;
sleep(1);
$this->actingAs($user)
->put("/posts/{$post->id}", [
'title' => 'Updated',
]);
expect($post->fresh()->updated_at)->toBeGreaterThan($originalUpdatedAt);
});Deleting Records
php
<?php
use App\Models\User;
use App\Models\Post;
test('soft deletes post', function () {
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$this->actingAs($user)
->delete("/posts/{$post->id}");
$this->assertSoftDeleted('posts', ['id' => $post->id]);
expect(Post::withTrashed()->find($post->id))->not->toBeNull();
});
test('deletes related comments when post is deleted', function () {
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$comment = $post->comments()->create(['body' => 'Test comment']);
$this->actingAs($user)
->delete("/posts/{$post->id}");
$this->assertDatabaseMissing('comments', ['id' => $comment->id]);
});Testing Validation
Form Validation
php
<?php
use App\Models\User;
test('validates required fields', function (array $data, array $expectedErrors) {
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/posts', $data);
$response->assertSessionHasErrors($expectedErrors);
})->with([
'no data' => [[], ['title', 'content']],
'missing title' => [['content' => 'test'], ['title']],
'missing content' => [['title' => 'test'], ['content']],
]);
test('validates title length', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/posts', [
'title' => 'ab', // Too short
'content' => 'Valid content',
]);
$response->assertSessionHasErrors(['title']);
});
test('validates email format', function () {
$response = $this->post('/register', [
'name' => 'John',
'email' => 'invalid-email',
'password' => 'password123',
]);
$response->assertSessionHasErrors(['email']);
});Custom Validation Rules
php
<?php
use App\Models\User;
test('validates custom business rules', function () {
$user = User::factory()->create();
Post::factory()->count(5)->create(['user_id' => $user->id]);
// User has reached max posts limit
$response = $this->actingAs($user)
->post('/posts', [
'title' => 'New Post',
'content' => 'Content',
]);
$response->assertSessionHasErrors(['limit']);
});
test('validates slug is unique', function () {
$user = User::factory()->create();
Post::factory()->create(['slug' => 'existing-post']);
$response = $this->actingAs($user)
->post('/posts', [
'title' => 'Existing Post', // Will generate same slug
'content' => 'Content',
]);
$response->assertSessionHasErrors(['slug']);
});Testing Views and Responses
View Testing
php
<?php
use App\Models\User;
use App\Models\Post;
test('displays post on page', function () {
$post = Post::factory()->create([
'title' => 'Test Title',
'content' => 'Test Content',
]);
$response = $this->get("/posts/{$post->slug}");
$response->assertStatus(200)
->assertViewIs('posts.show')
->assertViewHas('post', $post)
->assertSee('Test Title')
->assertSee('Test Content');
});
test('passes correct data to view', function () {
$user = User::factory()->create();
$posts = Post::factory()->count(3)->create(['user_id' => $user->id]);
$response = $this->actingAs($user)
->get('/dashboard');
$response->assertViewHas('posts', function ($viewPosts) use ($posts) {
return $viewPosts->count() === 3;
});
});
test('does not display sensitive data', function () {
$user = User::factory()->create(['password' => bcrypt('secret')]);
$response = $this->actingAs($user)
->get('/profile');
$response->assertDontSee('secret')
->assertDontSee($user->password);
});Response Assertions
php
<?php
use App\Models\User;
test('redirects after successful action', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/posts', [
'title' => 'New Post',
'content' => 'Content',
]);
$response->assertRedirect('/posts');
});
test('redirects back with errors on validation failure', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->from('/posts/create')
->post('/posts', []);
$response->assertRedirect('/posts/create');
$response->assertSessionHasErrors();
});
test('sets correct HTTP headers', function () {
$response = $this->get('/');
$response->assertHeader('Content-Type', 'text/html; charset=UTF-8');
});Testing Sessions and Cookies
Session Testing
php
<?php
use App\Models\User;
test('stores data in session', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/posts', [
'title' => 'New Post',
'content' => 'Content',
]);
$this->assertSessionHas('success', 'Post created successfully');
});
test('maintains session across requests', function () {
$response = $this->withSession(['key' => 'value'])
->get('/check-session');
$response->assertSessionHas('key', 'value');
});
test('flashes data to session', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/posts', [
'title' => 'New Post',
'content' => 'Content',
]);
$this->assertSessionHas('success');
// Flash data is removed after next request
$this->get('/posts');
$this->assertSessionMissing('success');
});Cookie Testing
php
<?php
test('sets cookie on response', function () {
$response = $this->post('/accept-terms');
$response->assertCookie('terms_accepted', 'true');
});
test('reads cookie from request', function () {
$response = $this->withCookie('preference', 'dark')
->get('/dashboard');
$response->assertSee('Dark Mode Active');
});
test('deletes expired cookies', function () {
$response = $this->withCookie('old_cookie', 'value')
->post('/clear-cookies');
$response->assertCookieExpired('old_cookie');
});Testing File Uploads
Basic File Upload
php
<?php
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
test('user can upload profile photo', function () {
Storage::fake('public');
$user = User::factory()->create();
$file = UploadedFile::fake()->image('avatar.jpg', 600, 600);
$response = $this->actingAs($user)
->post('/profile/photo', [
'photo' => $file,
]);
$response->assertRedirect('/profile');
$response->assertSessionHas('success');
Storage::disk('public')->assertExists('avatars/' . $file->hashName());
});
test('validates file type', function () {
$user = User::factory()->create();
$file = UploadedFile::fake()->create('document.pdf');
$response = $this->actingAs($user)
->post('/profile/photo', [
'photo' => $file,
]);
$response->assertSessionHasErrors(['photo']);
});
test('validates file size', function () {
$user = User::factory()->create();
$file = UploadedFile::fake()->image('large.jpg')->size(3000); // 3MB
$response = $this->actingAs($user)
->post('/profile/photo', [
'photo' => $file,
]);
$response->assertSessionHasErrors(['photo']);
});Multiple File Uploads
php
<?php
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
test('user can upload multiple files', function () {
Storage::fake('public');
$user = User::factory()->create();
$files = [
UploadedFile::fake()->image('photo1.jpg'),
UploadedFile::fake()->image('photo2.jpg'),
UploadedFile::fake()->image('photo3.jpg'),
];
$response = $this->actingAs($user)
->post('/posts/images', [
'images' => $files,
]);
$response->assertRedirect();
foreach ($files as $file) {
Storage::disk('public')->assertExists('images/' . $file->hashName());
}
});Testing Events and Listeners
Event Dispatching
php
<?php
use App\Events\PostPublished;
use App\Models\User;
use Illuminate\Support\Facades\Event;
test('dispatches event when post is published', function () {
Event::fake([PostPublished::class]);
$user = User::factory()->create();
$this->actingAs($user)
->post('/posts', [
'title' => 'New Post',
'content' => 'Content',
'status' => 'published',
]);
Event::assertDispatched(PostPublished::class, function ($event) {
return $event->post->title === 'New Post';
});
});
test('does not dispatch event for draft posts', function () {
Event::fake([PostPublished::class]);
$user = User::factory()->create();
$this->actingAs($user)
->post('/posts', [
'title' => 'Draft Post',
'content' => 'Content',
'status' => 'draft',
]);
Event::assertNotDispatched(PostPublished::class);
});Listener Testing
php
<?php
use App\Events\PostPublished;
use App\Listeners\SendPostNotification;
use App\Models\User;
use Illuminate\Support\Facades\Notification;
test('listener sends notification when event is dispatched', function () {
Notification::fake();
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$event = new PostPublished($post);
$listener = new SendPostNotification();
$listener->handle($event);
Notification::assertSentTo($user, PostPublishedNotification::class);
});Testing Jobs and Queues
Job Dispatching
php
<?php
use App\Jobs\ProcessPost;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
test('dispatches job when post is created', function () {
Queue::fake();
$user = User::factory()->create();
$this->actingAs($user)
->post('/posts', [
'title' => 'New Post',
'content' => 'Content',
]);
Queue::assertPushed(ProcessPost::class, function ($job) {
return $job->post->title === 'New Post';
});
});
test('job is pushed to correct queue', function () {
Queue::fake();
$user = User::factory()->create();
$this->actingAs($user)
->post('/posts', [
'title' => 'New Post',
'content' => 'Content',
]);
Queue::assertPushedOn('default', ProcessPost::class);
});Job Execution
php
<?php
use App\Jobs\ProcessPost;
use App\Models\Post;
test('job processes post correctly', function () {
$post = Post::factory()->create(['processed' => false]);
$job = new ProcessPost($post);
$job->handle();
expect($post->fresh()->processed)->toBeTrue();
});Testing Email
Email Sending
php
<?php
use App\Mail\WelcomeEmail;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
test('sends welcome email on registration', function () {
Mail::fake();
$this->post('/register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$user = User::where('email', 'john@example.com')->first();
Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
});
test('does not send email on failed registration', function () {
Mail::fake();
$this->post('/register', [
'name' => 'John',
'email' => 'invalid-email',
'password' => 'password123',
]);
Mail::assertNothingSent();
});Email Content
php
<?php
use App\Mail\WelcomeEmail;
use App\Models\User;
test('welcome email contains correct content', function () {
$user = User::factory()->create(['name' => 'John Doe']);
$mailable = new WelcomeEmail($user);
$mailable->assertSeeInHtml('Welcome, John Doe')
->assertSeeInHtml($user->email)
->assertHasSubject('Welcome to Our App');
});Testing Notifications
Notification Sending
php
<?php
use App\Models\User;
use App\Notifications\PostPublished;
use Illuminate\Support\Facades\Notification;
test('sends notification to followers when post is published', function () {
Notification::fake();
$author = User::factory()->create();
$follower = User::factory()->create();
$author->followers()->attach($follower);
$this->actingAs($author)
->post('/posts', [
'title' => 'New Post',
'content' => 'Content',
'status' => 'published',
]);
Notification::assertSentTo($follower, PostPublished::class);
});
test('does not send notification for draft posts', function () {
Notification::fake();
$author = User::factory()->create();
$follower = User::factory()->create();
$author->followers()->attach($follower);
$this->actingAs($author)
->post('/posts', [
'title' => 'Draft Post',
'content' => 'Content',
'status' => 'draft',
]);
Notification::assertNothingSent();
});Testing Middleware
Authentication Middleware
php
<?php
test('middleware redirects unauthenticated users', function () {
$response = $this->get('/dashboard');
$response->assertRedirect('/login');
});
test('middleware allows authenticated users', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->get('/dashboard');
$response->assertStatus(200);
});Custom Middleware
php
<?php
use App\Models\User;
test('middleware blocks banned users', function () {
$user = User::factory()->create(['banned' => true]);
$response = $this->actingAs($user)
->get('/posts');
$response->assertStatus(403);
});
test('middleware allows active users', function () {
$user = User::factory()->create(['banned' => false]);
$response = $this->actingAs($user)
->get('/posts');
$response->assertStatus(200);
});Testing Complete User Flows
Complete CRUD Flow
php
<?php
use App\Models\User;
test('user can complete full post lifecycle', function () {
$user = User::factory()->create();
// Create
$createResponse = $this->actingAs($user)
->post('/posts', [
'title' => 'My Post',
'content' => 'Content here',
'status' => 'draft',
]);
$createResponse->assertRedirect('/posts');
$this->assertDatabaseHas('posts', ['title' => 'My Post']);
$post = Post::where('title', 'My Post')->first();
// Read
$showResponse = $this->actingAs($user)
->get("/posts/{$post->id}");
$showResponse->assertStatus(200)
->assertSee('My Post');
// Update
$updateResponse = $this->actingAs($user)
->put("/posts/{$post->id}", [
'title' => 'Updated Post',
'content' => 'Updated content',
'status' => 'published',
]);
$updateResponse->assertRedirect("/posts/{$post->id}");
expect($post->fresh()->title)->toBe('Updated Post');
// Delete
$deleteResponse = $this->actingAs($user)
->delete("/posts/{$post->id}");
$deleteResponse->assertRedirect('/posts');
$this->assertSoftDeleted('posts', ['id' => $post->id]);
});User Registration to Post Creation Flow
php
<?php
test('new user can register and create post', function () {
// Register
$registerResponse = $this->post('/register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$registerResponse->assertRedirect('/dashboard');
$user = User::where('email', 'john@example.com')->first();
$this->assertAuthenticatedAs($user);
// Create post
$postResponse = $this->actingAs($user)
->post('/posts', [
'title' => 'My First Post',
'content' => 'Hello World',
]);
$postResponse->assertRedirect('/posts');
$this->assertDatabaseHas('posts', [
'title' => 'My First Post',
'user_id' => $user->id,
]);
});Testing Edge Cases
Concurrent Requests
php
<?php
use App\Models\User;
use App\Models\Post;
test('handles concurrent updates correctly', function () {
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id, 'views' => 0]);
// Simulate concurrent view increments
$this->actingAs($user)->get("/posts/{$post->id}");
$this->actingAs($user)->get("/posts/{$post->id}");
expect($post->fresh()->views)->toBe(2);
});Rate Limiting
php
<?php
test('rate limits requests', function () {
$user = User::factory()->create();
// Make 60 requests
for ($i = 0; $i < 60; $i++) {
$this->actingAs($user)->get('/api/posts');
}
// 61st request should be rate limited
$response = $this->actingAs($user)->get('/api/posts');
$response->assertStatus(429);
});Large Data Sets
php
<?php
test('handles pagination with large dataset', function () {
Post::factory()->count(100)->create();
$response = $this->get('/posts');
$response->assertStatus(200);
$response->assertViewHas('posts', function ($posts) {
return $posts->count() === 15; // Default pagination
});
});Best Practices
1. Use RefreshDatabase
php
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Database is automatically reset between tests
test('creates user', function () {
// Fresh database for each test
$user = User::factory()->create();
expect(User::count())->toBe(1);
});2. Test User Journeys
php
<?php
// Good - Tests complete user journey
test('user can publish post and receive notifications', function () {
$user = User::factory()->create();
$follower = User::factory()->create();
$user->followers()->attach($follower);
Notification::fake();
$this->actingAs($user)
->post('/posts', [
'title' => 'New Post',
'content' => 'Content',
'status' => 'published',
]);
Notification::assertSentTo($follower, PostPublished::class);
});
// Bad - Tests only one part
test('creates post', function () {
$user = User::factory()->create();
$this->actingAs($user)->post('/posts', [...]);
});3. Use Descriptive Test Names
php
<?php
// Good
test('user receives error when uploading file larger than 2MB')
test('admin can delete any post regardless of author')
test('email verification link expires after 24 hours')
// Bad
test('upload validation')
test('admin rights')
test('email test')4. Test Both Success and Failure Paths
php
<?php
test('user can update profile with valid data', function () {
// Success path
});
test('user cannot update profile with invalid email', function () {
// Failure path
});
test('user cannot update profile without authentication', function () {
// Unauthorized path
});5. Keep Tests Independent
php
<?php
// Good - Each test is independent
test('creates post', function () {
$user = User::factory()->create();
// ...
});
test('updates post', function () {
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
// ...
});
// Bad - Tests depend on each other
// Don't do this!Related Documentation
- Testing Overview - General testing concepts
- Unit Testing - Unit testing patterns
- API Testing - API endpoint testing
- Best Practices - General coding best practices
Quick Reference
Common Assertions
php
// HTTP Responses
$response->assertStatus(200)
$response->assertOk()
$response->assertRedirect('/path')
$response->assertViewIs('view.name')
$response->assertViewHas('key', $value)
$response->assertSee('text')
$response->assertDontSee('text')
// Session
$this->assertSessionHas('key', 'value')
$this->assertSessionHasErrors(['field'])
$this->assertSessionMissing('key')
// Authentication
$this->assertAuthenticated()
$this->assertAuthenticatedAs($user)
$this->assertGuest()
// Database
$this->assertDatabaseHas('table', ['column' => 'value'])
$this->assertDatabaseMissing('table', ['column' => 'value'])
$this->assertDatabaseCount('table', 5)
$this->assertSoftDeleted('table', ['id' => 1])Common Patterns
bash
# Run feature tests
php artisan test tests/Feature
# Run specific test file
php artisan test tests/Feature/PostTest.php
# Run with coverage
php artisan test --coverage tests/Feature