Feature test / end-to-end test — testing a full user-facing flow
Concept
Feature test / End-to-end test — a test that verifies a complete user-facing flow, from the HTTP request to the database to the response. Tests the entire stack as an end user would experience it.
Feature test: In Laravel's terminology, a test that makes an HTTP request and asserts the response. Tests a "feature" — a user-facing piece of functionality. Not always end-to-end (might mock external services).
End-to-end (E2E) test: Tests the complete path from the user interface to the database and back, sometimes including real browsers (Cypress, Selenium, Playwright).
What feature tests cover:
- Full HTTP lifecycle: Route → Middleware → Controller → Service → DB → Response.
- Authentication and authorization in the real stack.
- Validation (FormRequest in the real request cycle).
- Response format and status codes.
- Database state after the request.
What feature tests catch that unit/integration tests don't:
- Route registration is correct.
- Middleware is applied correctly.
- FormRequest rules work as expected.
- Auth middleware blocks unauthenticated requests.
- Response structure matches what the frontend expects.
In Laravel:
$this->get('/users'),$this->post('/api/orders', $data)— makes a fake HTTP request through the full stack.$this->actingAs($user)— authenticates a user for the test.assertStatus(200),assertJson(...),assertDatabaseHas(...).
Performance: Slower than unit tests (boots app, DB operations) but much faster than real browser E2E tests.
Code Example
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class OrderApiTest extends TestCase
{
use RefreshDatabase;
public function test_guest_cannot_create_order(): void
{
$response = $this->postJson('/api/orders', ['items' => [], 'total' => 100]);
$response->assertStatus(401); // Unauthenticated
}
public function test_user_can_create_order(): void
{
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 25.00, 'stock' => 10]);
$response = $this->actingAs($user, 'sanctum') // authenticate as user
->postJson('/api/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 2],
],
]);
$response->assertStatus(201)
->assertJsonStructure([
'id', 'status', 'total', 'items' => [['product_id', 'quantity', 'price']],
])
->assertJson(['status' => 'pending', 'total' => 50.00]);
// Database state
$this->assertDatabaseHas('orders', ['user_id' => $user->id, 'total' => 50.00]);
$this->assertDatabaseHas('order_items', ['product_id' => $product->id, 'quantity' => 2]);
// Stock was decremented
$this->assertEquals(8, $product->fresh()->stock);
}
public function test_validation_fails_without_items(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/orders', []); // missing items
$response->assertStatus(422)
->assertJsonValidationErrors(['items']);
}
public function test_user_can_only_see_own_orders(): void
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
Order::factory()->for($user1)->create();
Order::factory()->for($user2)->create();
$response = $this->actingAs($user1, 'sanctum')->getJson('/api/orders');
$response->assertStatus(200)
->assertJsonCount(1, 'data'); // only user1's order
}
public function test_email_sent_after_order_placed(): void
{
\Mail::fake();
$user = User::factory()->create();
$product = Product::factory()->create(['stock' => 5]);
$this->actingAs($user, 'sanctum')
->postJson('/api/orders', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
\Mail::assertSent(\App\Mail\OrderConfirmation::class, fn($m) => $m->hasTo($user->email));
}
}