Di bagian sebelumnya kita sudah menguasai cara membangun REST API dengan Sanctum. Sekarang kita bahas topik yang sering dilewati pemula tapi sangat penting untuk proyek yang serius: testing. Testing adalah praktik menulis kode yang memverifikasi bahwa kode aplikasi bekerja sesuai harapan — dan terus bekerja dengan benar saat ada perubahan di masa mendatang. Dengan test yang baik, kamu bisa refactor kode dengan percaya diri tanpa takut merusak fitur yang sudah berjalan.

💡 Ini adalah bagian ketiga belas dari seri "Belajar Laravel dari Nol". Di bagian ini kita akan belajar menulis test menggunakan PHPUnit dan Pest — dari unit test sederhana hingga feature test yang mensimulasikan request HTTP lengkap. Testing mungkin terasa seperti pekerjaan tambahan di awal, tapi investasi ini akan menghemat berjam-jam debugging di kemudian hari.


Mengapa Testing Penting?

Banyak developer — terutama yang baru memulai — menganggap testing sebagai kemewahan yang hanya dilakukan perusahaan besar. Ini adalah kesalahpahaman yang mahal. Test bukan tentang membuktikan kode benar hari ini, melainkan memastikan kode tetap benar saat kamu mengubahnya besok, minggu depan, atau enam bulan lagi saat sudah lupa cara kerjanya.

Text
─── TANPA TEST vs DENGAN TEST ────────────────────────────

TANPA TEST:
Developer A menambahkan fitur baru di PostController.
Tanpa disadari, fitur baru merusak cara kerja komentar.
Bug ini tidak terdeteksi sampai user mengeluh 3 hari kemudian.
Developer butuh 2 jam untuk menemukan dan memperbaikinya.
Kepercayaan user berkurang, reputasi aplikasi turun.

DENGAN TEST:
Developer A menambahkan fitur baru di PostController.
Menjalankan: php artisan test
Output: FAILED — CommentTest::test_user_can_post_comment
Test menunjukkan persis baris mana yang bermasalah.
Bug diperbaiki dalam 15 menit sebelum sampai ke production.
User tidak pernah merasakan masalah apapun.

─── MANFAAT NYATA TESTING ────────────────────────────────

✓ Refactor dengan percaya diri — ubah implementasi tanpa
  takut merusak behavior yang sudah ada
✓ Dokumentasi hidup — test menjelaskan bagaimana kode
  seharusnya bekerja dengan contoh nyata
✓ Deteksi bug lebih awal — jauh lebih murah diperbaiki
  saat masih di development daripada di production
✓ Desain kode yang lebih baik — kode yang mudah di-test
  biasanya juga kode yang lebih terstruktur
✓ Deploy dengan tenang — CI/CD bisa otomatis jalankan test
  sebelum deploy ke production

Jenis-Jenis Test di Laravel

Laravel mendukung beberapa jenis test yang masing-masing memiliki tujuan dan scope yang berbeda. Memahami perbedaannya akan membantumu memilih jenis test yang tepat untuk setiap situasi — tidak semua hal perlu di-test dengan cara yang sama.

#
Unit Test

Unit test menguji satu unit kode terkecil secara terisolasi — biasanya satu method atau satu fungsi — tanpa bergantung pada database, HTTP request, atau sistem eksternal lainnya. Sangat cepat dijalankan (milidetik) karena tidak ada I/O. Cocok untuk menguji logika bisnis murni, helper functions, service class, dan kalkulasi.

#
Feature Test

Feature test menguji satu fitur lengkap dari sudut pandang pengguna — mensimulasikan HTTP request, berinteraksi dengan database sesungguhnya, dan mengecek response yang dikembalikan. Lebih lambat dari unit test tapi jauh lebih representatif terhadap bagaimana aplikasi bekerja di dunia nyata. Ini adalah jenis test yang paling sering ditulis untuk aplikasi Laravel.

#
Browser Test (Dusk)

Browser test menggunakan browser sungguhan (Chrome) untuk menguji interaksi UI yang kompleks — mengklik tombol, mengisi form, dan memverifikasi tampilan. Sangat lambat dan lebih kompleks untuk di-setup. Gunakan hanya untuk alur kritis yang tidak bisa diuji dengan feature test biasa.


Setup: PHPUnit vs Pest

Laravel mendukung dua framework testing: PHPUnit (klasik, berbasis class) dan Pest (modern, syntax lebih ringkas dan ekspresif). Keduanya menggunakan infrastruktur yang sama di balik layar — Pest sebenarnya adalah wrapper di atas PHPUnit. Saat instalasi Breeze kamu sudah memilih salah satunya. Kita akan bahas keduanya agar kamu familiar dengan dua pendekatan ini.

Bash
# Cek framework testing yang dipakai
cat composer.json | grep -E "phpunit|pest"

# Install Pest jika belum ada
composer require pestphp/pest --dev
composer require pestphp/pest-plugin-laravel --dev
php artisan pest:install

# Jalankan semua test
php artisan test

# Atau langsung dengan PHPUnit / Pest
./vendor/bin/phpunit
./vendor/bin/pest

# ── OPSI BERGUNA ───────────────────────────────────────
# Jalankan test tertentu saja
php artisan test --filter=PostTest
php artisan test --filter=test_user_can_create_post

# Jalankan folder test tertentu
php artisan test tests/Feature
php artisan test tests/Unit

# Mode watch — otomatis jalankan ulang saat file berubah
php artisan test --watch  # Butuh: composer require --dev spatie/phpunit-watcher

# Tampilkan test yang paling lambat
php artisan test --profile

# Stop di test pertama yang gagal
php artisan test --stop-on-failure

# Jalankan test secara paralel (lebih cepat)
php artisan test --parallel
PHP
<?php
// Struktur folder test:
// tests/
// ├── Feature/           ← Feature tests (simulasi HTTP request)
// │   ├── Auth/
// │   │   ├── AuthenticationTest.php
// │   │   └── RegistrationTest.php
// │   ├── PostTest.php
// │   ├── CommentTest.php
// │   └── Api/
// │       └── PostApiTest.php
// ├── Unit/              ← Unit tests (logika murni)
// │   ├── Services/
// │   │   └── ImageUploadServiceTest.php
// │   └── Models/
// │       └── PostTest.php
// └── TestCase.php       ← Base test class

// ── phpunit.xml — Konfigurasi test ────────────────────
// Pastikan test menggunakan database terpisah:
// <env name="DB_CONNECTION" value="sqlite"/>
// <env name="DB_DATABASE" value=":memory:"/>

// Atau buat file .env.testing:
// APP_ENV=testing
// DB_CONNECTION=sqlite
// DB_DATABASE=:memory:

Unit Test: Menguji Logika Bisnis

Unit test adalah test yang paling cepat dan paling terfokus. Mari mulai dengan contoh sederhana — menguji Service class dan method di Model yang berisi logika bisnis tanpa bergantung pada database atau HTTP.

PHP
<?php
// tests/Unit/Models/PostTest.php — Unit test dengan PHPUnit

namespace Tests\Unit\Models;

use App\Models\Post;
use PHPUnit\Framework\TestCase;

class PostTest extends TestCase
{
    /** @test */
    public function it_generates_correct_read_time(): void
    {
        // Arrange: siapkan data
        $post = new Post();
        // 200 kata = 1 menit baca (200 kata per menit)
        $post->content = str_repeat('kata ', 200);

        // Act: jalankan kode yang diuji
        $readTime = $post->read_time;

        // Assert: verifikasi hasil
        $this->assertEquals('1 menit', $readTime);
    }

    /** @test */
    public function it_returns_correct_read_time_for_long_content(): void
    {
        $post = new Post();
        $post->content = str_repeat('kata ', 1000); // 1000 kata = 5 menit

        $this->assertEquals('5 menit', $post->read_time);
    }

    /** @test */
    public function it_detects_published_status_correctly(): void
    {
        $publishedPost = new Post();
        $publishedPost->status       = 'published';
        $publishedPost->published_at = now()->subDay();

        $draftPost = new Post();
        $draftPost->status = 'draft';

        $this->assertTrue($publishedPost->is_published);
        $this->assertFalse($draftPost->is_published);
    }
}
PHP
<?php
// tests/Unit/Models/PostTest.php — VERSI PEST (lebih ringkas)

use App\Models\Post;

test('post generates correct read time for 200 words', function () {
    $post          = new Post();
    $post->content = str_repeat('kata ', 200);

    expect($post->read_time)->toBe('1 menit');
});

test('post generates correct read time for long content', function () {
    $post          = new Post();
    $post->content = str_repeat('kata ', 1000);

    expect($post->read_time)->toBe('5 menit');
});

test('post detects published status correctly', function () {
    $published           = new Post(['status' => 'published']);
    $published->published_at = now()->subDay();

    $draft = new Post(['status' => 'draft']);

    expect($published->is_published)->toBeTrue();
    expect($draft->is_published)->toBeFalse();
});

// ── TEST SERVICE CLASS ─────────────────────────────────

use App\Services\ImageUploadService;

test('image upload service generates unique filenames', function () {
    $service = new ImageUploadService();

    $filename1 = $service->generateFilename('jpg');
    $filename2 = $service->generateFilename('jpg');

    expect($filename1)->not->toBe($filename2);
    expect($filename1)->toEndWith('.jpg');
});

Feature Test: Menguji Fitur Lengkap

Feature test adalah jenis test yang paling sering kamu tulis untuk aplikasi Laravel. Ia mensimulasikan HTTP request ke aplikasi dan mengecek response yang dikembalikan — termasuk status code, konten JSON, redirect, data di database, dan banyak lagi. Laravel menyediakan banyak helper method yang membuat penulisan feature test terasa sangat natural.

PHP
<?php
// tests/Feature/Auth/AuthenticationTest.php — PHPUnit

namespace Tests\Feature\Auth;

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

class AuthenticationTest extends TestCase
{
    use RefreshDatabase; // Reset database setiap test

    /** @test */
    public function users_can_authenticate_with_correct_credentials(): void
    {
        // Arrange: buat user di database
        $user = User::factory()->create([
            'email'    => 'sabda@devpath.id',
            'password' => bcrypt('password123'),
        ]);

        // Act: simulasi POST request ke /login
        $response = $this->post('/login', [
            'email'    => 'sabda@devpath.id',
            'password' => 'password123',
        ]);

        // Assert: verifikasi hasil
        $this->assertAuthenticated();           // User sudah login
        $response->assertRedirect('/dashboard'); // Redirect ke dashboard
    }

    /** @test */
    public function users_cannot_authenticate_with_invalid_password(): void
    {
        $user = User::factory()->create();

        $this->post('/login', [
            'email'    => $user->email,
            'password' => 'password-salah',
        ]);

        $this->assertGuest(); // User tidak login
    }

    /** @test */
    public function users_can_logout(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->post('/logout');

        $this->assertGuest();
        $response->assertRedirect('/');
    }
}
PHP
<?php
// tests/Feature/PostTest.php — Feature test lengkap dengan Pest

use App\Models\{Post, User, Category};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

// Aktifkan RefreshDatabase untuk semua test di file ini
uses(RefreshDatabase::class);

// ── HELPER: Buat user yang sudah login ────────────────
function createAuthUser(array $attributes = []): User
{
    return User::factory()->create(array_merge([
        'is_author' => true,
    ], $attributes));
}

// ════════════════════════════════════════════════════════
// TEST: MELIHAT POST
// ════════════════════════════════════════════════════════

test('anyone can view list of published posts', function () {
    // Arrange
    Post::factory(5)->published()->create();
    Post::factory(3)->draft()->create(); // Draft tidak seharusnya muncul

    // Act
    $response = $this->get('/blog');

    // Assert
    $response->assertStatus(200);
    $response->assertViewIs('posts.index');
    $response->assertViewHas('posts');
    // Hanya 5 post yang published yang muncul
    expect($response->viewData('posts'))->toHaveCount(5);
});

test('anyone can view a published post', function () {
    $post = Post::factory()->published()->create(['title' => 'Test Post']);

    $response = $this->get("/blog/{$post->slug}");

    $response->assertStatus(200);
    $response->assertViewIs('posts.show');
    $response->assertSee('Test Post');
});

test('guest cannot view draft post', function () {
    $post = Post::factory()->draft()->create();

    $response = $this->get("/blog/{$post->slug}");

    $response->assertStatus(404);
});

test('author can view their own draft post', function () {
    $user = createAuthUser();
    $post = Post::factory()->draft()->create(['user_id' => $user->id]);

    $response = $this->actingAs($user)->get("/blog/{$post->slug}");

    $response->assertStatus(200);
});

// ════════════════════════════════════════════════════════
// TEST: MEMBUAT POST
// ════════════════════════════════════════════════════════

test('guest cannot access create post page', function () {
    $response = $this->get('/posts/create');

    $response->assertRedirect('/login');
});

test('author can access create post page', function () {
    $user = createAuthUser();

    $response = $this->actingAs($user)->get('/posts/create');

    $response->assertStatus(200);
    $response->assertViewIs('posts.create');
});

test('author can create a post', function () {
    $user     = createAuthUser();
    $category = Category::factory()->create();

    $response = $this->actingAs($user)->post('/posts', [
        'title'       => 'Post Baru yang Keren',
        'content'     => str_repeat('Konten panjang. ', 20),
        'category_id' => $category->id,
        'status'      => 'published',
    ]);

    // Cek redirect setelah berhasil
    $response->assertRedirect();
    $response->assertSessionHas('success');

    // Cek data tersimpan di database
    $this->assertDatabaseHas('posts', [
        'title'   => 'Post Baru yang Keren',
        'user_id' => $user->id,
        'status'  => 'published',
    ]);
});

test('post creation fails with invalid data', function () {
    $user = createAuthUser();

    $response = $this->actingAs($user)->post('/posts', [
        'title'   => '', // Wajib diisi
        'content' => 'Terlalu pendek', // Minimal 100 karakter
        'status'  => 'invalid-status',
    ]);

    $response->assertSessionHasErrors(['title', 'content', 'category_id', 'status']);
    $this->assertDatabaseEmpty('posts');
});

test('author can upload thumbnail when creating post', function () {
    Storage::fake('public'); // Gunakan fake storage

    $user     = createAuthUser();
    $category = Category::factory()->create();
    $file     = UploadedFile::fake()->image('thumbnail.jpg', 800, 400);

    $this->actingAs($user)->post('/posts', [
        'title'       => 'Post dengan Thumbnail',
        'content'     => str_repeat('Konten. ', 20),
        'category_id' => $category->id,
        'status'      => 'draft',
        'thumbnail'   => $file,
    ]);

    // Verifikasi file tersimpan di storage
    Storage::disk('public')->assertExists('thumbnails/' . $file->hashName());
});
PHP
<?php
// Lanjutan PostTest.php

// ════════════════════════════════════════════════════════
// TEST: MENGUPDATE POST
// ════════════════════════════════════════════════════════

test('author can update their own post', function () {
    $user = createAuthUser();
    $post = Post::factory()->create(['user_id' => $user->id]);

    $response = $this->actingAs($user)->put("/posts/{$post->slug}", [
        'title'       => 'Judul yang Diupdate',
        'content'     => str_repeat('Konten baru. ', 20),
        'category_id' => $post->category_id,
        'status'      => 'published',
    ]);

    $response->assertRedirect();
    $this->assertDatabaseHas('posts', [
        'id'    => $post->id,
        'title' => 'Judul yang Diupdate',
    ]);
});

test('author cannot update post owned by another user', function () {
    $author    = createAuthUser();
    $otherUser = User::factory()->create();
    $post      = Post::factory()->create(['user_id' => $otherUser->id]);

    $response = $this->actingAs($author)->put("/posts/{$post->slug}", [
        'title'   => 'Coba Hack',
        'content' => str_repeat('konten. ', 20),
    ]);

    $response->assertStatus(403);
    $this->assertDatabaseMissing('posts', ['title' => 'Coba Hack']);
});

test('admin can update any post', function () {
    $admin = User::factory()->create(['is_admin' => true]);
    $post  = Post::factory()->create();

    $response = $this->actingAs($admin)->put("/posts/{$post->slug}", [
        'title'       => 'Diedit Admin',
        'content'     => str_repeat('Konten. ', 20),
        'category_id' => $post->category_id,
        'status'      => 'published',
    ]);

    $response->assertRedirect();
    $this->assertDatabaseHas('posts', ['title' => 'Diedit Admin']);
});

// ════════════════════════════════════════════════════════
// TEST: MENGHAPUS POST
// ════════════════════════════════════════════════════════

test('author can delete their own post', function () {
    $user = createAuthUser();
    $post = Post::factory()->create(['user_id' => $user->id]);

    $response = $this->actingAs($user)->delete("/posts/{$post->slug}");

    $response->assertRedirect(route('posts.index'));
    $this->assertSoftDeleted('posts', ['id' => $post->id]);
});

test('guest cannot delete any post', function () {
    $post = Post::factory()->published()->create();

    $response = $this->delete("/posts/{$post->slug}");

    $response->assertRedirect('/login');
    $this->assertDatabaseHas('posts', ['id' => $post->id, 'deleted_at' => null]);
});

Feature Test untuk API

Testing API endpoint sangat penting karena perubahan kecil di Controller bisa merusak aplikasi mobile yang mengkonsumsinya. Laravel menyediakan helper khusus untuk testing JSON API yang membuat verifikasi response JSON menjadi sangat mudah dan ekspresif.

PHP
<?php
// tests/Feature/Api/PostApiTest.php

use App\Models\{Post, User, Category};
use Laravel\Sanctum\Sanctum;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

// ── AUTH API TEST ──────────────────────────────────────

test('user can login and receive token', function () {
    $user = User::factory()->create(['password' => bcrypt('password123')]);

    $response = $this->postJson('/api/v1/auth/login', [
        'email'    => $user->email,
        'password' => 'password123',
    ]);

    $response
        ->assertStatus(200)
        ->assertJsonStructure([
            'success',
            'message',
            'data' => [
                'user' => ['id', 'name', 'email'],
                'token',
                'token_type',
                'expires_at',
            ],
        ])
        ->assertJson(['success' => true])
        ->assertJsonPath('data.token_type', 'Bearer');

    // Pastikan token ada di response
    expect($response->json('data.token'))->not->toBeNull();
});

test('login fails with wrong credentials', function () {
    $user = User::factory()->create();

    $response = $this->postJson('/api/v1/auth/login', [
        'email'    => $user->email,
        'password' => 'password-salah',
    ]);

    $response
        ->assertStatus(401)
        ->assertJson(['success' => false]);
});

// ── POST API TEST ──────────────────────────────────────

test('anyone can get list of published posts', function () {
    Post::factory(10)->published()->create();
    Post::factory(3)->draft()->create();

    $response = $this->getJson('/api/v1/posts');

    $response
        ->assertStatus(200)
        ->assertJsonStructure([
            'success',
            'data' => [
                '*' => ['id', 'title', 'slug', 'excerpt', 'status', 'published_at']
            ],
            'meta' => ['current_page', 'last_page', 'per_page', 'total'],
        ])
        ->assertJsonCount(10, 'data') // Hanya yang published
        ->assertJsonPath('meta.total', 10);
});

test('posts can be filtered by category', function () {
    $category  = Category::factory()->create(['slug' => 'laravel']);
    $otherCat  = Category::factory()->create(['slug' => 'php']);

    Post::factory(3)->published()->create(['category_id' => $category->id]);
    Post::factory(5)->published()->create(['category_id' => $otherCat->id]);

    $response = $this->getJson('/api/v1/posts?category=laravel');

    $response
        ->assertStatus(200)
        ->assertJsonCount(3, 'data')
        ->assertJsonPath('meta.total', 3);
});

test('authenticated user can create post', function () {
    // Sanctum::actingAs() untuk authenticate user di API test
    $user     = User::factory()->create(['is_author' => true]);
    $category = Category::factory()->create();

    Sanctum::actingAs($user, ['*']);

    $response = $this->postJson('/api/v1/posts', [
        'title'       => 'Post via API',
        'content'     => str_repeat('Konten panjang. ', 10),
        'category_id' => $category->id,
        'status'      => 'published',
    ]);

    $response
        ->assertStatus(201)
        ->assertJson(['success' => true])
        ->assertJsonStructure([
            'data' => ['id', 'title', 'slug', 'status', 'author'],
        ])
        ->assertJsonPath('data.title', 'Post via API');

    $this->assertDatabaseHas('posts', [
        'title'   => 'Post via API',
        'user_id' => $user->id,
    ]);
});

test('unauthenticated user cannot create post', function () {
    $response = $this->postJson('/api/v1/posts', [
        'title'   => 'Post Tanpa Auth',
        'content' => str_repeat('Konten. ', 10),
    ]);

    $response
        ->assertStatus(401)
        ->assertJson(['success' => false]);
});

test('post creation validates required fields', function () {
    $user = User::factory()->create(['is_author' => true]);
    Sanctum::actingAs($user, ['*']);

    $response = $this->postJson('/api/v1/posts', [
        'title' => '', // Kosong — wajib diisi
    ]);

    $response
        ->assertStatus(422)
        ->assertJsonStructure([
            'success',
            'message',
            'errors' => ['title', 'content', 'category_id', 'status'],
        ]);
});

test('author cannot update post owned by another user via api', function () {
    $author    = User::factory()->create(['is_author' => true]);
    $otherUser = User::factory()->create();
    $post      = Post::factory()->create(['user_id' => $otherUser->id]);

    Sanctum::actingAs($author, ['*']);

    $response = $this->putJson("/api/v1/posts/{$post->slug}", [
        'title'   => 'Coba Hack',
        'content' => str_repeat('konten. ', 10),
        'status'  => 'published',
    ]);

    $response->assertStatus(403);
});

Testing Helper Methods yang Paling Sering Dipakai

Laravel menyediakan puluhan helper method untuk membuat test menjadi lebih mudah ditulis dan lebih mudah dibaca. Berikut adalah referensi lengkap helper yang paling sering kamu gunakan dalam feature test sehari-hari.

PHP
<?php

// ── HTTP METHODS ───────────────────────────────────────
$this->get('/url');
$this->post('/url', $data);
$this->put('/url', $data);
$this->patch('/url', $data);
$this->delete('/url');

// JSON versions (set header Accept: application/json)
$this->getJson('/api/posts');
$this->postJson('/api/posts', $data);
$this->putJson('/api/posts/1', $data);
$this->deleteJson('/api/posts/1');

// ── AUTENTIKASI ────────────────────────────────────────
$this->actingAs($user);             // Login sebagai user (web)
$this->actingAs($user, 'web');      // Eksplisit guard
Sanctum::actingAs($user, ['*']);    // Login via Sanctum (API)

// ── RESPONSE ASSERTIONS ────────────────────────────────
$response->assertStatus(200);          // Status code
$response->assertOk();                 // Shorthand status 200
$response->assertCreated();            // Status 201
$response->assertNoContent();          // Status 204
$response->assertNotFound();           // Status 404
$response->assertForbidden();          // Status 403
$response->assertUnauthorized();       // Status 401
$response->assertUnprocessable();      // Status 422

$response->assertRedirect('/dashboard');
$response->assertRedirectToRoute('dashboard');

$response->assertViewIs('posts.index');
$response->assertViewHas('posts');
$response->assertViewHas('posts', fn($posts) => $posts->count() === 5);
$response->assertSee('Judul Post');
$response->assertDontSee('Password');

// JSON assertions
$response->assertJson(['success' => true]);
$response->assertJsonPath('data.title', 'Post Baru');
$response->assertJsonCount(10, 'data');
$response->assertJsonStructure(['data' => ['id', 'title', 'slug']]);
$response->assertJsonMissing(['password']);

// Session assertions
$response->assertSessionHas('success');
$response->assertSessionHasErrors(['title', 'content']);
$response->assertSessionHasErrors(['title' => 'Judul wajib diisi.']);

// ── DATABASE ASSERTIONS ────────────────────────────────
$this->assertDatabaseHas('posts', ['title' => 'Post Baru', 'user_id' => 1]);
$this->assertDatabaseMissing('posts', ['title' => 'Post Terhapus']);
$this->assertDatabaseEmpty('posts');
$this->assertDatabaseCount('posts', 5);
$this->assertSoftDeleted('posts', ['id' => 1]);
$this->assertNotSoftDeleted('posts', ['id' => 2]);

// ── AUTH ASSERTIONS ────────────────────────────────────
$this->assertAuthenticated();
$this->assertGuest();
$this->assertAuthenticatedAs($user);

// ── STORAGE ASSERTIONS ─────────────────────────────────
Storage::fake('public'); // Setup fake storage
Storage::disk('public')->assertExists('thumbnails/image.jpg');
Storage::disk('public')->assertMissing('thumbnails/deleted.jpg');

Database Testing: RefreshDatabase dan Factories

Setiap test harus berjalan dalam kondisi database yang bersih dan terprediksi. Laravel menyediakan beberapa trait untuk mengelola state database saat testing, serta Factory system yang sudah kita pelajari di Bagian 5 yang sangat berguna untuk membuat data test yang realistis.

PHP
<?php

// ── TRAIT DATABASE ─────────────────────────────────────

// RefreshDatabase: Reset database SETIAP test (paling umum)
// Menjalankan semua migration ulang, lebih lambat
use Illuminate\Foundation\Testing\RefreshDatabase;

class PostTest extends TestCase
{
    use RefreshDatabase;
}

// DatabaseTransactions: Wrap setiap test dalam transaction
// Lebih cepat dari RefreshDatabase tapi tidak bisa pakai
// truncate atau operasi DDL
use Illuminate\Foundation\Testing\DatabaseTransactions;

// ── FACTORY DALAM TEST ─────────────────────────────────

test('display only published posts', function () {
    // Buat 5 post published
    $published = Post::factory(5)->published()->create();

    // Buat 3 post draft
    Post::factory(3)->draft()->create();

    // Buat 2 post featured
    Post::factory(2)->published()->featured()->create();

    $response = $this->get('/blog');

    // Total yang muncul: 5 + 2 = 7 (draft tidak muncul)
    $response->assertViewHas('posts', fn($posts) => $posts->count() === 7);
});

// ── FACTORY DENGAN RELASI ──────────────────────────────
test('post shows author name', function () {
    $user = User::factory()->create(['name' => 'Sabda DevPath']);
    $post = Post::factory()->published()->create([
        'user_id' => $user->id,
        'title'   => 'Tutorial Laravel'
    ]);

    $response = $this->get("/blog/{$post->slug}");

    $response->assertSee('Sabda DevPath');
    $response->assertSee('Tutorial Laravel');
});

// ── CEK JUMLAH QUERY ───────────────────────────────────
test('post index does not have N+1 problem', function () {
    Post::factory(10)->published()->create();

    // Catat semua query yang berjalan
    $queryCount = 0;
    \DB::listen(function () use (&$queryCount) {
        $queryCount++;
    });

    $this->get('/blog');

    // Dengan eager loading yang benar, jumlah query harus minimal
    // (bukan 10+ query untuk 10 post)
    expect($queryCount)->toBeLessThan(5);
});

Test-Driven Development (TDD): Tulis Test Dulu

TDD (Test-Driven Development) adalah pendekatan di mana kamu menulis test sebelum menulis kode implementasinya. Siklus TDD adalah: Tulis test yang gagal → Tulis kode minimum agar test lulus → Refactor kode. Meski terasa aneh di awal, TDD memaksa kamu berpikir dari perspektif pengguna API/fitur sebelum memikirkan implementasi — menghasilkan desain kode yang lebih baik.

PHP
<?php
// Contoh alur TDD untuk fitur "like post"

// ── LANGKAH 1: Tulis test dulu (test akan GAGAL) ───────

test('user can like a post', function () {
    $user = User::factory()->create();
    $post = Post::factory()->published()->create();

    Sanctum::actingAs($user);

    $response = $this->postJson("/api/v1/posts/{$post->slug}/like");

    $response
        ->assertStatus(200)
        ->assertJson([
            'success'  => true,
            'is_liked' => true,
        ]);

    $this->assertDatabaseHas('likes', [
        'user_id'     => $user->id,
        'likeable_id' => $post->id,
    ]);
});

test('user can unlike a post they already liked', function () {
    $user = User::factory()->create();
    $post = Post::factory()->published()->create();

    // User sudah like sebelumnya
    $post->likes()->create(['user_id' => $user->id]);

    Sanctum::actingAs($user);

    $response = $this->postJson("/api/v1/posts/{$post->slug}/like");

    $response
        ->assertStatus(200)
        ->assertJson([
            'success'  => true,
            'is_liked' => false,
        ]);

    $this->assertDatabaseMissing('likes', [
        'user_id'     => $user->id,
        'likeable_id' => $post->id,
    ]);
});

test('like count updates correctly', function () {
    $user1 = User::factory()->create();
    $user2 = User::factory()->create();
    $post  = Post::factory()->published()->create();

    Sanctum::actingAs($user1);
    $this->postJson("/api/v1/posts/{$post->slug}/like");

    Sanctum::actingAs($user2);
    $response = $this->postJson("/api/v1/posts/{$post->slug}/like");

    $response->assertJsonPath('likes_count', 2);
});

// ── LANGKAH 2: Tulis implementasi agar test lulus ─────
// (Implementasi PostController::toggleLike() sudah ada di Bagian 12)

// ── LANGKAH 3: Jalankan test ──────────────────────────
// php artisan test --filter=like

// ── LANGKAH 4: Refactor jika perlu, test tetap harus lulus ──

Praktik: Test Suite Lengkap untuk Blog

Mari lihat bagaimana test suite yang terorganisir dengan baik terlihat untuk aplikasi blog — dengan penamaan yang konsisten, helper yang reusable, dan cakupan test yang komprehensif tanpa berlebihan.

PHP
<?php
// tests/Feature/PostTest.php — Test suite terorganisir

use App\Models\{Post, User, Category, Tag};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

uses(RefreshDatabase::class);

// ── SETUP: Helper functions ────────────────────────────
function makeUser(array $attrs = []): User
{
    return User::factory()->create($attrs);
}

function makeAdmin(): User
{
    return makeUser(['is_admin' => true]);
}

function makeAuthor(): User
{
    return makeUser(['is_author' => true]);
}

function makePost(array $attrs = []): Post
{
    return Post::factory()->published()->create($attrs);
}

// ── DESCRIBE BLOCK: Grouping test yang berkaitan ───────

describe('viewing posts', function () {
    test('guests can see published posts', function () {
        makePost(['title' => 'Post Publik']);

        $this->get('/blog')
            ->assertOk()
            ->assertSee('Post Publik');
    });

    test('draft posts are hidden from guests', function () {
        Post::factory()->draft()->create(['title' => 'Post Draft']);

        $this->get('/blog')
            ->assertOk()
            ->assertDontSee('Post Draft');
    });

    test('posts can be filtered by search', function () {
        makePost(['title' => 'Belajar Laravel']);
        makePost(['title' => 'Belajar Docker']);
        makePost(['title' => 'Tutorial Python']);

        $this->get('/blog?search=Laravel')
            ->assertOk()
            ->assertSee('Belajar Laravel')
            ->assertDontSee('Belajar Docker')
            ->assertDontSee('Tutorial Python');
    });
});

describe('creating posts', function () {
    test('guest is redirected to login', function () {
        $this->get('/posts/create')->assertRedirect('/login');
        $this->post('/posts', [])->assertRedirect('/login');
    });

    test('regular user without author role cannot create post', function () {
        $this->actingAs(makeUser())
            ->get('/posts/create')
            ->assertForbidden();
    });

    test('author can create post with all fields', function () {
        Storage::fake('public');

        $author   = makeAuthor();
        $category = Category::factory()->create();
        $tags     = Tag::factory(3)->create();

        $this->actingAs($author)->post('/posts', [
            'title'       => 'Post Lengkap',
            'content'     => str_repeat('Konten artikel. ', 10),
            'excerpt'     => 'Ringkasan singkat.',
            'category_id' => $category->id,
            'tags'        => $tags->pluck('id')->toArray(),
            'thumbnail'   => UploadedFile::fake()->image('cover.jpg'),
            'status'      => 'published',
        ])->assertRedirect();

        $post = Post::where('title', 'Post Lengkap')->first();
        expect($post)->not->toBeNull();
        expect($post->tags)->toHaveCount(3);
        expect($post->published_at)->not->toBeNull();
    });

    test('validation prevents incomplete post', function () {
        $this->actingAs(makeAuthor())
            ->post('/posts', ['title' => 'a'])
            ->assertSessionHasErrors(['title', 'content', 'category_id', 'status']);

        $this->assertDatabaseEmpty('posts');
    });
});

describe('editing posts', function () {
    test('author can edit their own post', function () {
        $author = makeAuthor();
        $post   = makePost(['user_id' => $author->id]);

        $this->actingAs($author)
            ->put("/posts/{$post->slug}", [
                'title'       => 'Judul Diupdate',
                'content'     => str_repeat('Konten baru. ', 10),
                'category_id' => $post->category_id,
                'status'      => 'published',
            ])->assertRedirect();

        expect($post->fresh()->title)->toBe('Judul Diupdate');
    });

    test('author cannot edit post owned by another', function () {
        $author = makeAuthor();
        $post   = makePost(); // Post milik user lain

        $this->actingAs($author)
            ->put("/posts/{$post->slug}", ['title' => 'Coba Edit'])
            ->assertForbidden();
    });

    test('admin can edit any post', function () {
        $post = makePost();

        $this->actingAs(makeAdmin())
            ->put("/posts/{$post->slug}", [
                'title'       => 'Diedit Admin',
                'content'     => str_repeat('Konten. ', 10),
                'category_id' => $post->category_id,
                'status'      => 'published',
            ])->assertRedirect();

        expect($post->fresh()->title)->toBe('Diedit Admin');
    });
});

describe('deleting posts', function () {
    test('author can delete their own post', function () {
        $author = makeAuthor();
        $post   = makePost(['user_id' => $author->id]);

        $this->actingAs($author)
            ->delete("/posts/{$post->slug}")
            ->assertRedirect(route('posts.index'));

        $this->assertSoftDeleted('posts', ['id' => $post->id]);
    });

    test('author cannot delete post owned by another', function () {
        $post = makePost();

        $this->actingAs(makeAuthor())
            ->delete("/posts/{$post->slug}")
            ->assertForbidden();
    });
});

💡 Gunakan describe() untuk mengelompokkan test yang berkaitan — ini membuat output test lebih mudah dibaca dan memudahkan menjalankan hanya kelompok test tertentu. Gunakan nama test yang deskriptif dalam format "subjek dapat melakukan aksi" atau "aksi berhasil/gagal dalam kondisi tertentu" — nama test adalah dokumentasi paling berharga dari perilaku sistem.


CI/CD: Jalankan Test Otomatis saat Push ke GitHub

Test yang hanya dijalankan secara manual tidak memberikan perlindungan penuh — developer sering lupa menjalankan test sebelum push. CI/CD (Continuous Integration/Continuous Deployment) memastikan test dijalankan otomatis setiap kali ada perubahan kode yang di-push ke repository.

YAML
# .github/workflows/tests.yml
# GitHub Actions — Jalankan test otomatis saat push

name: Laravel Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      # Atau gunakan SQLite :memory: untuk lebih cepat
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: laravel_test
        ports: ['3306:3306']
        options: --health-cmd="mysqladmin ping" --health-interval=10s

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo, pdo_mysql, gd
          coverage: none

      - name: Copy .env
        run: cp .env.example .env.testing

      - name: Install Dependencies
        run: composer install --no-interaction --prefer-dist --optimize-autoloader

      - name: Generate App Key
        run: php artisan key:generate --env=testing

      - name: Run Database Migrations
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: laravel_test
          DB_USERNAME: root
          DB_PASSWORD: password
        run: php artisan migrate --env=testing

      - name: Run Tests
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_DATABASE: laravel_test
          DB_USERNAME: root
          DB_PASSWORD: password
        run: php artisan test --parallel

⚠️ Jangan commit file .env.testing ke repository publik jika berisi kredensial nyata. Gunakan GitHub Secrets untuk menyimpan nilai sensitif dan referensikan di workflow file menggunakan ${{ secrets.DB_PASSWORD }}. Untuk test lokal, gunakan SQLite in-memory (DB_CONNECTION=sqlite, DB_DATABASE=:memory:) yang jauh lebih cepat dan tidak membutuhkan server database terpisah.


Langkah Selanjutnya

Luar biasa — kamu sudah menguasai Testing Dasar di Laravel! Mulai dari memahami mengapa testing penting, jenis-jenis test, unit test untuk logika bisnis, feature test yang mensimulasikan HTTP request, testing API dengan Sanctum, semua assertion helper yang tersedia, pendekatan TDD, membangun test suite yang terorganisir, hingga mengotomasi test dengan GitHub Actions CI/CD. Di bagian keempat belas dan terakhir dari seri ini, kita akan membahas cara Deploy Laravel ke VPS Production — menyatukan semua yang sudah dipelajari untuk menghadirkan aplikasimu ke dunia nyata.

🚀 Sebelum lanjut ke Bagian 14, pastikan kamu sudah berlatih: (1) menulis minimal 5 feature test untuk fitur yang sudah ada di aplikasimu dan menjalankan semuanya dengan php artisan test, (2) mencoba pendekatan TDD untuk satu fitur kecil yang baru, (3) menggunakan Storage::fake() untuk test yang melibatkan file upload, dan (4) setup GitHub Actions agar test otomatis berjalan setiap push. Kebiasaan menulis test sejak awal adalah yang paling membedakan developer junior dan senior dalam jangka panjang.