Unit test membuktikan logika perhitungan benar, tetapi pengguna tidak pernah memanggil PriceCalculator secara langsung — mereka membuka halaman, mengisi form, dan menekan tombol simpan. Feature test menguji jalur persis itu: request dikirim ke route, melewati middleware, masuk ke controller, menyentuh database, lalu kembali sebagai response — semuanya di dalam test, tanpa browser dan tanpa server web. Pada chapter ini kita membangun feature test untuk fitur CRUD post yang nyata, mulai dari skenario tersimpan sampai validasi gagal.

Apa yang Sesungguhnya Dijalankan Feature Test #

Method $this->post('/posts', [...]) tidak menembak jaringan. Laravel mengemas array tersebut menjadi objek Request sungguhan lalu memprosesnya melalui kernel HTTP aplikasi — routing, middleware, controller, dan Eloquent bekerja seperti pada request asli. Hasilnya berupa objek TestResponse berisi status code, header, session, dan isi response yang bisa kita assert.

Karena seluruh tumpukan ikut teruji, feature test mendeteksi kelas kesalahan yang tak terlihat dari unit test: route yang salah didaftarkan, form request yang tidak lolos middleware, kolom yang lupa di $fillable, sampai query yang gagal karena nama tabel berubah.

Fitur CRUD Post yang Akan Diuji #

Kita pakai satu fitur kecil sebagai bahan uji: tabel posts (kolom user_id, title, body, published_at, plus softDeletes()), model Post milik seorang User, dan tiga route web berikut:

php
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/posts', [PostController::class, 'index'])->name('posts.index');
Route::post('/posts', [PostController::class, 'store'])->name('posts.store');
Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');

Controller-nya menampung perilaku yang mau kita buktikan: validasi input, penyimpanan lewat relasi posts(), redirect ke halaman detail, dan flash session berisi pesan sukses:

php
public function index(): View
{
    $posts = Post::latest('id')->get();

    return view('posts.index', ['posts' => $posts]);
}

public function store(Request $request): RedirectResponse
{
    $validated = $request->validate([
        'title' => ['required', 'string', 'max:120'],
        'body' => ['required', 'string'],
    ]);

    $post = $request->user()->posts()->create($validated);

    return redirect()
        ->route('posts.show', $post)
        ->with('status', 'Post berhasil dibuat.');
}

public function show(Post $post): View
{
    return view('posts.show', ['post' => $post]);
}

Mekanisme migration, model, dan relasi hasMany/belongsTo sudah kita bahas tuntas di section Database dan Migrations serta Eloquent Relationships , jadi fokus kita di sini murni pada pengujian alurnya.

Pola Arrange-Act-Assert #

Setiap test yang rapi mengikuti tiga fase. Arrange menyiapkan keadaan awal — user yang login, data fixture. Act melakukan satu aksi yang diuji, biasanya satu baris request. Assert memverifikasi hasil dari tiga arah: response, database, dan halaman berikutnya. Pola ini membuat test mudah dibaca dan kegagalannya mudah dilacak karena kita tahu persis fase mana yang rusak.

Suite Test Lengkap #

Berikut suite untuk fitur di atas, lengkap dengan trait RefreshDatabase dan fixture user di setUp():

php
<?php

namespace Tests\Feature;

use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PostManagementTest extends TestCase
{
    use RefreshDatabase;

    private User $penulis;

    protected function setUp(): void
    {
        parent::setUp();

        $this->penulis = User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);
    }

    public function test_post_baru_tersimpan_dan_muncul_di_daftar(): void
    {
        $response = $this->actingAs($this->penulis)->post('/posts', [
            'title' => 'Belajar Feature Test',
            'body' => 'Feature test menguji alur lengkap dari route sampai database.',
        ]);

        $post = Post::query()->first();

        $response->assertRedirect(route('posts.show', $post));
        $response->assertSessionHas('status', 'Post berhasil dibuat.');

        $this->assertDatabaseHas('posts', [
            'title' => 'Belajar Feature Test',
            'user_id' => $this->penulis->id,
        ]);
        $this->assertDatabaseCount('posts', 1);

        $this->get('/posts')
            ->assertOk()
            ->assertSee('Belajar Feature Test');
    }

    public function test_post_wajib_memiliki_judul(): void
    {
        $this->actingAs($this->penulis)
            ->post('/posts', [
                'title' => '',
                'body' => 'Isi post tanpa judul.',
            ])
            ->assertInvalid(['title']);

        $this->assertDatabaseCount('posts', 0);
    }

    public function test_detail_post_menampilkan_isi_lengkap(): void
    {
        $post = Post::factory()->create([
            'title' => 'Belajar Feature Test',
            'body' => 'Feature test menguji alur lengkap dari route sampai database.',
            'user_id' => $this->penulis->id,
        ]);

        $this->get("/posts/{$post->id}")
            ->assertOk()
            ->assertSeeText('Belajar Feature Test')
            ->assertSeeText('Feature test menguji alur lengkap dari route sampai database.');
    }
}

Mari kita bedah test pertama baris per baris. Trait RefreshDatabase memigrasi database sekali lalu membungkus test dalam transaction, sehingga tiap test mulai dari keadaan bersih. Method setUp() berjalan sebelum setiap test, menjadikannya tempat ideal untuk fixture bersama seperti $this->penulis — menghemat pengulangan di semua method. Aksi $this->actingAs(...)->post('/posts', [...]) sekaligus me-login-kan user dan mengirim form. Assertion-nya lalu memeriksa tiga bukti: redirect menuju halaman detail (assertRedirect), flash session pesan sukses (assertSessionHas), dan baris database yang benar-benar tersimpan (assertDatabaseHas dengan nilai user_id yang tepat). Test ditutup dengan request GET /posts untuk memastikan judul benar-benar tampil — bukan sekadar tersimpan.

Test kedua sengaja mengirim title kosong. Assertion assertInvalid(['title']) memverifikasi validasi menolaknya, dan assertDatabaseCount('posts', 0) memastikan tidak ada baris nyasar yang tersimpan — kombinasi yang membuktikan alur gagal tetap aman.

Jalankan suite-nya:

bash
php artisan test tests/Feature/PostManagementTest.php

Output yang Dihasilkan:

text
  PASS  Tests\Feature\PostManagementTest
  ✓  post baru tersimpan dan muncul di daftar                            0.17s
  ✓  post wajib memiliki judul                                           0.01s
  ✓  detail post menampilkan isi lengkap                                 0.01s

  Tests:    3 passed (13 assertions)
  Duration: 0.27s

Tiga test ini memakai assertSee, assertRedirect, dan assertDatabaseHas — padahal kosakata assertion Laravel jauh lebih kaya, terutama untuk response JSON dan file unduhan. Chapter berikutnya memperluas kosakata itu satu per satu lewat HTTP tests. Rujukan resminya ada di dokumentasi Testing Laravel .