Flaky test database di Laravel hampir selalu punya pola yang sama: test kadang lolos di laptop, kadang gagal di CI, lalu sulit direproduksi. Penyebab umumnya bukan karena framework, melainkan karena state yang tidak benar-benar terisolasi, waktu yang berubah-ubah, proses asinkron yang tidak dikendalikan, dan assertion yang terlalu bergantung pada detail yang tidak stabil.

Kalau tujuan Anda adalah membuat suite test Laravel lebih konsisten di CI dan lokal, fokuslah pada beberapa hal inti: reset database dengan benar, kendalikan queue dan event, bekukan waktu saat perlu, buat factory yang deterministik, dan tulis assertion yang memeriksa perilaku penting, bukan kebetulan implementasi. Artikel ini membahas langkah praktis yang bisa langsung diterapkan.

Pola gejala flaky test database di Laravel

Sebelum memperbaiki, kenali dulu gejala yang biasanya muncul.

  • Test gagal hanya jika dijalankan bersama test lain, tetapi lolos saat dijalankan sendiri.
  • Gagal hanya di CI, terutama saat mode paralel aktif.
  • Assertion jumlah baris di database kadang berbeda satu atau dua.
  • Urutan data berubah-ubah karena query tanpa order by yang eksplisit.
  • Test yang bergantung pada timestamp atau scheduler gagal pada jam tertentu.
  • Job, listener, atau notification benar-benar berjalan saat test tidak mengharapkannya.

Jika sebuah test hanya stabil ketika dijalankan sendirian, hampir pasti ada kebocoran state atau ketergantungan tersembunyi terhadap urutan eksekusi.

Penyebab umum flaky test database

1. State database bocor antar test

Ini penyebab paling sering. Satu test membuat data, test berikutnya diam-diam membaca data itu. Masalah ini sering muncul jika ada test yang tidak memakai mekanisme reset database secara konsisten, atau ada proses di luar transaksi test yang tetap menulis ke database.

Contoh gejala:

  • Assertion assertDatabaseCount gagal karena ada baris tambahan dari test sebelumnya.
  • Unique constraint gagal secara acak karena email atau slug yang sama sudah ada.

2. Ketergantungan waktu

Waktu sistem berubah terus. Jika kode bisnis memeriksa now(), jatuh tempo, jam operasional, atau timestamp tertentu, hasil test bisa berbeda berdasarkan waktu eksekusi. Bahkan selisih beberapa milidetik dapat memengaruhi query created_at atau expiry logic.

3. Race condition

Race condition muncul ketika dua proses atau dua langkah test memodifikasi resource yang sama tanpa sinkronisasi yang jelas. Di level aplikasi, ini sering berkaitan dengan job, listener, cache, lock, atau operasi database yang diasumsikan urut padahal tidak.

4. Queue, event, dan notification tidak terkontrol

Dalam test, side effect yang berjalan otomatis sering membuat hasil menjadi tidak konsisten. Misalnya setelah membuat model, listener mengirim job yang kemudian menulis lagi ke database. Jika test hanya ingin memverifikasi respons endpoint, side effect ini justru menambah noise.

5. Urutan eksekusi test

Test yang baik harus independen. Jika test A harus jalan dulu agar test B lolos, berarti ada coupling yang berbahaya. Penyebabnya bisa berupa singleton yang belum di-reset, cache, static property, atau data database yang tersisa.

6. Factory yang tidak deterministik

Factory dengan data acak memang nyaman, tetapi acak yang tidak terkendali bisa memicu test flakey. Contohnya:

  • Factory membuat tanggal acak yang kadang masuk rentang valid, kadang tidak.
  • Factory membuat relasi berbeda tergantung urutan eksekusi.
  • Nilai acak dipakai dalam assertion yang mengasumsikan format atau urutan tertentu.

Strategi utama: isolasi database dengan benar

Pilih default yang aman: RefreshDatabase

Untuk mayoritas test aplikasi Laravel, RefreshDatabase adalah pilihan aman karena memastikan database berada pada state yang bersih antar test. Trait ini cocok ketika Anda ingin konsistensi lebih penting daripada optimasi mikro.

use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('membuat order baru', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)->post('/orders', [
        'product_id' => 10,
        'quantity' => 2,
    ]);

    $response->assertCreated();
    $this->assertDatabaseHas('orders', [
        'user_id' => $user->id,
        'product_id' => 10,
        'quantity' => 2,
    ]);
});

Mengapa ini membantu? Karena setiap test memulai dari state yang terprediksi. Anda tidak perlu mengandalkan bahwa test sebelumnya membersihkan dirinya sendiri.

Kapan DatabaseTransactions berguna

DatabaseTransactions biasanya lebih cepat karena membungkus test dalam transaksi lalu melakukan rollback. Pendekatan ini cocok jika seluruh operasi test benar-benar berada dalam koneksi yang sama dan tidak ada proses lain yang menulis ke database di luar transaksi tersebut.

use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(DatabaseTransactions::class);

it('mengubah status invoice', function () {
    $invoice = Invoice::factory()->create(['status' => 'draft']);

    $this->patch("/invoices/{$invoice->id}", [
        'status' => 'paid',
    ])->assertOk();

    $this->assertDatabaseHas('invoices', [
        'id' => $invoice->id,
        'status' => 'paid',
    ]);
});

Trade-off: transaksi tidak selalu cukup jika kode Anda memicu proses yang berjalan di koneksi lain, job terpisah, atau mekanisme yang melakukan commit di luar konteks test. Dalam kasus seperti itu, RefreshDatabase sering lebih aman.

Kesalahan umum

  • Mencampur test yang mengandalkan database bersih dengan test yang melakukan seeding besar tanpa reset.
  • Menggunakan seeder global untuk semua test, padahal sebagian besar test hanya butuh 1-2 record.
  • Mengandalkan ID tertentu seperti id = 1, yang mudah patah saat urutan pembuatan data berubah.

Mengontrol side effect: fake untuk queue, event, dan notification

Kalau tujuan test bukan memverifikasi integrasi queue atau event itu sendiri, jangan biarkan side effect berjalan sungguhan. Gunakan fake agar test fokus pada perilaku utama dan hasilnya stabil.

Queue fake

use Illuminate\Support\Facades\Queue;

it('mengantrikan job sinkronisasi setelah order dibuat', function () {
    Queue::fake();

    $user = User::factory()->create();

    $this->actingAs($user)->post('/orders', [
        'product_id' => 10,
        'quantity' => 1,
    ])->assertCreated();

    Queue::assertPushed(SyncOrderToErp::class);
});

Dengan cara ini, test tidak bergantung pada worker queue, timing eksekusi job, atau penulisan database tambahan dari job tersebut.

Event fake

use Illuminate\Support\Facades\Event;

it('memicu event setelah pembayaran sukses', function () {
    Event::fake();

    $payment = Payment::factory()->create(['status' => 'pending']);

    $this->post("/payments/{$payment->id}/confirm")->assertOk();

    Event::assertDispatched(PaymentConfirmed::class);
});

Ini menghindari listener yang mungkin menulis ke database, mengirim email, atau memicu job lain.

Notification fake

use Illuminate\Support\Facades\Notification;

it('mengirim notifikasi invoice jatuh tempo', function () {
    Notification::fake();

    $user = User::factory()->create();
    $invoice = Invoice::factory()->for($user)->create();

    app(SendInvoiceReminder::class)->handle($invoice);

    Notification::assertSentTo($user, InvoiceReminderNotification::class);
});

Kapan jangan memakai fake? Jika Anda memang sedang menulis integration test untuk memverifikasi listener atau job tertentu. Dalam kasus itu, uji unit dan integration test perlu dipisahkan dengan jelas agar ekspektasinya tidak bercampur.

Kontrol waktu agar test tidak berubah berdasarkan jam eksekusi

Jika logika bisnis bergantung pada waktu, bekukan waktu selama test. Intinya sederhana: semua pemanggilan now() dalam test harus menghasilkan nilai yang bisa diprediksi.

use Carbon\Carbon;

it('menandai invoice terlambat setelah jatuh tempo', function () {
    Carbon::setTestNow('2024-01-15 10:00:00');

    $invoice = Invoice::factory()->create([
        'due_at' => '2024-01-10 00:00:00',
        'status' => 'unpaid',
    ]);

    app(MarkOverdueInvoices::class)->handle();

    $this->assertDatabaseHas('invoices', [
        'id' => $invoice->id,
        'status' => 'overdue',
    ]);

    Carbon::setTestNow();
});

Mengapa ini penting? Tanpa kontrol waktu, test bisa gagal saat dijalankan melewati tengah malam, zona waktu berbeda di CI, atau ketika timestamp dibandingkan secara ketat.

Tips tambahan:

  • Jangan assert timestamp persis sampai detik jika tidak perlu.
  • Jika aplikasi sensitif terhadap zona waktu, samakan konfigurasi timezone di lokal dan CI.
  • Hindari memakai sleep() untuk menunggu waktu berubah; biasanya itu tanda desain test yang rapuh.

Factory deterministik dan seeding minimal

Gunakan factory yang eksplisit pada field penting

Data acak berguna untuk variasi, tetapi field yang memengaruhi perilaku test sebaiknya ditentukan jelas.

it('hanya menampilkan order aktif', function () {
    Order::factory()->create(['status' => 'cancelled']);
    Order::factory()->create(['status' => 'active']);

    $response = $this->get('/orders?status=active');

    $response->assertOk();
    $response->assertSee('active');
    $response->assertDontSee('cancelled');
});

Jangan biarkan status dibuat acak jika assertion Anda bergantung pada status tersebut.

Hindari seeding berlebihan

Banyak proyek punya kebiasaan menjalankan seeder besar untuk semua test. Ini membuat suite lebih lambat dan memperbesar peluang coupling antar data. Lebih baik seed hanya data referensi yang benar-benar diperlukan.

  • Seed data statis kecil seperti daftar role atau kode status jika memang dibutuhkan banyak test.
  • Untuk data domain test, buat langsung di test atau factory state agar konteksnya jelas.

Prinsipnya: semakin sedikit data awal, semakin kecil ruang bagi flaky behavior untuk bersembunyi.

Urutan eksekusi dan paralelisme

Jangan pernah mengandalkan urutan test

Jika sebuah test membutuhkan data dari test sebelumnya, test itu salah. Setiap test harus membuat precondition sendiri.

Contoh anti-pattern:

  • Test B mengasumsikan user admin sudah ada karena test A membuatnya.
  • Assertion mengandalkan jumlah total record yang tidak dibuat penuh di dalam test itu sendiri.

Perhatikan test paralel

Saat test dijalankan paralel di CI, masalah isolasi akan lebih cepat terlihat. Database, cache, file sementara, dan queue backend harus terisolasi antar proses jika memang dipakai bersama.

Hal yang perlu dicek saat paralel:

  • Apakah setiap proses test menggunakan database yang terpisah atau state yang diisolasi?
  • Apakah cache dan filesystem test berbagi namespace yang sama?
  • Apakah ada job atau listener yang tetap menulis ke resource global?

Jika test paralel sering gagal secara acak, curigai resource bersama lebih dulu, bukan assertion-nya.

Aturan assertion yang lebih stabil

Banyak flaky test sebenarnya berasal dari assertion yang salah sasaran. Assertion yang stabil memeriksa kontrak perilaku, bukan detail yang mudah berubah.

Gunakan assertion database yang spesifik

Lebih baik memeriksa record penting dengan kondisi spesifik daripada memeriksa seluruh payload atau jumlah total yang rentan berubah.

$this->assertDatabaseHas('orders', [
    'id' => $order->id,
    'status' => 'paid',
]);

Bandingkan dengan assertion yang rapuh seperti:

$this->assertEquals(3, Order::count());

Assertion jumlah total boleh dipakai, tetapi hanya jika test benar-benar mengontrol semua data yang relevan.

Selalu pakai pengurutan eksplisit saat memeriksa daftar

Jika query menghasilkan daftar, jangan mengasumsikan urutan default database. Tambahkan orderBy pada query atau sesuaikan assertion agar tidak bergantung pada urutan.

$orders = Order::query()
    ->where('status', 'active')
    ->orderBy('created_at')
    ->get();

Jangan assert field acak yang tidak relevan

Jika factory menghasilkan nama, slug, atau UUID acak, jangan gunakan field itu sebagai inti assertion kecuali memang sedang diuji.

Contoh setup dasar yang lebih aman

Base test case untuk Laravel

Anda bisa membuat default yang aman di test suite agar tim tidak perlu mengingat semuanya dari nol.

<?php

namespace Tests;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        Queue::fake();
        Event::fake();
        Notification::fake();
    }
}

Namun ada trade-off. Jika semua test otomatis memakai fake, integration test tertentu mungkin kehilangan perilaku nyata yang justru ingin diuji. Solusi praktisnya: pakai fake secara default hanya jika sesuai dengan pola proyek Anda, atau aktifkan fake per test/kelompok test yang memang membutuhkannya.

Contoh Pest dengan beforeEach

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;

uses(RefreshDatabase::class);

beforeEach(function () {
    Queue::fake();
});

it('menyimpan product tanpa memproses job asli', function () {
    $this->post('/products', [
        'name' => 'Keyboard',
        'price' => 250000,
    ])->assertCreated();

    $this->assertDatabaseHas('products', [
        'name' => 'Keyboard',
    ]);
});

Langkah diagnosis bertahap saat test masih flaky

Jangan langsung mengubah banyak hal sekaligus. Diagnosis yang baik dilakukan bertahap agar akar masalah terlihat jelas.

1. Pastikan test gagal saat dijalankan berulang

Jalankan test yang dicurigai berkali-kali. Jika hanya gagal sesekali, Anda sedang berhadapan dengan nondeterminism, bukan bug fungsional biasa.

php artisan test --filter=OrderTest
php artisan test --filter=OrderTest
php artisan test --filter=OrderTest

2. Jalankan test sendirian lalu bersama suite

Jika test lolos sendirian tapi gagal dalam suite penuh, fokus ke kebocoran state, cache, atau urutan eksekusi.

3. Bekukan waktu

Jika test menyentuh created_at, expiry, reminder, scheduler, atau jendela waktu tertentu, bekukan waktu terlebih dahulu. Ini langkah murah yang sering langsung menghilangkan flake.

4. Fake semua side effect yang tidak relevan

Queue, event, notification, mail, dan listener adalah tersangka utama. Nonaktifkan satu per satu untuk melihat kapan test menjadi stabil.

5. Periksa query dan urutan hasil

Cari query tanpa order by, assertion pada jumlah total data, atau penggunaan method seperti first() tanpa jaminan urutan.

6. Audit factory dan seeder

Lihat apakah ada data acak yang memengaruhi logika test. Field yang penting harus eksplisit.

7. Cek resource bersama di CI

Jika gagal hanya di CI, bandingkan:

  • driver database
  • timezone
  • parallel execution
  • cache backend
  • konfigurasi queue

Perbedaan kecil di environment sering cukup untuk memunculkan race condition yang tidak terlihat di lokal.

Checklist review PR untuk mencegah flaky test database

  • Apakah test membuat sendiri semua data yang dibutuhkannya?
  • Apakah test memakai RefreshDatabase atau mekanisme isolasi yang setara?
  • Apakah ada side effect queue, event, notification, atau mail yang seharusnya di-fake?
  • Apakah logika berbasis waktu dibekukan atau dikontrol?
  • Apakah query yang diperiksa punya urutan eksplisit jika hasilnya berupa daftar?
  • Apakah assertion memeriksa hal penting, bukan detail acak?
  • Apakah factory menetapkan field krusial secara eksplisit?
  • Apakah test tetap aman saat dijalankan paralel?
  • Apakah test bisa lolos tanpa bergantung pada seeder besar atau state global?

Workflow verifikasi sebelum merge

Supaya flaky test tidak lolos ke branch utama, gunakan workflow verifikasi yang konsisten.

  1. Jalankan test yang diubah secara lokal beberapa kali, bukan sekali saja.
  2. Jalankan subset terkait, misalnya seluruh file test dalam modul yang sama.
  3. Jalankan suite dengan konfigurasi yang mendekati CI, terutama jika CI memakai database berbeda atau mode paralel.
  4. Tinjau assertion: apakah ada asumsi soal jumlah total data, urutan default, atau waktu saat ini?
  5. Periksa side effect: job, listener, notifikasi, cache, file, dan integrasi eksternal.
  6. Pastikan cleanup dan isolasi benar-benar bekerja, bukan hanya kebetulan lolos.

Jika memungkinkan, tambahkan aturan tim bahwa test baru tidak dianggap siap merge sebelum lolos lebih dari satu kali di lokal dan satu kali di pipeline CI.

Kapan perlu memisahkan jenis test

Tidak semua test harus memverifikasi semua lapisan sekaligus. Untuk mengurangi flake:

  • Feature test: fokus pada alur HTTP, respons, dan perubahan database utama. Fake side effect yang tidak relevan.
  • Unit test: fokus pada aturan bisnis murni tanpa database jika memungkinkan.
  • Integration test terbatas: verifikasi job, listener, atau integrasi database tertentu secara sengaja dan terisolasi.

Pemisahan ini membantu Anda menulis assertion yang lebih tepat dan mengurangi area nondeterministik di suite utama.

Penutup

Menjinakkan flaky test database di Laravel bukan soal menambah retry di CI. Solusi yang benar adalah menghilangkan sumber nondeterminism: reset state database dengan konsisten, kendalikan waktu, fake side effect yang tidak sedang diuji, gunakan factory yang deterministik, dan hindari assertion yang rapuh.

Jika Anda hanya ingin mulai dari langkah paling berdampak, urutannya sederhana: pakai RefreshDatabase, fake queue/event/notification, bekukan waktu saat perlu, dan audit assertion yang bergantung pada urutan atau jumlah total record. Empat langkah itu biasanya sudah menghapus sebagian besar flaky behavior, baik di laptop maupun di CI.