A feature test pretends to be a browser or API client, sends a request through your whole app, and checks the response and the database afterwards.
๐ต๏ธIt's a mystery shopper. They walk in the front door, order something, and report back: was the service right, did the receipt match, did the stockroom actually change?
Test what users actually do
Covers the whole chain
Routes, middleware, validation, controllers, models and views are all exercised in one test.
Proves your security rules
"Guests are redirected" and "users can't edit others' posts" become checks that run forever.
No real browser needed
Laravel handles the request in memory, so hundreds of feature tests run in seconds.
What a feature test touches
Response status
assertOk(), assertStatus(201), assertNotFound(), assertForbidden()
Page content
assertSee('Title'), assertDontSee('Draft'), assertViewIs('posts.index')
Redirects & errors
assertRedirect('/login'), assertSessionHasErrors('title')
Database
assertDatabaseHas(), assertDatabaseMissing(), assertDatabaseCount()
Test the blog's post features
-
Create a feature test with a clean database
Generate the file, then turn on
RefreshDatabase. The neatest way is once for all feature tests intests/Pest.php.php artisan make:test PostTest # Pest feature test php artisan make:test PostTest --phpunit # class-based PHPUnit versionpest()->extend(Tests\TestCase::class) ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); -
GET a page and check what's on it
$this->get()sends a fake GET request and returns a test response you can chain assertions on.use App\Models\Post; test('the blog index lists posts', function () { Post::factory()->create(['title' => 'Learning Pest']); $this->get('/posts') ->assertStatus(200) ->assertSee('Learning Pest'); }); -
Log in with
actingAsProtected pages need a user.
actingAs($user)logs that user in for the request, with no login form needed. Test the guest path too.use App\Models\User; test('guests are redirected to login', function () { $this->get('/posts/create')->assertRedirect('/login'); }); test('users can see the create form', function () { $this->actingAs(User::factory()->create()) ->get('/posts/create') ->assertOk(); }); -
POST a form and check the database
Send data with
post(), then useassertDatabaseHasto prove the row was really saved.test('a user can publish a post', function () { $user = User::factory()->create(); $response = $this->actingAs($user)->post('/posts', [ 'title' => 'My first post', 'body' => 'Written in a test!', ]); $response->assertRedirect('/posts'); $this->assertDatabaseHas('posts', [ 'title' => 'My first post', 'user_id' => $user->id, ]); }); test('a title is required', function () { $this->actingAs(User::factory()->create()) ->post('/posts', ['title' => '']) ->assertSessionHasErrors('title'); }); -
Test a JSON API
For API endpoints, use
getJson()/postJson(). They sendAccept: application/jsonfor you. Then assert on the JSON withassertJson(a partial match) orassertJsonPath(one value by dot path).use Laravel\Sanctum\Sanctum; test('the API creates a post', function () { Sanctum::actingAs(User::factory()->create()); $this->postJson('/api/posts', ['title' => 'API post', 'body' => 'Hi']) ->assertStatus(201) ->assertJsonPath('data.title', 'API post'); }); test('invalid data returns 422', function () { Sanctum::actingAs(User::factory()->create()); $this->postJson('/api/posts', []) ->assertStatus(422) ->assertJsonValidationErrors(['title', 'body']); }); test('a post can be fetched', function () { $post = Post::factory()->create(['title' => 'Hello']); $this->getJson("/api/posts/{$post->id}") ->assertOk() ->assertJson(['data' => ['id' => $post->id, 'title' => 'Hello']]); }); -
The PHPUnit equivalent
Everything above works in PHPUnit too: same assertions, just wrapped in a class.
class PostTest extends TestCase { use RefreshDatabase; public function test_guests_are_redirected_to_login(): void { $this->get('/posts/create')->assertRedirect('/login'); } }
Avoid this, do that
$this->post('/posts', $data)
->assertStatus(302);
A redirect doesn't prove anything was saved; validation failures redirect too.
$this->post('/posts', $data)
->assertRedirect('/posts');
$this->assertDatabaseHas('posts', $data);
Assert the outcome: where you landed and what changed in the database.
$this->post('/api/posts', [])
->assertStatus(422);
Without the JSON Accept header you may get a 302 redirect instead of 422.
$this->postJson('/api/posts', [])
->assertStatus(422);
The *Json helpers behave exactly like a real API client.
Chain ->dump() or ->dd() onto a test response to print its body, or ->dumpSession() to see validation errors. Remove it once the test passes.
- Test that a guest visiting
/posts/createis redirected to/login. - Test that a logged-in user can create a post, and assert the row with
assertDatabaseHas. - Test that user A gets
403when trying to update user B's post. - Test
DELETE /api/posts/{id}returns204and useassertDatabaseMissing. - Test
GET /api/postswithassertJsonPath('meta.total', 3)after creating three posts.
Key takeaways
- Feature tests send fake requests through the full app: routes, middleware, controllers and DB.
- Use
RefreshDatabaseso every test starts with an empty database. actingAs($user)logs in;Sanctum::actingAs()does the same for API routes.- Assert outcomes:
assertStatus,assertSee,assertRedirect,assertDatabaseHas. - For APIs, use
getJson/postJsonplusassertJsonandassertJsonPath.