Langsung ke konten
KamusNgoding
Mahir Laravel 4 menit baca

Testing di Laravel: Panduan Lengkap PHPUnit dan Pest untuk Developer Indonesia

#laravel #testing #phpunit #pest #unit-test #feature-test #tdd #advanced

Pendahuluan

“Saya belum pernah nulis test. Kayaknya buang-buang waktu.”

Hampir setiap developer Indonesia pernah berkata demikian. Sampai suatu hari mereka mengalami: refactoring kecil yang tidak sengaja merusak fitur lain, deploy yang memperkenalkan bug regression, atau rasa was-was setiap kali push ke production.

Testing adalah jaring pengaman — memungkinkan kamu refactor dengan percaya diri, mendeteksi bug sebelum user menemukannya, dan tidur nyenyak setelah deploy.

Di Laravel, testing sudah terintegrasi penuh. Tidak perlu setup apapun — langsung bisa jalankan:

php artisan test

Jenis Test di Laravel

Bayangkan kamu membangun gedung:

  • Unit Test → Uji setiap bata secara individual (fungsi, class, method)
  • Feature Test → Uji satu ruangan (endpoint, halaman, fitur lengkap)
  • Browser Test (Dusk) → Uji gedung dari perspektif pengunjung (klik tombol, isi form di browser nyata)

Untuk kebanyakan aplikasi Laravel, Feature Test memberikan nilai terbesar karena menguji alur dari HTTP request hingga response.


Langkah 1: Struktur File Test

tests/
├── Unit/
│   └── TaskTest.php          # Unit test untuk model/class spesifik
├── Feature/
│   ├── Auth/
│   │   └── LoginTest.php     # Test alur login
│   └── TaskTest.php          # Test endpoint task
├── TestCase.php              # Base class semua test
└── Pest.php                  # Konfigurasi Pest (jika pakai Pest)

Setup .env.testing agar test menggunakan database terpisah:

# .env.testing
APP_ENV=testing
DB_CONNECTION=sqlite
DB_DATABASE=:memory:   # SQLite in-memory — cepat dan terisolasi

CACHE_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array

Langkah 2: Unit Test Pertama

Unit test menguji satu fungsi/method secara terisolasi:

php artisan make:test TaskStatusTest --unit
<?php
// tests/Unit/TaskStatusTest.php

namespace Tests\Unit;

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

class TaskStatusTest extends TestCase
{
    /** @test */
    public function task_can_be_marked_as_completed(): void
    {
        $task = new Task(['status' => 'pending']);

        $task->markAsCompleted();

        $this->assertEquals('completed', $task->status);
    }

    /** @test */
    public function completed_task_returns_true_for_is_done(): void
    {
        $task = new Task(['status' => 'completed']);

        $this->assertTrue($task->isDone());
    }

    /** @test */
    public function priority_label_returns_correct_value(): void
    {
        $task = new Task(['priority' => 'high']);

        $this->assertEquals('Tinggi', $task->priorityLabel());
    }
}
// app/Models/Task.php — Method yang ditest

public function markAsCompleted(): void
{
    $this->status = 'completed';
}

public function isDone(): bool
{
    return $this->status === 'completed';
}

public function priorityLabel(): string
{
    return match($this->priority) {
        'low'    => 'Rendah',
        'medium' => 'Sedang',
        'high'   => 'Tinggi',
        default  => 'Tidak Diketahui',
    };
}

Langkah 3: Feature Test — HTTP Request Testing

Feature test menguji alur HTTP lengkap dari request hingga response:

php artisan make:test TaskApiTest
<?php
// tests/Feature/TaskApiTest.php

namespace Tests\Feature;

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

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

    /** @test */
    public function authenticated_user_can_get_task_list(): void
    {
        $user = User::factory()->create();
        Task::factory()->count(3)->create(['user_id' => $user->id]);

        $response = $this->actingAs($user, 'sanctum')
            ->getJson('/api/tasks');

        $response->assertStatus(200)
            ->assertJsonCount(3, 'data')
            ->assertJsonStructure([
                'data' => [
                    '*' => ['id', 'title', 'status', 'priority'],
                ],
            ]);
    }

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

        $response = $this->actingAs($user, 'sanctum')
            ->postJson('/api/tasks', [
                'title'    => 'Belajar Laravel Testing',
                'priority' => 'high',
            ]);

        $response->assertStatus(201)
            ->assertJsonPath('data.title', 'Belajar Laravel Testing');

        $this->assertDatabaseHas('tasks', [
            'title'   => 'Belajar Laravel Testing',
            'user_id' => $user->id,
        ]);
    }

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

        $response = $this->actingAs($user, 'sanctum')
            ->postJson('/api/tasks', ['priority' => 'high']); // tanpa title

        $response->assertStatus(422)
            ->assertJsonValidationErrors(['title']);
    }

    /** @test */
    public function user_cannot_delete_other_users_task(): void
    {
        $owner = User::factory()->create();
        $other = User::factory()->create();
        $task  = Task::factory()->create(['user_id' => $owner->id]);

        $response = $this->actingAs($other, 'sanctum')
            ->deleteJson("/api/tasks/{$task->id}");

        $response->assertStatus(403); // Forbidden

        $this->assertDatabaseHas('tasks', ['id' => $task->id]); // Task masih ada
    }

    /** @test */
    public function guest_cannot_access_tasks(): void
    {
        $this->getJson('/api/tasks')->assertStatus(401);
    }
}

Langkah 4: Database Testing dengan Factory

Factory membuat data test secara realistis:

<?php
// database/factories/TaskFactory.php

namespace Database\Factories;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class TaskFactory extends Factory
{
    public function definition(): array
    {
        return [
            'user_id'  => User::factory(), // Otomatis buat user baru
            'title'    => $this->faker->sentence(4),
            'priority' => $this->faker->randomElement(['low', 'medium', 'high']),
            'status'   => $this->faker->randomElement(['pending', 'completed']),
            'due_date' => $this->faker->optional()->dateTimeBetween('now', '+30 days'),
        ];
    }

    // State — buat task dengan kondisi spesifik
    public function completed(): static
    {
        return $this->state(['status' => 'completed']);
    }

    public function highPriority(): static
    {
        return $this->state(['priority' => 'high']);
    }
}
// Penggunaan di test:

// Buat 5 task biasa
Task::factory()->count(5)->create();

// Buat 3 task yang sudah selesai milik user tertentu
Task::factory()->count(3)->completed()->create(['user_id' => $user->id]);

// Buat 10 task dengan priority tinggi
Task::factory()->count(10)->highPriority()->create();

Langkah 5: Mocking — Isolasi Dependensi Eksternal

Saat testing, kita tidak ingin benar-benar mengirim email, memproses queue, atau memanggil API Midtrans. Gunakan mock!

<?php
// tests/Feature/RegisterTest.php

use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use App\Mail\WelcomeMail;
use App\Jobs\SendWelcomeEmail;

/** @test */
public function registration_sends_welcome_email(): void
{
    // Intercept semua mail — tidak benar-benar terkirim
    Mail::fake();

    $response = $this->postJson('/api/register', [
        'name'                  => 'Budi Santoso',
        'email'                 => '[email protected]',
        'password'              => 'password123',
        'password_confirmation' => 'password123',
    ]);

    $response->assertStatus(201);

    // Verifikasi bahwa WelcomeMail dikirim ke email yang benar
    Mail::assertSent(WelcomeMail::class, function ($mail) {
        return $mail->hasTo('[email protected]');
    });
}

/** @test */
public function registration_dispatches_welcome_job(): void
{
    Queue::fake(); // Intercept semua queue job

    $this->postJson('/api/register', [...]);

    // Verifikasi job di-dispatch
    Queue::assertPushed(SendWelcomeEmail::class);
}

/** @test */
public function file_upload_is_stored(): void
{
    Storage::fake('public'); // Fake storage — file tidak benar-benar disimpan

    $user     = User::factory()->create();
    $fakeFile = UploadedFile::fake()->image('photo.jpg', 400, 400);

    $this->actingAs($user)
        ->postJson('/api/profile/photo', ['photo' => $fakeFile])
        ->assertStatus(200);

    // Verifikasi file ada di fake storage
    Storage::disk('public')->assertExists("avatars/{$user->fresh()->avatar}");
}

Langkah 6: Pest — Sintaks yang Lebih Bersih

Pest adalah test framework di atas PHPUnit dengan sintaks yang jauh lebih ekspresif:

composer require pestphp/pest --dev
composer require pestphp/pest-plugin-laravel --dev
php artisan pest:install

Perbandingan PHPUnit vs Pest

// ❌ PHPUnit (verbose)
class TaskTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function user_can_create_task(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user)->postJson('/api/tasks', [...])->assertStatus(201);
    }
}

// ✅ Pest (ekspresif, seperti bahasa Inggris)
it('allows user to create a task', function () {
    $user = User::factory()->create();
    actingAs($user)->postJson('/api/tasks', [...])->assertStatus(201);
});

// Pest dengan `expect()` (assertion yang lebih readable)
it('returns 3 tasks', function () {
    $user  = User::factory()->create();
    Task::factory()->count(3)->create(['user_id' => $user->id]);

    $response = actingAs($user)->getJson('/api/tasks');

    expect($response->status())->toBe(200)
        ->and($response->json('data'))->toHaveCount(3);
});

Pest Dataset — Uji Banyak Input Sekaligus

// Uji validasi dengan berbagai input tidak valid
it('rejects invalid task data', function (array $data, string $errorField) {
    $user = User::factory()->create();

    actingAs($user)
        ->postJson('/api/tasks', $data)
        ->assertJsonValidationErrors([$errorField]);
})->with([
    'missing title'    => [['priority' => 'high'], 'title'],
    'empty title'      => [['title' => '', 'priority' => 'high'], 'title'],
    'invalid priority' => [['title' => 'Test', 'priority' => 'urgent'], 'priority'],
    'title too long'   => [['title' => str_repeat('a', 256), 'priority' => 'low'], 'title'],
]);

Langkah 7: Test-Driven Development (TDD) Workflow

TDD berarti menulis test sebelum menulis kode. Siklus: Red → Green → Refactor.

Contoh: Tambah Fitur “Complete Task”

1. Tulis test dulu (Red — test gagal karena fitur belum ada)

it('allows user to mark their task as complete', function () {
    $user = User::factory()->create();
    $task = Task::factory()->create(['user_id' => $user->id, 'status' => 'pending']);

    actingAs($user)
        ->patchJson("/api/tasks/{$task->id}/complete")
        ->assertStatus(200)
        ->assertJsonPath('data.status', 'completed');

    expect($task->fresh()->status)->toBe('completed');
});

2. Jalankan test — pasti gagal (404)

php artisan test --filter "mark task as complete"

3. Buat route dan controller (Green — buat test lulus)

// routes/api.php
Route::patch('/tasks/{task}/complete', [TaskController::class, 'complete']);

// TaskController
public function complete(Task $task): JsonResponse
{
    $this->authorize('update', $task);
    $task->update(['status' => 'completed']);
    return response()->json(['data' => new TaskResource($task)]);
}

4. Jalankan test — harus hijau

5. Refactor jika perlu tanpa takut merusak apapun


Jalankan Test

# Semua test
php artisan test

# Filter test spesifik
php artisan test --filter TaskApiTest

# Paralel — lebih cepat
php artisan test --parallel

# Lihat coverage (butuh Xdebug)
php artisan test --coverage

Troubleshooting: Error yang Sering Muncul

Database state bocor antar test

Penyebab: RefreshDatabase tidak digunakan, atau menggunakan database production.

Solusi:

// Selalu gunakan RefreshDatabase atau DatabaseTransactions di Feature Test
use Illuminate\Foundation\Testing\RefreshDatabase;

class TaskTest extends TestCase
{
    use RefreshDatabase; // ← Wajib ada ini

    // ...
}

// Pastikan .env.testing menggunakan DB yang berbeda
DB_DATABASE=:memory: // SQLite in-memory

Class “Tests\TestCase” not found

Penyebab: Autoload tidak diperbarui setelah membuat file test baru.

Solusi:

composer dump-autoload

Mock tidak bekerja — masih memanggil service asli

Penyebab: Facade mock harus dipanggil sebelum kode yang di-test dieksekusi.

Solusi:

/** @test */
public function test_email_is_sent(): void
{
    Mail::fake(); // ← Harus di AWAL test, sebelum aksi apapun

    // Baru kemudian lakukan request
    $this->postJson('/api/register', [...]);

    Mail::assertSent(WelcomeMail::class);
}

Pertanyaan yang Sering Diajukan

PHPUnit vs Pest, mana yang harus dipilih pemula?

Mulai dengan PHPUnit untuk memahami konsep dasar, lalu beralih ke Pest untuk produktivitas lebih tinggi. Pest berjalan di atas PHPUnit, jadi semua yang kamu pelajari di PHPUnit tetap berlaku. Kebanyakan proyek Laravel baru saat ini langsung pakai Pest.

Berapa persen code coverage yang ideal?

Tidak ada angka ajaib. 80% coverage dengan test yang bermakna jauh lebih baik dari 100% coverage dengan test yang hanya mengeksekusi kode tanpa assertion berarti. Fokus pada business-critical paths: alur autentikasi, pembayaran, operasi data penting.

Kapan harus menulis test, sebelum atau sesudah coding?

TDD (test dulu) menghasilkan desain yang lebih baik karena memaksamu berpikir tentang API sebelum implementasi. Test setelah coding lebih pragmatis dan sering dipilih developer berpengalaman untuk fitur yang spesifikasinya masih berubah. Yang paling penting: test harus ada, kapanpun kamu menulisnya.

Apakah testing memperlambat development?

Di jangka pendek: ya, sedikit. Di jangka panjang: tidak — justru mempercepat. Tanpa test, kamu menghabiskan waktu debugging regression manual. Dengan test, kamu bisa refactor dengan berani dan deploy lebih cepat karena sudah yakin fitur tidak rusak.


Kesimpulan

Testing bukan pilihan — ini standar profesi. Developer yang menulis test adalah developer yang bisa dipercaya timnya untuk deploy tanpa merusak apapun.

Kita sudah belajar:

  • Unit test untuk logika bisnis
  • Feature test untuk HTTP endpoints
  • Database testing dengan Factory
  • Mocking untuk isolasi dependensi eksternal
  • Pest untuk sintaks yang lebih ekspresif
  • TDD workflow: Red → Green → Refactor

Selanjutnya, integrasikan test ke pipeline deployment otomatis di CI/CD dengan GitHub Actions — setiap push ke main akan menjalankan semua test secara otomatis!

Selamat bergabung dengan barisan developer yang bisa tidur nyenyak setelah deploy! 🧪✅

Artikel Terkait