In one sentence

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?

Why it matters

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.

The big picture

What a feature test touches

๐ŸงชTest$this->post('/posts')
๐Ÿ›ก๏ธMiddlewareauth, CSRF*
๐ŸŽฎControllervalidates & saves
๐Ÿ—„๏ธTest DBreset each test
โœ…Assertionsstatus, DB, JSON
The request runs through the real app. (*CSRF checks are skipped automatically while running tests.)
๐Ÿ“ถ

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()

Step by step

Test the blog's post features

  1. Create a feature test with a clean database

    Generate the file, then turn on RefreshDatabase. The neatest way is once for all feature tests in tests/Pest.php.

    
              php artisan make:test PostTest          # Pest feature test
              php artisan make:test PostTest --phpunit  # class-based PHPUnit version
            
    
              pest()->extend(Tests\TestCase::class)
                  ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
                  ->in('Feature');
            
  2. 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');
              });
            
  3. Log in with actingAs

    Protected 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();
              });
            
  4. POST a form and check the database

    Send data with post(), then use assertDatabaseHas to 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');
              });
            
  5. Test a JSON API

    For API endpoints, use getJson() / postJson(). They send Accept: application/json for you. Then assert on the JSON with assertJson (a partial match) or assertJsonPath (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']]);
              });
            
  6. 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');
                  }
              }
            
Common mistakes

Avoid this, do that

โŒ Avoid

          $this->post('/posts', $data)
              ->assertStatus(302);
        

A redirect doesn't prove anything was saved; validation failures redirect too.

โœ… Do

          $this->post('/posts', $data)
              ->assertRedirect('/posts');
          $this->assertDatabaseHas('posts', $data);
        

Assert the outcome: where you landed and what changed in the database.

โŒ Avoid

          $this->post('/api/posts', [])
              ->assertStatus(422);
        

Without the JSON Accept header you may get a 302 redirect instead of 422.

โœ… Do

          $this->postJson('/api/posts', [])
              ->assertStatus(422);
        

The *Json helpers behave exactly like a real API client.

๐Ÿ’ก Pro tip: debug a failing response

Chain ->dump() or ->dd() onto a test response to print its body, or ->dumpSession() to see validation errors. Remove it once the test passes.

๐Ÿ‹๏ธ Try it yourself
  1. Test that a guest visiting /posts/create is redirected to /login.
  2. Test that a logged-in user can create a post, and assert the row with assertDatabaseHas.
  3. Test that user A gets 403 when trying to update user B's post.
  4. Test DELETE /api/posts/{id} returns 204 and use assertDatabaseMissing.
  5. Test GET /api/posts with assertJsonPath('meta.total', 3) after creating three posts.
Recap

Key takeaways

Check yourself

Mini quiz