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! 🧪✅