Strategi test Laravel untuk cegah flaky dan regresi di CI tidak cukup hanya dengan menambah jumlah test. Masalah utama biasanya ada pada desain suite: terlalu banyak test berat, state antar test bocor, dependency eksternal tidak diisolasi, atau assertion bergantung pada waktu dan data yang tidak deterministik.
Untuk developer Laravel, target yang realistis adalah test suite yang cepat, stabil, dan cukup ketat untuk menahan regresi sebelum merge. Caranya adalah membagi jenis test dengan jelas, memakai fake atau mock pada boundary yang tepat, memilih database test sesuai kebutuhan, dan menulis setup yang tidak meninggalkan side effect. Artikel ini fokus ke praktik yang langsung bisa diterapkan di project Laravel tanpa melebar ke pembahasan CI provider tertentu.
Piramida tes di Laravel: unit, integration, feature, dan smoke test
Banyak suite Laravel melambat dan menjadi flaky karena hampir semua skenario ditaruh di feature test yang menyentuh database, queue, event, dan HTTP sekaligus. Pendekatan ini memang terasa mudah di awal, tetapi mahal dalam jangka panjang. Lebih aman jika test dibagi menurut level risiko dan biaya eksekusi.
1. Unit test
Gunakan unit test untuk logika murni yang tidak perlu boot framework penuh atau akses database. Contohnya: perhitungan harga, policy kecil, formatter, parser, mapper, dan service domain yang dependency-nya bisa di-stub.
- Cepat dijalankan.
- Paling kecil peluang flaky jika benar-benar terisolasi.
- Cocok untuk edge case yang banyak.
Jika class masih membutuhkan container Laravel atau model Eloquent aktif, kemungkinan itu bukan unit test murni lagi.
2. Integration test
Integration test memverifikasi interaksi antar komponen internal, misalnya repository dengan database, job dengan queue payload, atau service yang menulis ke cache dan database. Ini berguna ketika unit test terlalu sempit, tetapi feature test terlalu mahal.
Di Laravel, integration test sering tidak dibedakan secara formal, tetapi secara struktur sebaiknya dipisah agar mudah diketahui biaya dan cakupannya.
3. Feature test
Feature test memverifikasi alur aplikasi dari sisi HTTP, command, middleware, authorization, validasi, dan persistence. Ini adalah tulang punggung banyak aplikasi Laravel karena framework menyediakan API test yang nyaman.
Namun feature test sebaiknya hanya meng-cover skenario bernilai tinggi, misalnya:
- user membuat order,
- admin mengubah status invoice,
- API menolak request tanpa scope,
- controller memicu job/event yang benar.
Jangan ulangi seluruh kombinasi edge case domain di level ini jika sudah tercakup di unit atau integration test.
4. Smoke test
Smoke test adalah lapisan tipis untuk memastikan jalur paling kritis aplikasi tetap hidup. Biasanya jumlahnya sedikit dan assertion-nya minimal. Contoh:
- homepage atau health endpoint merespons sukses,
- login page bisa diakses,
- endpoint API utama tidak melempar error 500,
- artisan command penting dapat dieksekusi.
Smoke test berguna sebagai early signal untuk regresi fatal tanpa biaya besar.
Distribusi yang masuk akal
Tidak ada rasio absolut, tetapi secara praktis:
- Mayoritas ada di unit test dan integration test ringan.
- Feature test fokus pada alur bisnis utama dan boundary framework.
- Smoke test sedikit, cepat, dan dijalankan hampir selalu.
Jika suite Anda didominasi feature test yang semuanya membuat banyak data dan memanggil banyak fake, biasanya itu tanda piramida tes terbalik.
Memilih database nyata vs SQLite in-memory
Pemilihan database test adalah salah satu sumber false confidence terbesar. SQLite in-memory memang sangat cepat, tetapi tidak selalu merepresentasikan perilaku database produksi.
Kapan SQLite in-memory cocok
- Project relatif sederhana.
- Query tidak bergantung pada fitur spesifik engine database produksi.
- Tujuan utama adalah validasi alur dasar dan kecepatan feedback lokal.
- Anda tidak mengandalkan behavior transaksi, locking, tipe kolom, atau constraint yang berbeda antar engine.
Untuk unit test dan sebagian feature test ringan, ini sering cukup.
Kapan lebih aman memakai database nyata
Pakai engine yang sama atau sedekat mungkin dengan produksi jika Anda menguji hal-hal berikut:
- query builder atau raw SQL yang tidak trivial,
- constraint unik dan foreign key yang sensitif,
- sorting/filtering yang bergantung kollation atau tipe data,
- JSON query, full-text, lock, transaction semantics,
- bug sebelumnya pernah muncul hanya di MySQL/PostgreSQL tetapi lolos di SQLite.
SQLite bisa berbeda dalam penanganan tipe, default value, strictness, dan fitur SQL tertentu. Jika regresi sering lolos dari test lokal tetapi gagal di staging/produksi, penyebabnya sering di sini.
Prinsip praktis
- Gunakan SQLite in-memory untuk feedback cepat bila test tidak sensitif terhadap perbedaan engine.
- Gunakan database nyata untuk integration/feature test yang memverifikasi query dan persistence penting.
- Jangan campur asumsi: jika test ingin memverifikasi perilaku database spesifik, jangan jalankan di SQLite lalu merasa aman.
Trait database yang umum dipakai
Untuk banyak kasus Laravel, RefreshDatabase adalah pilihan aman karena menjaga state per test tetap bersih. Bila Anda mencoba optimasi dengan transaksi atau reuse state, pastikan tidak ada kebocoran data antar test.
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('can create an order', function () {
$response = $this->postJson('/api/orders', [
'product_id' => 10,
'quantity' => 2,
]);
$response->assertCreated();
$this->assertDatabaseHas('orders', [
'product_id' => 10,
'quantity' => 2,
]);
});Jika suite sudah besar, pertimbangkan pemisahan grup test cepat dan test yang membutuhkan database nyata. Tujuannya bukan menambah kompleksitas, melainkan menjaga feedback loop tetap sehat.
Isolasi dependency agar test tidak flaky
Flaky test di Laravel paling sering berasal dari dependency yang diam-diam berubah antar eksekusi: waktu, cache, queue asynchronous, event listener, mail, notification, dan HTTP call ke layanan eksternal. Prinsip utamanya: jangan biarkan test bergantung pada dunia luar atau state global yang tidak dikontrol.
Isolasi waktu
Test yang memeriksa expired token, jadwal, window rate limit, atau timestamp mudah gagal jika memakai waktu sistem secara langsung. Bekukan waktu di awal test agar hasil konsisten.
use Carbon\Carbon;
it('marks invoice as overdue after due date', function () {
Carbon::setTestNow('2025-01-15 10:00:00');
$invoice = Invoice::factory()->create([
'due_at' => now()->subDay(),
'status' => 'pending',
]);
$invoice->refreshStatus();
expect($invoice->fresh()->status)->toBe('overdue');
Carbon::setTestNow();
});Hal penting:
- Reset waktu setelah test bila tidak memakai helper/teardown terpusat.
- Hindari assertion timestamp yang terlalu presisi jika tidak perlu.
- Jangan membandingkan string tanggal mentah jika format/cast bisa berubah; lebih aman bandingkan objek waktu atau status bisnisnya.
Cache
Cache sering menjadi sumber kebocoran state antar test, terutama jika store yang dipakai sama dengan environment lokal. Jika test memverifikasi cache hit/miss, pastikan key unik atau store dibersihkan.
- Jangan gunakan key statis yang dipakai banyak test.
- Jika test tidak sedang menguji cache, hindari assertion yang bergantung pada isi cache.
- Pastikan environment test memakai store yang aman untuk di-reset.
Queue
Untuk sebagian besar feature test, Anda tidak perlu menjalankan worker sungguhan. Yang ingin diverifikasi biasanya adalah job ter-dispatch dengan payload yang benar, bukan bahwa worker eksternal sedang hidup.
use Illuminate\Support\Facades\Queue;
it('dispatches invoice email job after payment', function () {
Queue::fake();
$response = $this->postJson('/api/payments', [
'invoice_id' => 123,
'method' => 'bank_transfer',
]);
$response->assertOk();
Queue::assertPushed(SendInvoiceEmail::class, function ($job) {
return $job->invoiceId === 123;
});
});Pakai queue sungguhan hanya jika Anda memang menguji perilaku eksekusi job, retry, atau interaksi dengan persistence yang terjadi di dalam job. Itu lebih cocok di integration test terpisah.
Event
Event dan listener dapat memperluas side effect tanpa terlihat di test. Jika tujuan test adalah memverifikasi satu flow utama, fake event bisa mengurangi noise. Tetapi hati-hati: terlalu banyak fake dapat menyembunyikan bug integrasi.
- Fake event jika hanya ingin memastikan event dipancarkan.
- Jangan fake jika listener adalah bagian penting dari outcome yang sedang diuji.
use Illuminate\Support\Facades\Event;
it('emits order placed event', function () {
Event::fake();
$this->postJson('/api/orders', [
'product_id' => 10,
'quantity' => 1,
])->assertCreated();
Event::assertDispatched(OrderPlaced::class);
});Mail dan notification
Mail dan notification hampir selalu sebaiknya di-fake pada feature test. Anda cukup pastikan pesan terkirim ke target yang benar dengan data yang relevan.
use Illuminate\Support\Facades\Mail;
it('sends welcome email after registration', function () {
Mail::fake();
$this->post('/register', [
'name' => 'Rina',
'email' => '[email protected]',
'password' => 'secret-password',
'password_confirmation' => 'secret-password',
]);
Mail::assertSent(WelcomeMail::class, function ($mail) {
return $mail->hasTo('[email protected]');
});
});HTTP fake untuk API eksternal
Jangan biarkan test memanggil API sungguhan. Selain lambat, hasilnya bergantung pada jaringan, rate limit, dan data layanan luar. Gunakan HTTP fake dan uji dua hal: request yang dikirim serta respons yang ditangani.
use Illuminate\Support\Facades\Http;
it('stores shipment tracking number from courier API', function () {
Http::fake([
'courier.example.com/*' => Http::response([
'tracking_number' => 'TRX-001',
'status' => 'created',
], 200),
]);
$service = app(ShipmentService::class);
$tracking = $service->createShipment(['order_id' => 1]);
expect($tracking)->toBe('TRX-001');
Http::assertSent(fn ($request) => str_contains($request->url(), '/shipments'));
});Kesalahan umum adalah hanya mem-fake response sukses. Tambahkan juga test untuk timeout, 4xx/5xx, payload tidak valid, dan retry policy jika memang ada.
Mencegah flaky dari race condition dan data tidak deterministik
Tidak semua flaky test disebabkan dependency eksternal. Banyak juga yang muncul dari asumsi urutan eksekusi, data acak, dan query yang hasilnya tidak dijamin stabil.
Hindari asumsi urutan tanpa explicit order
Jika assertion Anda mengandalkan item pertama atau terakhir dari query, pastikan query memiliki order by yang jelas. Tanpa itu, hasil bisa berbeda antar database atau antar eksekusi.
- Jangan assert item index pertama jika query tidak mengurutkan data.
- Gunakan ID atau field unik untuk memverifikasi record yang benar.
- Jika response JSON berupa list, pertimbangkan assertion berbasis subset, bukan urutan, kecuali urutan memang bagian dari kontrak API.
Waspadai data factory yang terlalu acak
Factory dengan faker membantu variasi data, tetapi randomness yang tidak terkendali dapat memunculkan collision atau perilaku berbeda. Contoh umum:
- email unik bentrok karena test paralel,
- tanggal random membuat status bisnis berubah,
- teks random memicu validasi panjang/string edge case yang tidak disengaja.
Lebih aman gunakan data eksplisit untuk field yang memengaruhi logika inti, dan gunakan faker hanya untuk field dekoratif.
Race condition pada queue dan event
Masalah ini sering muncul ketika test mengasumsikan side effect asynchronous sudah selesai padahal belum. Jika suatu proses berjalan di queue, ada dua pendekatan yang lebih aman:
- di feature test, assert bahwa job/event ter-dispatch,
- di integration test terpisah, jalankan job dan verifikasi efeknya.
Jangan menulis satu test besar yang mengandalkan queue berjalan otomatis kecuali environment test memang disiapkan khusus untuk itu.
Jangan pakai sleep sebagai solusi
Menambah sleep() sering membuat test tampak lulus, tetapi akar masalah tetap ada dan suite menjadi lambat. Jika Anda tergoda menambah sleep, biasanya ada dependency asynchronous atau state yang belum diisolasi dengan benar.
Pola setUp dan tearDown yang aman
State global yang tidak dipulihkan adalah penyebab klasik flaky test. Waktu yang dibekukan, facade fake, config override, singleton yang sudah resolve, dan cache yang tertinggal bisa memengaruhi test berikutnya.
Prinsip dasar
- Setup hanya yang benar-benar dibutuhkan test.
- Jangan taruh terlalu banyak state bersama di base test class.
- Setiap override global harus punya mekanisme reset.
- Hindari dependency tersembunyi dari test lain.
Contoh base test yang minimal
<?php
namespace Tests;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
}Contoh di atas sederhana tetapi berguna: jika ada test yang lupa me-reset waktu, teardown tetap membersihkannya. Prinsip yang sama bisa diterapkan untuk state global lain jika memang diperlukan.
Kesalahan yang sering terjadi
- Membuat data mahal di
setUp()untuk semua test, padahal hanya sebagian kecil yang butuh. - Menggunakan property statis untuk menyimpan model hasil create lalu dimodifikasi antar test.
- Mengubah config global tanpa mengembalikannya.
- Menjalankan seed besar di semua test sehingga suite lambat dan sulit ditelusuri.
Lebih baik buat helper kecil yang eksplisit dipanggil hanya oleh test yang memerlukannya.
Desain suite Laravel yang cepat tetapi stabil
Kecepatan penting, tetapi optimasi yang salah justru menaikkan flaky rate. Tujuannya adalah mengurangi biaya test berat, bukan mengorbankan isolasi.
Pisahkan suite menurut tujuan
Struktur yang umum dan mudah dipahami:
tests/
Unit/
Domain/
Support/
Integration/
Repositories/
Jobs/
Services/
Feature/
Auth/
Orders/
Billing/
Api/
Smoke/
HealthCheckTest.php
CriticalRoutesTest.phpPemisahan ini membantu reviewer melihat biaya test dan membantu tim menentukan gate sebelum merge.
Naming test yang jelas
Gunakan nama yang menjelaskan perilaku, bukan implementasi internal. Contoh:
it_creates_order_and_dispatches_confirmation_jobit_rejects_payment_when_invoice_is_already_paidit_marks_invoice_overdue_after_due_date
Nama seperti testOrder atau testApiSuccess terlalu kabur dan menyulitkan diagnosis saat gagal di CI.
Pest atau PHPUnit?
Keduanya bisa dipakai dengan baik. Pilih yang paling konsisten dengan tim.
- Pest biasanya lebih ringkas dan enak dibaca untuk behavior-driven flow.
- PHPUnit terasa lebih eksplisit dan familiar untuk struktur class tradisional.
Yang lebih penting dari tool adalah disiplin isolasi state, factory yang deterministik, dan assertion yang tepat sasaran. Jangan ubah style framework test jika itu justru menambah kebisingan pada codebase.
Kurangi biaya feature test
- Uji happy path utama dan beberapa failure path penting, bukan semua kombinasi.
- Pindahkan edge case domain ke unit test.
- Fake dependency eksternal di boundary.
- Buat data sesedikit mungkin untuk memicu skenario.
Contoh kasus umum yang sering menyebabkan flaky
Kasus 1: assert email terkirim setelah queue async
Masalah: test memanggil endpoint lalu langsung assert email terkirim, padahal pengiriman dilakukan lewat job asynchronous.
Perbaikan: di feature test, fake queue dan assert job dikirim. Lalu buat integration test untuk job yang memverifikasi mail terkirim saat job dijalankan.
Kasus 2: test gagal hanya menjelang tengah malam
Masalah: logika due date memakai now() langsung sehingga perilaku berubah tergantung jam eksekusi CI.
Perbaikan: freeze waktu dalam test dan gunakan tanggal eksplisit pada factory/data setup.
Kasus 3: response list kadang urutannya berubah
Masalah: test mengasumsikan urutan default query.
Perbaikan: tambahkan sorting yang eksplisit di query atau ubah assertion agar tidak bergantung pada posisi elemen jika urutan bukan kontrak fitur.
Kasus 4: test lolos di SQLite tetapi gagal di produksi
Masalah: query atau constraint spesifik database tidak terwakili di SQLite.
Perbaikan: pindahkan skenario tersebut ke suite yang berjalan di database nyata.
Checklist review PR untuk kualitas test
Gunakan checklist ini saat review agar flaky dan regresi bisa ditahan lebih awal.
- Apakah perubahan bisnis inti memiliki test di level yang tepat: unit, integration, atau feature?
- Apakah test baru bergantung pada waktu sistem? Jika ya, apakah waktu di-freeze?
- Apakah ada HTTP call eksternal, mail, event, notification, atau queue yang seharusnya di-fake?
- Apakah assertion bergantung pada urutan data tanpa sorting eksplisit?
- Apakah factory menggunakan data random untuk field penting yang sebaiknya eksplisit?
- Apakah test menyentuh database dengan engine yang sesuai untuk risiko yang diuji?
- Apakah ada state global yang mungkin bocor: cache, config, singleton, time travel, facade fake?
- Apakah test terlalu besar dan seharusnya dipecah menjadi verifikasi dispatch + verifikasi eksekusi?
- Apakah nama test menjelaskan perilaku yang dijaga dari regresi?
- Apakah test akan tetap stabil jika dijalankan sendiri, berulang, atau paralel?
Rekomendasi test gate sebelum merge
Test gate yang baik bukan sekadar “jalankan semua”. Gate harus memberi feedback cepat sekaligus menjaga area kritis tetap terlindungi.
Gate minimum yang praktis
- Smoke test harus lulus.
- Unit test harus lulus seluruhnya.
- Integration test penting untuk persistence, job, dan service kritis harus lulus.
- Feature test untuk alur bisnis utama dan endpoint sensitif harus lulus.
Pola yang sehat untuk tim
- PR kecil: jalankan smoke + unit + feature/integration terkait area perubahan.
- Sebelum merge ke branch utama: jalankan seluruh gate wajib.
- Untuk area sensitif seperti billing, auth, atau permission: jangan kompromi pada test database nyata jika memang dibutuhkan.
Jika suite penuh terlalu lambat, solusi utamanya adalah merapikan stratifikasi test dan mengurangi test berat yang duplikatif, bukan menurunkan gate sampai regresi mudah lolos.
Penutup
Strategi test Laravel yang efektif untuk mencegah flaky dan regresi di CI berangkat dari desain, bukan dari jumlah assertion. Pisahkan level test dengan disiplin, gunakan database yang sesuai dengan risiko, isolasi dependency seperti waktu dan HTTP eksternal, dan hindari state global yang bocor antar test.
Jika Anda ingin satu aturan praktis untuk mulai merapikan suite hari ini, gunakan ini: uji logika di level serendah mungkin, uji integrasi hanya di boundary yang penting, dan buat setiap test deterministik. Dengan begitu, pipeline akan lebih dapat dipercaya dan hasil gagal di CI lebih mudah ditindaklanjuti, bukan diabaikan sebagai flaky biasa.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!