Skip to content

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');
});
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!


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

CPR - Clinical Patient Records