Banyak developer yang baru pertama kali dengar soal testing langsung punya reaksi yang sama: “Nanti aja, yang penting fiturnya jalan dulu.”
Dan memang, testing tidak membuat fitur jalan lebih cepat. Tapi suatu hari, kamu refactor satu bagian kecil dari kode — dan tiga fitur lain di tempat yang tidak ada hubungannya tiba-tiba rusak. Kalau ada test, kamu tahu dalam hitungan detik. Kalau tidak ada, kamu tahu saat user laporan.
Artikel ini bahas tiga jenis test yang ada di Laravel, kapan masing-masing dipakai, dan bagaimana menangani satu masalah klasik: bagaimana test sebuah fitur yang bergantung pada service eksternal yang tidak bisa kamu kontrol.
🤔 Satu Prinsip Sebelum Mulai
Sebelum masuk ke kode, ada dua cara pikir soal testing yang perlu diluruskan:
❌ “Kalau test hijau, berarti pasti benar.”
✅ “Kalau test hijau, kemungkinan besar benar. Kalau merah, pasti ada yang salah.”
Testing bukan jaminan kebenaran mutlak. Test adalah kode juga — dan kode bisa bug. Yang test lakukan adalah menurunkan risiko, bukan menghilangkannya.
Dan satu prinsip lagi yang sering dilanggar: test harus lebih bodoh dari kode yang ditest. Artinya, test tidak boleh meniru logika dari kode yang ingin dicek. Test cukup berupa input dan output yang diharapkan. Kalau test ikut berhitung diskon, siapa yang mengecek kalau perhitungannya salah?
🔬 Unit Test — “Tes Satu Komponen, Sendirian”
Analoginya
Bayangin kamu beli mesin kopi baru. Sebelum dirangkai, kamu tes masing-masing komponen: apakah grinder-nya mau berputar? Apakah pompa airnya mau mengalir? Apakah heater-nya mau panas?
Setiap komponen ditest sendirian, tanpa bergantung pada komponen lain. Kalau grinder rusak, kamu langsung tahu — tidak perlu nunggu kopi jadinya terasa aneh.
Unit Test di Laravel persis seperti itu. Dia menguji satu method atau class secara terisolasi — tanpa database, tanpa HTTP, tanpa service eksternal.
Karakteristiknya
- Tidak akses database
- Tidak lewat HTTP request
- Tidak load service eksternal
- Semua dependency yang tidak relevan di-mock
- Sangat cepat — ratusan test bisa selesai dalam hitungan detik
Kodenya
Pertama, buat logic yang mau ditest sebagai class terpisah. Ini kunci penting — logic yang tertanam di Controller tidak bisa ditest unit secara bersih.
📁 app/Services/DiscountService.php
<?php
namespace App\Services;
class DiscountService
{
public function apply(int $price, int $discount): int
{
return $price - ($price * $discount / 100);
}
}
php artisan make:test DiscountServiceTest --unit
📁 tests/Unit/DiscountServiceTest.php
<?php
namespace Tests\Unit;
use App\Services\DiscountService;
use PHPUnit\Framework\TestCase;
class DiscountServiceTest extends TestCase
{
public function test_discount_is_applied_correctly(): void
{
$service = new DiscountService();
// Input: harga 100, diskon 10%
$result = $service->apply(100, 10);
// Output yang diharapkan: 90
$this->assertEquals(90, $result);
}
public function test_zero_discount_returns_original_price(): void
{
$service = new DiscountService();
$result = $service->apply(100, 0);
$this->assertEquals(100, $result);
}
public function test_full_discount_returns_zero(): void
{
$service = new DiscountService();
$result = $service->apply(100, 100);
$this->assertEquals(0, $result);
}
}
📌 Perhatikan — tidak ada setUp() yang kompleks, tidak ada database, tidak ada $this->artisan(...). Cuma buat instance, panggil method, cek hasilnya. Sesederhana itu.
Mental model: “Kalau logika ini dipanggil sendirian, apakah hasilnya benar?”
🌐 Feature Test — “Tes Satu Fitur dari Mata User”
Analoginya
Kembali ke analogi mesin kopi — setelah semua komponen lulus tes, sekarang kamu rangkai semuanya dan tes: “Kalau aku masukin kopi dan air, apakah kopinya keluar dengan benar?”
Kamu tidak lagi cek satu komponen sendirian. Kamu cek satu alur dari awal sampai akhir — seperti yang dilakukan user sungguhan.
Feature Test di Laravel bekerja seperti itu. Dia mensimulasikan HTTP request dari luar, melewati semua lapisan — middleware, validation, controller, service — dan mengecek apakah responsenya benar.
Karakteristiknya
- Mensimulasikan HTTP request (tapi tidak benar-benar lewat network)
- Menyentuh database (dengan
RefreshDatabasetrait, database di-reset tiap test) - Tidak bergantung pada service eksternal — kalau ada, di-mock
- Lebih lambat dari Unit Test, tapi masih jauh lebih cepat dari manual testing
Kodenya
php artisan make:test DiscountApiTest
📁 tests/Feature/DiscountApiTest.php
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DiscountApiTest extends TestCase
{
use RefreshDatabase;
public function test_discount_api_returns_correct_final_price(): void
{
$response = $this->postJson('/api/discount', [
'price' => 100,
'discount' => 10,
]);
$response
->assertStatus(200)
->assertJson([
'final' => 90,
]);
}
public function test_discount_api_requires_price(): void
{
$response = $this->postJson('/api/discount', [
'discount' => 10,
// 'price' sengaja tidak dikirim
]);
// Validasi harus reject request ini
$response->assertStatus(422);
}
public function test_authenticated_user_can_access_discount(): void
{
$user = User::factory()->create();
// Simulasi user sudah login
$response = $this->actingAs($user)
->postJson('/api/discount', [
'price' => 200,
'discount' => 25,
]);
$response
->assertStatus(200)
->assertJson(['final' => 150]);
}
}
📌 Yang ditest bukan hanya angkanya — tapi juga bahwa route-nya benar, middleware berjalan, validasi bekerja, dan response punya format yang tepat. Satu test bisa menangkap banyak hal sekaligus.
Mental model: “Kalau user memakai fitur ini, apakah semuanya berhasil?”
🔗 Integration Test — “Tes Apakah Komponen Bisa Kerja Bareng”
Analoginya
Mesin kopi sudah beres. Tapi sekarang kamu sambungkan ke sistem kelistrikan rumah, ke sistem filter air, dan ke aplikasi timer otomatis. Pertanyaannya berbeda: “Apakah semua sistem ini bisa bekerja bersama dengan benar?”
Integration Test bukan lagi soal apakah satu fitur berjalan dari sudut pandang user. Ini soal apakah komponen nyata — database sungguhan, Redis sungguhan, queue sungguhan — bisa bekerja bersama tanpa ada yang salah konfigurasi.
Karakteristiknya
- Menggunakan database nyata (bukan mock)
- Bisa menyentuh Redis, Queue, atau service eksternal di environment sandbox
- Menangkap error konfigurasi yang tidak terlihat di Unit dan Feature Test
- Paling lambat dari tiga jenis test
- Tidak harus lewat HTTP — bisa langsung tes service dan model
Kodenya
php artisan make:test OrderIntegrationTest
📁 tests/Feature/Integration/OrderIntegrationTest.php
<?php
namespace Tests\Feature\Integration;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class OrderIntegrationTest extends TestCase
{
use RefreshDatabase;
public function test_order_is_saved_to_database_with_correct_final_price(): void
{
$user = User::factory()->create();
$this->actingAs($user)->postJson('/api/orders', [
'price' => 100,
'discount' => 10,
]);
// Cek database secara langsung — apakah data benar-benar tersimpan?
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
'final_price' => 90,
]);
}
public function test_order_count_increases_after_creation(): void
{
$user = User::factory()->create();
$this->actingAs($user)->postJson('/api/orders', [
'price' => 100,
'discount' => 10,
]);
// Cek jumlah record di database
$this->assertDatabaseCount('orders', 1);
}
}
Ketika postJson('/api/orders', [...]) dieksekusi, yang sebenarnya terjadi adalah seluruh pipeline Laravel berjalan sungguhan:
Test
↓
HTTP Request (fake, tapi melewati pipeline nyata)
↓
Route → Middleware (auth, validation, dll)
↓
Controller → Service (DiscountService)
↓
Model (Order) → Database (insert sungguhan)
↓
assertDatabaseHas() → cek hasil akhir di database nyata
Tidak ada satu komponen pun yang di-mock. Semua bekerja sungguhan — dan itulah yang membuat ini disebut Integration Test.
Mental model: “Apakah komponen-komponen ini benar-benar bisa kerja bareng?”
🗺️ Tiga Jenis Test, Satu Strategi
Ketiganya bukan pilihan — ketiganya dipakai bersama dengan urutan yang logis.
Step 1 — Unit Test
"Logic-nya benar tidak, kalau dipanggil sendirian?"
↓
Step 2 — Feature Test
"Fitur-nya jalan tidak, dari sudut pandang user?"
↓
Step 3 — Integration Test
"Semua komponennya terhubung dengan benar?"
| Unit Test | Feature Test | Integration Test | |
|---|---|---|---|
| Kecepatan | ⚡ Sangat cepat | 🏃 Sedang | 🐢 Paling lambat |
| Database | ❌ Tidak | ✅ (refresh) | ✅ Nyata |
| HTTP | ❌ Tidak | ✅ Simulasi | ✅ Simulasi |
| Mock? | ✅ Semua dep. | ✅ External saja | ❌ Tidak |
| Lokasi | tests/Unit/ | tests/Feature/ | tests/Feature/Integration/ |
| Fokus | Logic | Alur fitur | Integrasi sistem |
Cara Menjalankan Test
# Semua test
php artisan test
# Satu file spesifik
php artisan test tests/Unit/DiscountServiceTest.php
# Satu method spesifik
php artisan test --filter=test_discount_is_applied_correctly
# Dengan output detail
php artisan test --debug
🎭 Mocking Service — “Palsu, Tapi Terkontrol”
Masalahnya
Bayangkan fiturmu bergantung pada Midtrans untuk proses pembayaran. Setiap kali test dijalankan, apa yang terjadi?
- Test mengirim request ke server Midtrans yang sungguhan
- Kamu kena charge per transaksi
- Test gagal kalau internet mati atau API Midtrans sedang down
- Hasilnya tidak bisa diprediksi — Midtrans bisa return berbeda tiap waktu
Ini yang disebut non-deterministic — hasil test bergantung pada pihak luar yang tidak bisa kamu kontrol.
Mocking adalah solusinya: ganti service eksternal asli dengan versi palsu selama testing, yang responnya bisa kamu atur sendiri.
Aturan Emas Sebelum Mock
Ada satu kebiasaan yang harus dihindari:
// ❌ JANGAN — HTTP call langsung di Controller, tidak bisa di-mock dengan bersih
public function pay(Request $request)
{
$response = Http::post('https://api.midtrans.com/charge', [...]);
// ...
}
Bungkus semua service eksternal dalam class sendiri:
// ✅ LAKUKAN — bungkus dalam class, lalu mock class-nya
public function pay(Request $request)
{
$result = app(PaymentGateway::class)->charge($request->amount);
// ...
}
Dengan begini, yang di-mock adalah PaymentGateway — bukan jaringan. Test jadi bersih, predictable, dan cepat.
Mocking HTTP Client — Http::fake()
📁 app/Services/PaymentGateway.php
Class asli yang akan di-mock saat testing.
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class PaymentGateway
{
public function charge(int $amount): bool
{
$response = Http::post('https://payment.test/charge', [
'amount' => $amount,
]);
return $response->successful();
}
}
📁 tests/Feature/PaymentTest.php
<?php
namespace Tests\Feature;
use App\Services\PaymentGateway;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class PaymentTest extends TestCase
{
public function test_payment_succeeds_when_gateway_returns_200(): void
{
// Instruksikan Http facade untuk tidak benar-benar kirim request
Http::fake([
'payment.test/charge' => Http::response([
'status' => 'success',
], 200),
]);
$gateway = new PaymentGateway();
$result = $gateway->charge(100);
$this->assertTrue($result);
}
public function test_payment_fails_when_gateway_returns_500(): void
{
Http::fake([
'payment.test/charge' => Http::response([
'status' => 'error',
], 500),
]);
$gateway = new PaymentGateway();
$result = $gateway->charge(100);
$this->assertFalse($result);
}
}
📌 Tidak ada HTTP request yang benar-benar dikirim. Http::fake() mengganti HTTP Client di Service Container dengan Fake Client — Guzzle tidak pernah dipanggil, DNS tidak pernah resolve, tidak ada traffic keluar sama sekali.
Bagaimana Http::fake() Bekerja di Balik Layar
Http adalah Facade — bukan class biasa, tapi proxy ke service yang terdaftar di Service Container. Alurnya normal:
Http::post() → Facade → HttpFactory → Guzzle → Network → Server
Saat Http::fake() dipanggil, Laravel mengganti HttpFactory di Container dengan versi palsu:
Http::post() → Facade → HttpFactory (FAKE MODE) → Fake Response → STOP
Guzzle tidak pernah disentuh. Inilah kenapa Http::fake() bisa ada — karena Laravel memang merancang Http Client untuk bisa ditest dengan cara ini.
Fake Facade Lainnya
Laravel menyediakan mekanisme ::fake() untuk Facade-Facade yang sering dipakai saat testing:
// Pastikan email tidak benar-benar terkirim
Mail::fake();
// Pastikan notifikasi tidak benar-benar dikirim
Notification::fake();
// Pastikan job tidak benar-benar diqueue
Queue::fake();
// Pastikan event tidak benar-benar di-fire
Event::fake();
// Pastikan job bus tidak benar-benar dispatch
Bus::fake();
// Pastikan HTTP request tidak benar-benar keluar
Http::fake();
Setelah memanggil fake(), kamu bisa assert bahwa sesuatu seharusnya terjadi — tanpa benar-benar menjalankannya:
public function test_welcome_email_is_sent_after_registration(): void
{
Mail::fake();
$this->postJson('/api/register', [
'name' => 'Budi',
'email' => 'budi@mail.com',
'password' => 'secret123',
]);
// Cek bahwa WelcomeMail memang di-queue untuk dikirim ke Budi
Mail::assertQueued(WelcomeMail::class, function ($mail) {
return $mail->hasTo('budi@mail.com');
});
}
public function test_order_job_is_dispatched_after_payment(): void
{
Queue::fake();
$this->postJson('/api/pay', ['order_id' => 1]);
// Cek bahwa ProcessOrder memang masuk antrian
Queue::assertPushed(ProcessOrder::class);
}
⚠️ Kesalahan yang Sering Terjadi
Test meniru logika kode yang ditest. Ini yang paling sering. Kalau DiscountService menghitung $price - ($price * $discount / 100), test tidak boleh nulis hal yang sama untuk “membuktikan” hasilnya. Test cukup bilang: input 100 dan 10, output harus 90. Itu saja.
Semua ditest lewat Feature Test. Unit Test sangat cepat dan sangat terfokus. Kalau kamu malas bikin Unit Test dan langsung lompat ke Feature Test untuk semua hal, test suite kamu akan makin lambat dan sulit dilacak mana yang gagal kenapa.
External service tidak di-mock di Feature Test. Feature Test tidak seharusnya benar-benar mengirim email atau memanggil API berbayar. Selalu mock service eksternal di Feature Test — simpan Integration Test dengan service nyata untuk environment yang memang disiapkan untuk itu.
Tidak memakai RefreshDatabase. Kalau lupa pakai trait ini di Feature dan Integration Test, data dari satu test bisa mengotori test berikutnya dan hasilnya jadi tidak bisa dipercaya.
💡 Ingat!
Testing bukan soal menulis kode tambahan yang membuang waktu. Testing adalah percakapan dengan kode masa depan — versi kamu enam bulan lagi yang lupa kenapa logic ini ditulis seperti ini.
Kalau test hijau semua setelah refactor besar, kamu bisa tidur nyenyak. Kalau merah, kamu tahu persis apa yang pecah — bukan nunggu user yang laporan duluan.
Dan satu hal yang tidak bisa digantikan oleh test manapun: keputusan desain yang baik. Logic yang tertanam di Controller tidak bisa diunit-test. Service eksternal yang di-call langsung tanpa dibungkus tidak bisa di-mock dengan bersih. Testing yang mudah ditulis adalah sinyal bahwa arsitektur kodenya sudah baik — dan testing yang susah ditulis seringkali adalah sinyal sebaliknya.