Introduction
A feature test makes a real HTTP request to your app — routing, middleware, controller, database, view rendering, all of it — and asserts on the response. It's the highest-value layer of the test pyramid because it catches integration bugs that pure unit tests can't see.
Key Concepts
- HTTP test methods:
$this->get(),->post(),->put(),->patch(),->delete()— plus JSON variants (getJson,postJson). - Response assertions: Fluent assertions on the
TestResponseobject:assertStatus,assertJson,assertSee,assertRedirect. actingAs($user): Sets an authenticated user for the next request, so you can test protected routes.RefreshDatabasetrait: Resets the database between tests via transactions.
Real World Context
Feature tests are what keep a Laravel app from regressing. A single test like test_guest_cannot_create_post locks in a security guarantee that a refactor can't accidentally break. A test like test_post_creation_fires_notification documents the side effects of an action so future contributors know what to expect.
Deep Dive
Making HTTP Requests
php<?php namespace Tests\Feature; use Tests\TestCase; class PostTest extends TestCase { public function test_users_can_view_posts(): void { $response = $this->get('/posts'); $response->assertStatus(200); } public function test_users_can_create_posts(): void { $response = $this->post('/posts', [ 'title' => 'Test Post', 'body' => 'This is a test post body.', ]); $response->assertStatus(201); } }
All HTTP Methods
php$this->get('/posts'); $this->post('/posts', $data); $this->put('/posts/1', $data); $this->patch('/posts/1', $data); $this->delete('/posts/1'); // With headers $this->withHeaders([ 'X-Custom-Header' => 'Value', ])->get('/api/posts'); // JSON requests (sets Accept: application/json) $this->getJson('/api/posts'); $this->postJson('/api/posts', $data); $this->putJson('/api/posts/1', $data); $this->deleteJson('/api/posts/1');
Response Assertions
Status Codes
php$response->assertStatus(200); $response->assertOk(); // 200 $response->assertCreated(); // 201 $response->assertNoContent(); // 204 $response->assertNotFound(); // 404 $response->assertForbidden(); // 403 $response->assertUnauthorized(); // 401 $response->assertUnprocessable(); // 422
Response Content
php// View assertions $response->assertViewIs('posts.index'); $response->assertViewHas('posts'); $response->assertViewHas('posts', $expectedPosts); $response->assertViewHas('user', function ($user) { return $user->name === 'John'; }); // Text assertions $response->assertSee('Welcome'); $response->assertDontSee('Error'); $response->assertSeeText('Welcome'); // Ignores HTML tags // JSON assertions $response->assertJson([ 'id' => 1, 'title' => 'Test Post', ]); $response->assertJsonPath('data.0.title', 'First Post'); $response->assertJsonCount(3, 'data'); $response->assertJsonStructure([ 'data' => [ '*' => ['id', 'title', 'body'], ], 'meta' => ['total', 'per_page'], ]); $response->assertExactJson([...]); // Must match exactly $response->assertJsonMissing(['secret_field']);
Redirects
php$response->assertRedirect('/dashboard'); $response->assertRedirectToRoute('dashboard'); $response->assertRedirectToSignedRoute('unsubscribe');
Headers and Cookies
php$response->assertHeader('Content-Type', 'application/json'); $response->assertHeaderMissing('X-Secret-Header'); $response->assertCookie('session_id'); $response->assertCookieExpired('old_cookie');
Testing with Authentication
phpuse App\Models\User; public function test_authenticated_users_can_create_posts(): void { $user = User::factory()->create(); $response = $this->actingAs($user) ->post('/posts', [ 'title' => 'Test Post', 'body' => 'Content here...', ]); $response->assertCreated(); } public function test_guests_cannot_create_posts(): void { $response = $this->post('/posts', [ 'title' => 'Test Post', 'body' => 'Content here...', ]); $response->assertRedirect('/login'); } // API authentication with Sanctum public function test_api_authentication(): void { $user = User::factory()->create(); $response = $this->actingAs($user, 'sanctum') ->getJson('/api/user'); $response->assertOk(); $response->assertJson(['id' => $user->id]); }
Session and Flash Data
php// Set session before request $this->withSession(['key' => 'value']) ->get('/dashboard'); // Assert session $response->assertSessionHas('status', 'success'); $response->assertSessionHasErrors(['email', 'password']); $response->assertSessionHasErrors(['email' => 'Invalid email']); $response->assertSessionDoesntHaveErrors();
Testing File Uploads
phpuse Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; public function test_users_can_upload_avatar(): void { Storage::fake('avatars'); $file = UploadedFile::fake()->image('avatar.jpg'); $response = $this->actingAs($this->user) ->post('/avatar', [ 'avatar' => $file, ]); $response->assertOk(); // Assert file was stored Storage::disk('avatars')->assertExists($file->hashName()); } public function test_only_images_are_accepted(): void { Storage::fake('avatars'); $file = UploadedFile::fake()->create('document.pdf', 100); $response = $this->actingAs($this->user) ->post('/avatar', ['avatar' => $file]); $response->assertSessionHasErrors(['avatar']); }
Complete Feature Test Example
php<?php namespace Tests\Feature; use App\Models\Post; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class PostTest extends TestCase { use RefreshDatabase; protected User $user; protected function setUp(): void { parent::setUp(); $this->user = User::factory()->create(); } public function test_guests_can_view_posts(): void { $post = Post::factory()->create(['title' => 'Test Post']); $response = $this->get('/posts'); $response->assertOk(); $response->assertSee('Test Post'); } public function test_authenticated_users_can_create_posts(): void { $response = $this->actingAs($this->user) ->post('/posts', [ 'title' => 'New Post', 'body' => 'Post content here...', ]); $response->assertRedirect('/posts'); $this->assertDatabaseHas('posts', [ 'title' => 'New Post', 'user_id' => $this->user->id, ]); } public function test_post_title_is_required(): void { $response = $this->actingAs($this->user) ->post('/posts', [ 'body' => 'Post content here...', ]); $response->assertSessionHasErrors(['title']); } public function test_users_can_only_update_their_own_posts(): void { $post = Post::factory()->create(); // Different user $response = $this->actingAs($this->user) ->put("/posts/{$post->id}", [ 'title' => 'Updated Title', ]); $response->assertForbidden(); } }
Common Pitfalls
- Forgetting
RefreshDatabase— without it, tests leak rows into the next test, producing spooky action-at-a-distance failures. Add the trait to any test that touches the database. - Using
->get()when testing JSON endpoints — the JSON variants (->getJson,->postJson) setAccept: application/json, so your API routes return JSON errors instead of HTML. Always match the variant to the endpoint type.
Best Practices
- One behavior per test —
test_post_creation_redirectsandtest_post_creation_fires_eventare clearer than one monolithictest_post_creation. Failure messages point at the exact broken behavior. - Use factories, not hand-built rows —
User::factory()->create()beatsUser::create([...])in every test because the factory handles required fields and relationships for you.
Summary
- Feature tests make real HTTP requests and assert on the
TestResponse. $this->get/post/put/patch/deletecover the standard verbs;*Jsonvariants set JSON headers.actingAs($user)authenticates;RefreshDatabaseresets between tests.- Assert on status, JSON shape, view bindings, session data, and headers fluently.