# Testing and Authentication Reference Deep-dive reference for PHPUnit/Pest testing, Sanctum, Fortify, policies, form requests, and browser testing with Dusk. --- ## PHPUnit Setup ### phpunit.xml ```xml ./tests/Unit ./tests/Feature ./app ``` ### Test Databases ```php // Option 1: SQLite in-memory (fastest) // .env.testing DB_CONNECTION=sqlite DB_DATABASE=:memory: // Option 2: Separate MySQL test database DB_CONNECTION=mysql DB_DATABASE=app_testing // Option 3: Per-test transaction rollback (fastest for MySQL) use Illuminate\Foundation\Testing\DatabaseTransactions; // Option 4: Migrate fresh per test class (safest, slowest) use Illuminate\Foundation\Testing\RefreshDatabase; ``` --- ## Pest PHP (Preferred in Laravel 11+) ### Project Setup ```bash composer require pestphp/pest pestphp/pest-plugin-laravel --dev php artisan pest:install ``` ### File Structure and Syntax ```php // tests/Feature/PostTest.php use App\Models\{Post, User}; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); // Group related tests describe('Post creation', function () { beforeEach(function () { $this->user = User::factory()->create(); $this->actingAs($this->user); }); it('creates a post with valid data', function () { $response = $this->post('/posts', [ 'title' => 'My First Post', 'body' => 'Post content here.', ]); $response->assertRedirect(); $this->assertDatabaseHas('posts', ['title' => 'My First Post']); }); it('requires a title', function () { $response = $this->post('/posts', ['body' => 'Content']); $response->assertInvalid(['title']); }); it('is pending future implementation')->todo(); }); // Top-level tests test('guests cannot create posts', function () { $this->post('/posts', ['title' => 'Test'])->assertRedirect('/login'); }); ``` ### Pest Expectations ```php // Chained expectations expect($value) ->toBeTrue() ->not->toBeNull() ->toEqual('expected') ->toBeString() ->toHaveCount(3) ->toContain('substring') ->toMatchArray(['key' => 'value']) ->toHaveKey('name') ->toHaveKeys(['id', 'name', 'email']) ->toBeBetween(1, 10) ->toBeGreaterThan(5) ->toBeLessThanOrEqual(100) ->toBeInstanceOf(User::class) ->toBeNull() ->toBeEmpty() ->toThrow(InvalidArgumentException::class, 'message'); // Higher-order expectations expect([1, 2, 3])->each->toBeInt(); expect($users)->each->toBeInstanceOf(User::class); // Expectations on collections expect($users)->sequence( fn($user) => $user->name->toBe('Alice'), fn($user) => $user->name->toBe('Bob'), ); ``` ### Datasets ```php it('validates email format', function (string $email, bool $valid) { $response = $this->post('/register', ['email' => $email]); if ($valid) { $response->assertValid(['email']); } else { $response->assertInvalid(['email']); } })->with([ ['valid@example.com', true], ['not-an-email', false], ['missing@', false], ['@nodomain.com', false], ]); // Shared datasets // tests/Datasets/emails.php dataset('invalid_emails', ['not-email', '@nodomain', 'missing@tld']); ``` ### Architectural Testing ```php // tests/Architecture/AppTest.php arch('controllers do not use Eloquent directly') ->expect('App\Http\Controllers') ->not->toUse(['Illuminate\Database\Eloquent\Model']); arch('actions are invokable') ->expect('App\Actions') ->toBeClasses() ->toHaveSuffix('Action'); arch('models extend Eloquent') ->expect('App\Models') ->toExtend('Illuminate\Database\Eloquent\Model'); arch('no debug functions in production code') ->expect('App') ->not->toUse(['dd', 'dump', 'ray', 'var_dump']); ``` --- ## HTTP Tests ### Basic HTTP Testing ```php // GET requests $response = $this->get('/posts'); $response = $this->getJson('/api/posts'); // sets Accept: application/json // POST / PUT / PATCH / DELETE $response = $this->post('/posts', $data); $response = $this->postJson('/api/posts', $data); $response = $this->put('/posts/1', $data); $response = $this->patch('/posts/1', ['status' => 'published']); $response = $this->delete('/posts/1'); // With headers $response = $this->withHeaders(['X-Custom-Header' => 'value'])->get('/api/data'); // With cookies $response = $this->withCookie('token', 'abc')->get('/dashboard'); // Follow redirects $response = $this->followingRedirects()->post('/posts', $data); ``` ### Response Assertions ```php // Status codes $response->assertOk(); // 200 $response->assertCreated(); // 201 $response->assertAccepted(); // 202 $response->assertNoContent(); // 204 $response->assertMovedPermanently(); // 301 $response->assertFound(); // 302 $response->assertNotModified(); // 304 $response->assertBadRequest(); // 400 $response->assertUnauthorized(); // 401 $response->assertPaymentRequired(); // 402 $response->assertForbidden(); // 403 $response->assertNotFound(); // 404 $response->assertMethodNotAllowed(); // 405 $response->assertUnprocessable(); // 422 $response->assertTooManyRequests(); // 429 $response->assertServerError(); // 500 $response->assertStatus(418); // custom // Redirect $response->assertRedirect('/home'); $response->assertRedirectToRoute('dashboard'); $response->assertRedirectContains('/orders'); // View $response->assertViewIs('posts.index'); $response->assertViewHas('posts'); $response->assertViewHas('user', fn($user) => $user->id === 1); $response->assertSee('Hello World'); $response->assertSeeText('Hello World'); // strips HTML $response->assertDontSee('Error'); // JSON $response->assertJson(['status' => 'ok', 'data' => ['id' => 1]]); $response->assertJsonFragment(['email' => 'user@example.com']); $response->assertJsonPath('data.user.name', 'John'); $response->assertJsonPath('data.*.id', [1, 2, 3]); $response->assertJsonCount(3, 'data'); $response->assertJsonStructure([ 'data' => [ '*' => ['id', 'title', 'created_at'], ], 'meta' => ['total', 'per_page'], ]); $response->assertJsonMissing(['password', 'remember_token']); $response->assertExactJson(['key' => 'value']); // exact match // Headers and cookies $response->assertHeader('Content-Type', 'application/json'); $response->assertCookie('session'); $response->assertCookieMissing('auth_token'); // Session $response->assertSessionHas('success'); $response->assertSessionHasErrors(['email', 'password']); $response->assertSessionMissing('error'); // Validation errors $response->assertValid(['name', 'email']); $response->assertInvalid(['email' => 'invalid email format']); ``` --- ## Database Testing ### Traits ```php use Illuminate\Foundation\Testing\RefreshDatabase; // Migrates fresh for every test class (drops + re-migrates). Slower but safe. use Illuminate\Foundation\Testing\DatabaseTransactions; // Wraps each test in a transaction, rolls back. Fast, but doesn't work with external processes. use Illuminate\Foundation\Testing\DatabaseMigrations; // Migrates before the test suite, rolls back after. Per-file. ``` ### Database Assertions ```php $this->assertDatabaseHas('users', [ 'email' => 'user@example.com', 'role' => 'admin', ]); $this->assertDatabaseMissing('users', [ 'email' => 'deleted@example.com', ]); $this->assertDatabaseCount('posts', 5); $this->assertSoftDeleted('posts', ['id' => $post->id]); $this->assertNotSoftDeleted('posts', ['id' => $post->id]); $this->assertDatabaseEmpty('cache'); // Model-based assertions $this->assertModelExists($post); $this->assertModelMissing($deletedPost); ``` ### Factory Usage in Tests ```php // Create persisted records $user = User::factory()->create(); $user = User::factory()->admin()->create(['name' => 'Override Name']); // Create without persisting $user = User::factory()->make(); // Create multiple $users = User::factory()->count(5)->create(); // Create with relationships $post = Post::factory() ->for(User::factory()->admin()) ->hasComments(3) ->withTags(5) ->create(); // Seed specific data $this->seed(RoleSeeder::class); $this->seed([RoleSeeder::class, PermissionSeeder::class]); ``` --- ## Mocking Facades ### Mail ```php Mail::fake(); $this->post('/checkout', $orderData); Mail::assertSent(OrderConfirmationMail::class); Mail::assertSent(OrderConfirmationMail::class, 1); // sent exactly once Mail::assertSent(OrderConfirmationMail::class, fn($mail) => $mail->hasTo('customer@example.com') && $mail->hasSubject('Your Order Confirmation') ); Mail::assertNotSent(RefundMail::class); Mail::assertQueued(WeeklyNewsletterMail::class); // queued, not sent Mail::assertNothingSent(); ``` ### Notification ```php Notification::fake(); $this->post('/orders', $data); Notification::assertSentTo($user, InvoicePaidNotification::class); Notification::assertSentTo($user, InvoicePaidNotification::class, fn($n) => $n->invoice->id === $invoiceId ); Notification::assertNotSentTo($admin, InvoicePaidNotification::class); Notification::assertCount(2); Notification::assertNothingSent(); // On-demand notifications Notification::assertSentOnDemand(AlertNotification::class, fn($n, $routes) => $routes->hasRoute('mail', 'ops@example.com') ); ``` ### Event ```php Event::fake(); // Or fake only specific events: Event::fake([OrderPlaced::class, PaymentProcessed::class]); $this->post('/orders', $data); Event::assertDispatched(OrderPlaced::class); Event::assertDispatched(OrderPlaced::class, fn($e) => $e->order->id === $orderId); Event::assertDispatchedTimes(StockUpdated::class, 3); Event::assertNotDispatched(OrderCancelled::class); Event::assertListening(OrderPlaced::class, SendOrderConfirmation::class); Event::assertNothingDispatched(); ``` ### Queue / Bus ```php Queue::fake(); $this->post('/upload', $fileData); Queue::assertPushed(ProcessUpload::class); Queue::assertPushed(ProcessUpload::class, fn($job) => $job->filename === 'test.csv'); Queue::assertPushedOn('imports', ProcessUpload::class); Queue::assertNotPushed(NotifyAdmin::class); Queue::assertCount(2); Queue::assertNothingPushed(); // Bus for batches and chains Bus::fake(); Bus::assertChained([ValidateData::class, ProcessData::class, NotifyUser::class]); Bus::assertBatched(fn($batch) => $batch->jobs->count() === 100); ``` ### HTTP Client ```php Http::fake([ 'api.stripe.com/v1/charges' => Http::response([ 'id' => 'ch_123', 'status' => 'succeeded', ], 200), 'api.sendgrid.com/*' => Http::response(['message' => 'success'], 202), '*' => Http::response('Not mocked', 404), // catch-all ]); // Simulate failure Http::fake(['api.stripe.com/*' => Http::response(['error' => 'declined'], 402)]); // Sequence of responses Http::fake([ 'api.example.com/*' => Http::sequence() ->push(['data' => []], 200) ->push(['data' => [1]], 200) ->pushStatus(429), // rate limit on 3rd call ]); // Assert requests were made Http::assertSent(fn($request) => $request->url() === 'https://api.stripe.com/v1/charges' && $request['amount'] === 2000 ); Http::assertSentCount(3); Http::assertNotSent(fn($request) => str_contains($request->url(), 'sendgrid')); ``` ### Storage ```php Storage::fake('s3'); $this->post('/avatars', ['photo' => UploadedFile::fake()->image('photo.jpg')]); Storage::disk('s3')->assertExists('avatars/photo.jpg'); Storage::disk('s3')->assertMissing('avatars/old.jpg'); ``` --- ## Sanctum Authentication ### API Token Authentication ```php // Installation composer require laravel/sanctum php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" php artisan migrate // User model use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { use HasApiTokens; } // Issue token (login endpoint) $token = $user->createToken('mobile-app', ['orders:read', 'orders:write']); return response()->json(['token' => $token->plainTextToken]); // Check abilities $user->tokenCan('orders:read'); // bool $user->currentAccessToken(); // PersonalAccessToken model // Token expiration (config/sanctum.php) 'expiration' => 60 * 24 * 7, // 7 days in minutes // Revoke tokens $user->tokens()->delete(); // all tokens $user->currentAccessToken()->delete(); // current only ``` ### Testing with Sanctum ```php use Laravel\Sanctum\Sanctum; // Authenticate as user (no real token needed) Sanctum::actingAs($user); Sanctum::actingAs($user, ['orders:read', 'orders:write']); // with abilities // Feature test examples it('returns orders for authenticated user', function () { Sanctum::actingAs(User::factory()->create(), ['orders:read']); Order::factory()->count(3)->for(auth()->user())->create(); $this->getJson('/api/orders') ->assertOk() ->assertJsonCount(3, 'data'); }); it('rejects requests without valid token', function () { $this->getJson('/api/orders')->assertUnauthorized(); }); it('enforces token abilities', function () { Sanctum::actingAs(User::factory()->create(), ['orders:read']); // no write ability $this->postJson('/api/orders', $data)->assertForbidden(); }); ``` ### SPA Authentication (Cookie-based) ```php // Frontend must first hit GET /sanctum/csrf-cookie // Then POST /login with credentials // Subsequent requests use session cookie + X-XSRF-TOKEN header // config/sanctum.php 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,localhost:3000')), // routes/api.php Route::middleware('auth:sanctum')->get('/user', fn(Request $request) => $request->user()); // CORS (config/cors.php) 'paths' => ['api/*', 'sanctum/csrf-cookie'], 'allowed_origins' => ['http://localhost:3000'], 'supports_credentials' => true, ``` --- ## Fortify (Headless Authentication) ```bash composer require laravel/fortify php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider" php artisan migrate ``` ### Configuration ```php // config/fortify.php 'features' => [ Features::registration(), Features::resetPasswords(), Features::emailVerification(), Features::updateProfileInformation(), Features::updatePasswords(), Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, ]), ], ``` ### Customizing Actions ```php // app/Actions/Fortify/CreateNewUser.php class CreateNewUser implements CreatesNewUsers { public function create(array $input): User { Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'unique:users'], 'password' => ['required', Password::defaults(), 'confirmed'], ])->validate(); return DB::transaction(function () use ($input) { $user = User::create([ 'name' => $input['name'], 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); $user->assignRole('user'); // spatie/laravel-permission event(new Registered($user)); return $user; }); } } // FortifyServiceProvider::boot() Fortify::createUsersUsing(CreateNewUser::class); Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class); Fortify::updateUserPasswordsUsing(UpdateUserPassword::class); Fortify::resetUserPasswordsUsing(ResetUserPassword::class); ``` --- ## Policies and Gates ### Defining a Policy ```php // php artisan make:policy PostPolicy --model=Post class PostPolicy { // Gates receive user as first arg (nullable for guests) public function viewAny(?User $user): bool { return true; // anyone can list posts } public function view(?User $user, Post $post): bool { return $post->is_published || $user?->id === $post->user_id; } public function create(User $user): bool { return $user->hasVerifiedEmail(); } public function update(User $user, Post $post): bool { return $user->id === $post->user_id || $user->isAdmin(); } public function delete(User $user, Post $post): bool { return $user->id === $post->user_id || $user->isAdmin(); } public function restore(User $user, Post $post): bool { return $user->isAdmin(); } public function forceDelete(User $user, Post $post): bool { return $user->isAdmin(); } } ``` ### Registering Policies (Laravel 11+ auto-discovery) ```php // Auto-discovered if model/policy naming convention followed // OR manual registration in AppServiceProvider::boot(): Gate::policy(Post::class, PostPolicy::class); ``` ### Using Policies ```php // Controller class PostController extends Controller { public function update(Request $request, Post $post): RedirectResponse { $this->authorize('update', $post); // ... } // Resource controller - authorize all methods at once public function __construct() { $this->authorizeResource(Post::class, 'post'); } } // Route-level middleware Route::put('/posts/{post}', [PostController::class, 'update']) ->middleware('can:update,post'); // Blade @can('update', $post) ... @endcan @cannot('delete', $post) ... @endcannot // Manual check if (Gate::allows('update', $post)) { ... } if (Gate::denies('delete', $post)) { abort(403); } // Before all policy checks (super-admin bypass) Gate::before(fn(User $user) => $user->isSuperAdmin() ? true : null); ``` ### Testing Policies ```php it('allows post author to update their post', function () { $user = User::factory()->create(); $post = Post::factory()->for($user)->create(); $this->actingAs($user) ->put("/posts/{$post->id}", ['title' => 'Updated']) ->assertOk(); }); it('prevents non-author from updating post', function () { $author = User::factory()->create(); $visitor = User::factory()->create(); $post = Post::factory()->for($author)->create(); $this->actingAs($visitor) ->put("/posts/{$post->id}", ['title' => 'Hacked']) ->assertForbidden(); }); ``` --- ## Form Requests ### Request Class ```php // php artisan make:request StorePostRequest class StorePostRequest extends FormRequest { // Who can make this request? public function authorize(): bool { return $this->user()->hasVerifiedEmail(); } // Validation rules public function rules(): array { return [ 'title' => ['required', 'string', 'min:5', 'max:255'], 'body' => ['required', 'string', 'min:50'], 'status' => ['required', Rule::in(['draft', 'published'])], 'tags' => ['nullable', 'array', 'max:5'], 'tags.*' => ['integer', 'exists:tags,id'], 'image' => ['nullable', 'image', 'max:2048', 'mimes:jpg,png,webp'], 'published_at' => ['nullable', 'date', 'after:now', Rule::requiredIf($this->status === 'published')], ]; } // Transform input before validation public function prepareForValidation(): void { $this->merge([ 'slug' => Str::slug($this->title ?? ''), 'status' => $this->status ?? 'draft', ]); } // Custom error messages public function messages(): array { return [ 'title.required' => 'A post title is required.', 'body.min' => 'Posts must be at least 50 characters.', ]; } // Custom attribute names in error messages public function attributes(): array { return [ 'published_at' => 'publication date', ]; } // After validation hook (complex cross-field validation) public function after(): array { return [ function (Validator $validator) { if ($this->hasFile('image') && $this->status === 'draft') { $validator->errors()->add('image', 'Images cannot be added to draft posts.'); } }, ]; } // Safe data for controller use // $request->validated() - only validated fields // $request->safe()->only(['title', 'body']) - subset // $request->safe()->except(['tags']) - exclude } ``` ### Testing Form Requests ```php it('creates a post with valid data', function () { $user = User::factory()->verified()->create(); $this->actingAs($user)->postJson('/posts', [ 'title' => 'A Valid Post Title', 'body' => str_repeat('a', 50), // meet min:50 'status' => 'draft', ])->assertCreated(); }); it('requires a title', function () { $this->actingAs(User::factory()->verified()->create()) ->postJson('/posts', ['body' => str_repeat('a', 50), 'status' => 'draft']) ->assertUnprocessable() ->assertJsonValidationErrors(['title']); }); // Test the form request class directly (unit test) it('validates correctly', function () { $request = StorePostRequest::create('/posts', 'POST', [ 'title' => 'Valid Title', 'body' => str_repeat('a', 50), 'status' => 'draft', ]); $validator = Validator::make($request->all(), (new StorePostRequest)->rules()); expect($validator->fails())->toBeFalse(); }); ``` --- ## Middleware Testing ```php // Test route with middleware applied it('redirects unauthenticated users', function () { $this->get('/dashboard')->assertRedirect('/login'); }); // Test with middleware excluded it('processes request without auth in test', function () { $response = $this->withoutMiddleware(Authenticate::class)->get('/dashboard'); $response->assertOk(); }); // Exclude all middleware $this->withoutMiddleware()->get('/dashboard'); // Exclude CSRF for POST tests (alternative to using withHeaders) // Usually unnecessary if using postJson() or RefreshDatabase ``` --- ## Browser Testing with Dusk ### Setup ```bash composer require laravel/dusk --dev php artisan dusk:install # Update APP_URL in .env.dusk.local # Start Chrome: php artisan dusk:chrome-driver # Run tests: php artisan dusk ``` ### Test Structure ```php // tests/Browser/LoginTest.php use Laravel\Dusk\Browser; use Tests\DuskTestCase; class LoginTest extends DuskTestCase { public function test_user_can_login(): void { $user = User::factory()->create(['password' => Hash::make('password')]); $this->browse(function (Browser $browser) use ($user) { $browser->visit('/login') ->type('email', $user->email) ->type('password', 'password') ->press('Login') ->assertPathIs('/dashboard') ->assertSee('Welcome back'); }); } public function test_user_can_upload_avatar(): void { $user = User::factory()->create(); $this->browse(function (Browser $browser) use ($user) { $browser->loginAs($user) ->visit('/settings/profile') ->attach('avatar', __DIR__.'/../fixtures/avatar.jpg') ->press('Save') ->assertSee('Profile updated'); }); } } ``` ### Dusk Selectors and Assertions ```php $browser ->visit('/posts') ->assertTitle('Posts - My App') ->assertSee('Latest Posts') ->assertDontSee('Error') ->click('@create-post-btn') // dusk="create-post-btn" attribute ->pause(500) // ms - prefer waitFor instead ->waitFor('.modal', 5) // wait up to 5s ->waitForText('Post created') ->waitUntilMissing('.spinner') ->assertVisible('#post-form') ->assertMissing('.error-message') ->type('input[name=title]', 'My Post') ->select('select[name=status]', 'published') ->check('input[name=featured]') ->uncheck('input[name=notify]') ->radio('input[name=type]', 'article') ->screenshot('after-form-fill') // saves to tests/Browser/screenshots/ ->assertInputValue('title', 'My Post') ->assertChecked('featured') ->press('Submit') ->assertPathIs('/posts') ->assertRouteIs('posts.index'); // JavaScript execution $browser->script('document.querySelector(".modal").remove()'); $value = $browser->value('#hidden-input'); // Multiple browsers (for real-time features) $this->browse(function (Browser $alice, Browser $bob) { $alice->loginAs($this->user)->visit('/chat'); $bob->loginAs($this->otherUser)->visit('/chat') ->type('#message', 'Hello!') ->press('Send'); $alice->waitForText('Hello!')->assertSee('Hello!'); }); ``` --- ## Test Helpers and Utilities ### Custom Test Helpers ```php // tests/TestCase.php - add reusable methods abstract class TestCase extends BaseTestCase { protected function signIn(?User $user = null): User { $user ??= User::factory()->create(); $this->actingAs($user); return $user; } protected function signInAsAdmin(): User { $admin = User::factory()->admin()->create(); $this->actingAs($admin); return $admin; } protected function assertValidationError(TestResponse $response, string $field): void { $response->assertUnprocessable() ->assertJsonValidationErrors([$field]); } } ``` ### Parallel Testing ```bash # Run tests in parallel (requires brianium/paratest) composer require brianium/paratest --dev php artisan test --parallel php artisan test --parallel --processes=4 ``` ```php // Use separate test database per process // phpunit.xml: // Or configure in ParallelRunner ``` ### Test-Specific Configuration ```php // .env.testing overrides MAIL_MAILER=array QUEUE_CONNECTION=sync CACHE_STORE=array SESSION_DRIVER=array // Per-test config override Config::set('mail.default', 'array'); Config::set('queue.default', 'sync'); // Freeze time (Carbon) $this->travelTo(now()->setDate(2024, 1, 15)); $this->travelBack(); Carbon::setTestNow('2024-01-15 12:00:00'); Carbon::setTestNow(); // reset ```