Laravel: menstabilkan test database dengan transaction dan seed minimal pada dasarnya berarti memastikan setiap test berjalan di state yang dapat diprediksi, tidak bocor ke test lain, dan tidak bergantung pada data global yang berubah-ubah. Jika test kadang lolos di lokal tetapi gagal di CI, penyebab paling umum biasanya bukan assertion yang salah, melainkan state yang tidak terisolasi: data sisa test sebelumnya, seed terlalu besar, waktu yang bergerak, nilai acak, queue asinkron, cache, atau event yang memicu efek samping.

Di Laravel, tiga pendekatan yang paling sering dipakai untuk test berbasis database adalah RefreshDatabase, DatabaseTransactions, dan migrasi per-suite. Tidak ada satu pilihan yang paling benar untuk semua kasus. Kuncinya adalah memahami trade-off antara isolasi, performa, dan kesederhanaan debugging, lalu menggabungkannya dengan seed minimal yang deterministik.

Kapan test database menjadi flaky

Flaky test adalah test yang kadang berhasil dan kadang gagal tanpa perubahan kode yang relevan. Pada test database, gejalanya sering terlihat seperti ini:

  • Jumlah record berbeda antara lokal dan CI.
  • Assertion urutan data gagal karena created_at atau nilai acak berubah.
  • Test yang lolos jika dijalankan sendiri, tetapi gagal jika dijalankan bersama suite lain.
  • Record yang seharusnya tidak ada ternyata masih tertinggal dari test sebelumnya.
  • Worker queue, cache, atau listener event membuat efek samping di luar ekspektasi test.

Masalah ini hampir selalu berakar pada satu hal: test tidak benar-benar independen. Solusinya bukan sekadar menambah retry, melainkan membangun test yang menghasilkan state sendiri, seminimal mungkin, lalu dibersihkan secara konsisten.

Memilih strategi isolasi database di Laravel

1. RefreshDatabase

RefreshDatabase umum dipakai karena praktis dan aman untuk banyak skenario. Trait ini memastikan test berjalan pada database yang sudah dimigrasikan dan direfresh sesuai kebutuhan framework. Secara praktis, pendekatan ini cocok ketika:

  • Anda membutuhkan isolasi yang kuat antar test.
  • Test membuat dan mengubah banyak record.
  • Anda ingin perilaku test dekat dengan struktur database sebenarnya.
  • Suite masih cukup kecil atau waktu eksekusi masih wajar.

Contoh PHPUnit:

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class CreateOrderTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_can_create_order(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->postJson('/orders', [
            'sku' => 'ABC-001',
            'qty' => 2,
        ]);

        $response->assertCreated();
        $this->assertDatabaseHas('orders', [
            'user_id' => $user->id,
            'sku' => 'ABC-001',
            'qty' => 2,
        ]);
    }
}

Contoh Pest:

use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

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

    $this->actingAs($user)
        ->postJson('/orders', [
            'sku' => 'ABC-001',
            'qty' => 2,
        ])
        ->assertCreated();

    $this->assertDatabaseHas('orders', [
        'user_id' => $user->id,
        'sku' => 'ABC-001',
    ]);
});

Kelebihan: aman, mudah dipahami, dan cocok untuk kebanyakan integration test. Kekurangan: bisa lebih lambat jika suite besar dan setiap test menuntut setup database yang berat, terutama jika dibarengi seed besar.

2. DatabaseTransactions

DatabaseTransactions membungkus setiap test di dalam transaksi lalu melakukan rollback setelah test selesai. Ini biasanya lebih cepat daripada melakukan refresh state database berulang kali, selama kasus uji Anda kompatibel dengan model transaksi.

Pendekatan ini cocok ketika:

  • Test hanya membutuhkan perubahan data sementara.
  • Anda ingin cleanup yang cepat tanpa migrasi ulang.
  • Interaksi database terjadi pada koneksi yang sama dan tidak bergantung pada proses terpisah.
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;

class UpdateProfileTest extends TestCase
{
    use DatabaseTransactions;

    public function test_user_can_update_profile(): void
    {
        $user = User::factory()->create(['name' => 'Lama']);

        $this->actingAs($user)
            ->patchJson('/profile', ['name' => 'Baru'])
            ->assertOk();

        $this->assertDatabaseHas('users', [
            'id' => $user->id,
            'name' => 'Baru',
        ]);
    }
}

Kelebihan: cepat dan sederhana. Keterbatasan penting:

  • Kurang cocok jika test memicu proses yang berjalan di luar transaksi test, misalnya worker queue terpisah.
  • Bisa membingungkan jika ada beberapa koneksi database dengan transaksi berbeda.
  • Tidak selalu ideal untuk menguji perilaku yang bergantung pada commit nyata ke database.

Jika Anda menguji fitur yang menyalakan job asinkron, listener, atau proses eksternal yang membaca data setelah commit, DatabaseTransactions sering menjadi sumber test yang tampak acak.

3. Migrasi per-suite

Pendekatan migrasi per-suite berarti database test disiapkan sekali di awal suite, lalu tiap test membersihkan state dengan strategi yang lebih terkendali. Ini umum dipakai ketika suite sudah besar dan waktu migrasi ulang menjadi mahal.

Cocok ketika:

  • Skema database besar dan migrasi memakan waktu.
  • Anda punya pipeline CI yang menyiapkan test database sekali di awal job.
  • Tim siap disiplin menjaga isolasi data antar test.

Trade-off-nya jelas: performa bisa lebih baik, tetapi risiko kebocoran state juga naik jika ada test yang tidak membersihkan data dengan benar. Karena itu, pendekatan ini sebaiknya dipadukan dengan transaksi per test atau strategi cleanup yang konsisten.

Aturan praktis: jika prioritas utama Anda adalah kestabilan dan kemudahan debugging, mulai dari RefreshDatabase. Jika suite mulai lambat dan karakter test cocok dengan transaksi, pertimbangkan DatabaseTransactions. Jika suite sudah sangat besar, barulah optimalkan dengan migrasi per-suite secara sadar dan terukur.

Kenapa seed berlebihan membuat test rapuh

Banyak flaky test bermula dari kebiasaan memanggil seeder besar untuk semua test, misalnya seluruh data katalog, role, permission, region, dan puluhan relasi lain, padahal test hanya butuh satu user dan satu order. Masalahnya bukan hanya performa, tetapi juga ketidakjelasan state awal.

Seed besar memicu beberapa risiko:

  • Assertion diam-diam bergantung pada data yang tidak dibuat oleh test itu sendiri.
  • ID, jumlah record, dan relasi bisa berubah saat seeder berkembang.
  • Order query tanpa orderBy menjadi tidak stabil karena data latar belakang bertambah.
  • Test lebih sulit dibaca karena setup tersebar di seeder global.

Prinsip yang lebih aman adalah: buat hanya data yang dibutuhkan test. Jika sebuah test memerlukan role admin, cukup buat role itu saja. Jika butuh satu produk aktif, buat satu produk aktif. Hindari asumsi bahwa database test selalu berisi semua data referensi aplikasi.

Strategi factory, fixture, dan seed minimal yang deterministik

Gunakan factory sebagai sumber setup utama

Untuk sebagian besar test aplikasi Laravel, factory sebaiknya menjadi alat utama membangun state. Factory membuat setup lebih dekat ke kebutuhan test dibanding seeder global.

$user = User::factory()->create([
    'email' => '[email protected]',
]);

$product = Product::factory()->create([
    'sku' => 'ABC-001',
    'price' => 150000,
    'is_active' => true,
]);

Perhatikan bahwa field penting diisi eksplisit. Ini membantu mengurangi kegagalan akibat nilai acak yang berubah dari satu run ke run lain.

Tambahkan state yang jelas pada factory

Jika ada kombinasi data yang sering dipakai, buat state bernama agar setup tetap ringkas tetapi eksplisit.

// contoh konsep factory state
$product = Product::factory()->active()->create();
$user = User::factory()->admin()->create();

Keuntungan pendekatan ini adalah niat test lebih mudah dibaca, dan perubahan aturan domain dapat dipusatkan di factory, bukan tersebar ke banyak file test.

Gunakan fixture kecil untuk data referensi yang benar-benar stabil

Ada kasus di mana data referensi memang konstan, misalnya daftar status internal, role inti, atau konfigurasi domain yang tidak sering berubah. Untuk kasus seperti itu, Anda bisa punya fixture atau seeder kecil yang sangat terbatas.

Contoh penggunaan di setUp() atau helper suite:

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

    $this->seed(RoleSeeder::class);
}

Namun batasi hanya untuk data yang:

  • ukuran kecil,
  • stabil secara domain,
  • dibutuhkan banyak test,
  • tidak menambah relasi kompleks yang tidak perlu.

Hindari assertion yang bergantung pada auto-increment atau total global

Contoh assertion rapuh:

$this->assertEquals(1, User::count());

Assertion ini mudah gagal jika ada setup tambahan di test yang sama atau fixture global. Lebih aman memeriksa record yang relevan:

$this->assertDatabaseHas('users', [
    'email' => '[email protected]',
]);

Jika memang perlu mengecek jumlah, pastikan test sendiri yang mengontrol semua data yang ikut dihitung.

Membuat hasil test konsisten: waktu, random, queue, cache, dan event

Isolasi waktu

Test yang bergantung pada waktu sering gagal karena now() bergerak di antara operasi, atau karena zona waktu berbeda antara lokal dan CI. Solusinya adalah membekukan waktu saat test berjalan.

public function test_invoice_due_date_is_calculated_consistently(): void
{
    $this->freezeTime();

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

    $this->postJson("/invoices/{$invoice->id}/finalize")
        ->assertOk();

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

Selain membekukan waktu, pastikan aplikasi test menggunakan timezone yang konsisten. Jika logika bisnis peka terhadap tanggal lokal, buat assertion yang eksplisit terhadap timezone yang dipakai aplikasi.

Kendalikan nilai acak

Factory sering memakai data acak. Ini berguna untuk variasi, tetapi buruk jika field acak ikut dipakai pada assertion atau query. Untuk test penting, isi field kunci secara eksplisit: email, SKU, kode transaksi, tanggal, status.

Prinsip sederhana: acak untuk noise, eksplisit untuk hal yang diuji.

Gunakan fake untuk queue bila tujuan test bukan worker nyata

Jika Anda hanya ingin memastikan job dikirim, jangan biarkan queue berjalan asinkron. Pakai fake agar test tidak bergantung pada worker, backend queue, atau timing.

use Illuminate\Support\Facades\Queue;

public function test_order_dispatches_sync_job(): void
{
    Queue::fake();

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

    $this->actingAs($user)
        ->postJson('/orders', ['sku' => 'ABC-001', 'qty' => 1])
        ->assertCreated();

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

Kalau yang ingin diuji adalah isi job atau dampak setelah job diproses, uji itu secara terpisah pada level job handler, bukan mengandalkan worker sungguhan di test feature biasa.

Bersihkan atau fake cache

Cache sering menjadi sumber state bocor antar test, terutama jika key tidak unik atau environment CI berbagi service cache yang sama selama satu job. Untuk test yang sensitif terhadap cache, gunakan fake atau reset state dengan jelas.

Masalah umum:

  • Test pertama mengisi cache, test kedua membaca nilai lama.
  • Key cache tidak memasukkan konteks user, tenant, atau environment test.
  • Assertion gagal hanya saat suite penuh dijalankan.

Jika fitur Anda memang menguji perilaku cache, buat test yang eksplisit terhadap key dan lifecycle-nya. Jika tidak, pertimbangkan mem-fake dependensi atau membersihkan cache di setup suite yang relevan.

Fake event jika listener memicu efek samping yang bukan fokus test

Event dan listener sering menulis ke database lain, mengirim notifikasi, atau mendorong queue. Jika test Anda hanya ingin memverifikasi endpoint menyimpan data, event side effect bisa diisolasi.

use Illuminate\Support\Facades\Event;

public function test_user_registration_persists_user(): void
{
    Event::fake();

    $this->postJson('/register', [
        'name' => 'Ayu',
        'email' => '[email protected]',
        'password' => 'secret123',
    ])->assertCreated();

    $this->assertDatabaseHas('users', [
        'email' => '[email protected]',
    ]);
}

Namun hati-hati: jika yang ingin diuji justru integrasi event-listener, jangan mem-fake semuanya. Pisahkan jenis test sesuai tujuan.

Struktur test yang rapi dan mudah dipelihara

Test database lebih stabil jika setup-nya lokal, eksplisit, dan mudah dibaca. Struktur yang disarankan:

  • Satu test, satu skenario utama.
  • Setup di dalam test selama masih ringkas; pindahkan ke helper hanya jika benar-benar berulang.
  • Factory state bernama untuk pola domain yang sering dipakai.
  • Assertion fokus pada perilaku, bukan detail implementasi yang tidak relevan.

Contoh pola yang rapi dengan helper kecil:

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

class SubmitOrderTest extends TestCase
{
    use RefreshDatabase;

    public function test_customer_can_submit_order(): void
    {
        Queue::fake();
        $this->freezeTime();

        $customer = $this->createCustomer();
        $product = Product::factory()->create([
            'sku' => 'ABC-001',
            'stock' => 10,
            'is_active' => true,
        ]);

        $this->actingAs($customer)
            ->postJson('/orders', [
                'sku' => $product->sku,
                'qty' => 2,
            ])
            ->assertCreated();

        $this->assertDatabaseHas('orders', [
            'user_id' => $customer->id,
            'sku' => 'ABC-001',
            'qty' => 2,
        ]);

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

    private function createCustomer(): User
    {
        return User::factory()->create([
            'email' => '[email protected]',
        ]);
    }
}

Polanya jelas: fake dependensi, freeze time, buat data minimum, jalankan aksi, lalu verifikasi hasil.

Checklist diagnosis flaky test database

Saat ada test yang acak gagal, periksa daftar ini secara sistematis:

  1. Apakah test lolos jika dijalankan sendiri tetapi gagal dalam suite penuh? Jika ya, kemungkinan ada kebocoran state.
  2. Apakah test bergantung pada seeder global? Coba ganti dengan factory lokal yang minimum.
  3. Apakah query bergantung pada urutan tanpa orderBy? Tambahkan sorting eksplisit.
  4. Apakah assertion mengandalkan jumlah total record? Ganti ke assertion yang fokus pada record relevan.
  5. Apakah ada field waktu atau tanggal? Bekukan waktu dan cek timezone.
  6. Apakah ada nilai random yang ikut diuji? Isi field penting secara eksplisit.
  7. Apakah test memicu queue, event, mail, notification, atau cache? Fake atau reset sesuai kebutuhan.
  8. Apakah test memakai transaksi tetapi efek samping terjadi di proses lain? Pertimbangkan pindah ke RefreshDatabase.
  9. Apakah ada beberapa koneksi database? Pastikan semuanya terisolasi dan dikonfigurasi untuk test.
  10. Apakah CI dan lokal memakai engine atau konfigurasi DB berbeda? Samakan semaksimal mungkin, terutama collation, timezone, dan strictness.

Trade-off performa versus isolasi

Tidak ada strategi gratis. Semakin kuat isolasi, biasanya semakin besar biaya setup. Ringkasnya:

  • RefreshDatabase: isolasi kuat, paling aman untuk banyak kasus, tetapi bisa lebih lambat.
  • DatabaseTransactions: cepat, ideal untuk banyak test CRUD biasa, tetapi tidak cocok untuk semua efek samping lintas proses.
  • Migrasi per-suite: efisien pada suite besar, tetapi menuntut disiplin tinggi agar state tidak bocor.

Rekomendasi praktis untuk banyak tim:

  1. Mulai dari RefreshDatabase sebagai default integration test.
  2. Kurangi waktu suite bukan dengan seed besar yang dibagi bersama, tetapi dengan mengurangi data setup per test.
  3. Pakai DatabaseTransactions untuk kelompok test yang benar-benar cocok dan sudah dipahami batasannya.
  4. Optimalkan ke level suite hanya setelah ada bukti bottleneck yang nyata.

Dengan kata lain, optimasi performa sebaiknya datang setelah kestabilan dasar tercapai.

Workflow verifikasi sebelum merge

Untuk mencegah regresi test database, tim sebaiknya punya alur verifikasi sederhana sebelum merge:

  1. Jalankan test yang diubah secara lokal beberapa kali, bukan sekali.
  2. Jalankan subset terkait bersama test lain di direktori yang sama untuk mendeteksi kebocoran state.
  3. Pastikan test tidak bergantung pada data dari seeder besar kecuali memang fixture global yang stabil.
  4. Tinjau apakah ada penggunaan now(), random, queue, cache, atau event tanpa kontrol eksplisit.
  5. Jika menambah migration atau mengubah relasi, jalankan suite integration yang menyentuh area itu.
  6. Di CI, gunakan environment test yang konsisten dan jangan berbagi state antar job secara tidak sengaja.

Untuk pull request, reviewer bisa memakai pertanyaan cepat berikut:

  • Apakah test ini membuat data sendiri?
  • Apakah ada side effect yang belum di-fake atau diisolasi?
  • Apakah assertion cukup spesifik terhadap perilaku yang diuji?
  • Apakah pilihan RefreshDatabase atau DatabaseTransactions sudah sesuai karakter test?

Rekomendasi akhir

Jika tujuan Anda adalah mengurangi flaky test dan mencegah regresi pada test database di Laravel, kombinasi yang paling aman biasanya adalah:

  • Gunakan RefreshDatabase sebagai baseline untuk test yang menyentuh database.
  • Bangun state dengan factory, bukan seed besar.
  • Seed hanya data referensi yang benar-benar kecil dan stabil.
  • Bekukan waktu dan isi field penting secara eksplisit.
  • Fake queue, event, dan dependensi asinkron jika itu bukan fokus test.
  • Optimalkan performa belakangan, setelah suite terbukti stabil.

Poin terpentingnya sederhana: test yang baik tidak meminta database test menjadi replika penuh dunia produksi. Test yang baik hanya meminta state minimum yang dibutuhkan untuk membuktikan satu perilaku. Di situlah kestabilan, kecepatan, dan kemudahan maintenance biasanya bertemu.