Testing di Laravel sudah lama nyaman, tetapi kehadiran Pest membuat pengalaman menulis test menjadi lebih ringkas dan mudah dibaca. Yang penting, perubahan ini bukan sekadar soal sintaks yang lebih indah. Dengan struktur yang tepat, Pest membantu tim menjaga kecepatan umpan balik, meningkatkan kepercayaan saat refactor, dan mengurangi regresi pada endpoint API maupun logika bisnis.

Artikel ini membahas strategi testing Laravel modern dengan Pest secara praktis. Fokusnya bukan membandingkan framework test secara dangkal, melainkan bagaimana membangun test suite yang cepat, jelas, dan tidak rapuh. Kita akan membahas struktur test, penggunaan factory, mock dan fake, pengujian database, HTTP test, fake untuk queue/mail/notification, contoh pengujian endpoint API dan service class, serta tips menjaga test tetap cepat di CI.

Mengapa Pest Cocok untuk Laravel Modern

Pest berjalan di atas PHPUnit, sehingga Anda tetap memanfaatkan fondasi ekosistem yang matang. Bedanya, Pest memberi API yang lebih ekspresif, terutama untuk test yang berulang, setup global, dan assertion yang lebih natural dibaca. Untuk tim Laravel, ini penting karena banyak test sebenarnya adalah dokumentasi perilaku aplikasi.

Secara praktik, keuntungan Pest terasa pada beberapa hal:

  • Syntax lebih ringkas, sehingga test lebih mudah dibaca dan dirawat.
  • Integrasi baik dengan Laravel, termasuk helper aplikasi, database, HTTP testing, dan fake.
  • Cocok untuk style test behavior-oriented, misalnya menggambarkan apa yang seharusnya terjadi pada endpoint atau service.
  • Tetap kompatibel dengan PHPUnit, sehingga migrasi tidak harus sekaligus.

Trade-off-nya, tim tetap perlu disiplin. Syntax yang ringkas tidak otomatis menghasilkan test yang baik. Test yang terlalu bergantung pada detail implementasi internal tetap akan rapuh, meskipun ditulis dengan Pest.

Struktur Test yang Sehat: Kapan Unit, Kapan Feature

Unit test untuk logika bisnis yang terisolasi

Unit test cocok untuk class yang memiliki aturan bisnis jelas dan bisa diuji tanpa bootstrapping aplikasi penuh. Contohnya service class, validator khusus, value object, atau helper yang melakukan transformasi data. Tujuan utamanya adalah menguji logika dengan cepat dan deterministik.

Unit test sebaiknya:

  • Tidak bergantung pada database jika tidak perlu.
  • Tidak menembak HTTP endpoint.
  • Tidak bergantung pada queue, mail, atau notifikasi sungguhan.
  • Fokus pada input, output, dan efek samping yang memang menjadi kontrak class tersebut.

Feature test untuk perilaku aplikasi dari luar

Feature test digunakan untuk menguji alur aplikasi yang melibatkan beberapa komponen: routing, middleware, request validation, database, authorization, response JSON, hingga side effect seperti dispatch job atau pengiriman notifikasi. Di Laravel, sebagian besar nilai testing justru sering datang dari feature test karena ia merepresentasikan bagaimana aplikasi benar-benar dipakai.

Aturan praktis yang cukup aman:

  • Jika ingin menguji endpoint API, gunakan feature test.
  • Jika ingin menguji aturan domain yang berdiri sendiri, gunakan unit test.
  • Jika suatu logika sulit diuji tanpa boot aplikasi, pertimbangkan apakah desain class perlu dirapikan.

Kesalahan umum adalah menaruh terlalu banyak logika penting langsung di controller. Akibatnya, test yang seharusnya cukup cepat di level unit terpaksa diuji lewat feature test yang lebih berat.

Fondasi Praktis: Setup Pest, Factory, dan Database Testing

Struktur direktori dan file dasar

Umumnya Anda akan memiliki dua area utama: tests/Unit dan tests/Feature. Pest memungkinkan setup global melalui file seperti tests/Pest.php. Di sana, Anda bisa mendaftarkan trait atau helper yang sering dipakai.

<?php

use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class)->in('Feature');

Contoh di atas membuat semua test di folder Feature otomatis me-refresh database. Ini praktis, tetapi jangan memakainya membabi buta di semua folder jika tidak diperlukan, karena akan memperlambat suite.

Gunakan factory untuk data yang realistis

Factory adalah kunci agar test mudah dibaca dan tidak penuh setup manual. Factory yang baik menghasilkan data valid secara default, lalu menyediakan state untuk variasi penting, misalnya user admin, user nonaktif, atau order berstatus tertentu.

<?php

it('menampilkan daftar order milik user yang login', function () {
    $user = User::factory()->create();
    Order::factory()->count(3)->for($user)->create();

    $response = $this->actingAs($user)->getJson('/api/orders');

    $response
        ->assertOk()
        ->assertJsonCount(3, 'data');
});

Kenapa factory penting? Karena test menjadi fokus pada perilaku, bukan detail pembuatan data. Selain itu, saat skema model berubah, Anda cukup memperbarui factory, bukan puluhan test satu per satu.

Pilih strategi database yang tepat

Laravel menyediakan beberapa pendekatan, tetapi yang paling sering dipakai adalah RefreshDatabase. Trait ini cocok untuk feature test yang menyentuh database. Untuk unit test murni, hindari database jika tidak dibutuhkan.

Beberapa catatan penting:

  • In-memory SQLite bisa cepat, tetapi tidak selalu merepresentasikan perilaku database produksi seperti MySQL atau PostgreSQL, terutama untuk constraint, tipe kolom, atau query spesifik.
  • Database test yang sama dengan engine produksi lebih akurat, tetapi biasanya lebih lambat.
  • Gunakan seed seperlunya. Jangan memanggil seeder besar pada setiap test jika hanya butuh satu-dua entitas.

Untuk banyak tim, kombinasi yang sehat adalah: unit test tanpa database, feature test dengan database yang mendekati produksi, dan data test dibentuk lewat factory minimalis.

Menguji Service Class dengan Unit Test yang Cepat

Misalkan Anda memiliki service untuk menghitung total harga order setelah diskon. Logika seperti ini sebaiknya tidak ditaruh di controller, dan sangat cocok diuji di level unit.

<?php

namespace App\Services;

class OrderPricingService
{
    public function calculate(int $subtotal, bool $isMember): int
    {
        if ($subtotal < 0) {
            throw new InvalidArgumentException('Subtotal tidak valid');
        }

        $discount = $isMember ? 10 : 0;

        return (int) round($subtotal - ($subtotal * $discount / 100));
    }
}

Test dengan Pest:

<?php

use App\Services\OrderPricingService;

it('menghitung diskon member dengan benar', function () {
    $service = new OrderPricingService();

    expect($service->calculate(100000, true))->toBe(90000);
    expect($service->calculate(100000, false))->toBe(100000);
});

it('melempar exception jika subtotal negatif', function () {
    $service = new OrderPricingService();

    $service->calculate(-1000, true);
})->throws(InvalidArgumentException::class);

Kenapa ini efektif? Karena test berjalan sangat cepat, tidak perlu database, dan langsung memverifikasi kontrak bisnis yang penting. Jika logika diskon berubah, Anda cukup mengubah service dan test ini tanpa menyentuh layer HTTP.

Menguji Endpoint API dengan Feature Test

Feature test adalah tempat terbaik untuk memastikan endpoint bekerja dari sudut pandang klien. Misalkan Anda punya endpoint POST /api/orders yang memerlukan autentikasi, menyimpan data ke database, dan mendispatch job untuk proses lanjutan.

<?php

use App\Jobs\ProcessOrder;
use App\Models\User;
use Illuminate\Support\Facades\Queue;

it('membuat order baru dan mendispatch job pemrosesan', function () {
    Queue::fake();

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

    $payload = [
        'items' => [
            ['product_id' => 1, 'quantity' => 2],
            ['product_id' => 2, 'quantity' => 1],
        ],
        'shipping_address' => 'Jl. Melati No. 10',
    ];

    $response = $this
        ->actingAs($user, 'sanctum')
        ->postJson('/api/orders', $payload);

    $response
        ->assertCreated()
        ->assertJsonStructure([
            'data' => ['id', 'status', 'shipping_address']
        ]);

    $this->assertDatabaseHas('orders', [
        'user_id' => $user->id,
        'shipping_address' => 'Jl. Melati No. 10',
    ]);

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

Test di atas memverifikasi beberapa hal penting dalam satu alur:

  • Autentikasi user.
  • Endpoint menerima payload JSON.
  • Response HTTP sesuai ekspektasi.
  • Data benar-benar tersimpan.
  • Job dipush ke queue, tanpa benar-benar dieksekusi.

Ini jauh lebih bernilai daripada hanya menguji bahwa controller memanggil method tertentu. Yang diuji adalah perilaku aplikasi yang terlihat dari luar.

Uji validasi dan authorization secara eksplisit

Selain skenario sukses, endpoint API wajib diuji untuk kasus gagal. Dua yang paling sering terlewat adalah validasi dan authorization.

<?php

it('menolak pembuatan order tanpa shipping address', function () {
    $user = User::factory()->create();

    $response = $this
        ->actingAs($user, 'sanctum')
        ->postJson('/api/orders', [
            'items' => [
                ['product_id' => 1, 'quantity' => 1],
            ],
        ]);

    $response
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['shipping_address']);
});

Pengujian seperti ini membantu menangkap regresi saat request class, policy, atau middleware berubah.

Mock, Fake, dan Kapan Harus Memakai Keduanya

Gunakan fake untuk boundary Laravel

Untuk komponen seperti Queue, Mail, Notification, Event, atau Bus, gunakan fake bila tujuan Anda adalah memastikan efek samping terpicu tanpa mengeksekusi sistem aslinya. Ini lebih cepat dan lebih stabil daripada benar-benar mengirim email atau memproses queue.

<?php

use App\Mail\WelcomeMail;
use App\Models\User;
use Illuminate\Support\Facades\Mail;

it('mengirim email sambutan saat user terdaftar', function () {
    Mail::fake();

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

    // misalnya ada action yang memicu pengiriman mail
    Mail::to($user->email)->send(new WelcomeMail($user));

    Mail::assertSent(WelcomeMail::class, function ($mail) use ($user) {
        return $mail->hasTo($user->email);
    });
});

Pola yang sama berlaku untuk notification dan queue. Dengan fake, Anda menguji kontrak integrasi secara cukup, tanpa membawa biaya eksekusi sistem eksternal.

Gunakan mock untuk dependency internal yang memang perlu diisolasi

Mock cocok ketika sebuah class bergantung pada interface atau service lain, dan Anda ingin mengisolasi unit yang sedang diuji. Namun, mock berlebihan sering membuat test rapuh karena terlalu mengikat pada detail implementasi.

Contoh aman: service yang memanggil payment gateway client melalui interface. Anda tidak ingin unit test benar-benar mengakses jaringan. Dalam kasus seperti ini, mock dependency masuk akal.

Prinsip sederhananya:

  • Fake untuk boundary framework atau side effect umum.
  • Mock untuk dependency internal atau eksternal yang perlu diisolasi.
  • Hindari mock jika yang diuji sebenarnya bisa diverifikasi lewat output atau perubahan state yang nyata.

Menjaga Test Suite Tetap Cepat di Lokal dan CI

Prioritaskan test yang memberi sinyal paling tinggi

Jangan semua hal diuji melalui feature test berat. Simpan logika domain di class terpisah agar dapat diuji lewat unit test cepat. Feature test cukup untuk jalur penting: autentikasi, authorization, endpoint inti, validasi, dan integrasi komponen aplikasi.

Kurangi setup yang tidak perlu

Beberapa penyebab test lambat sangat umum:

  • Menggunakan RefreshDatabase untuk semua test, termasuk yang tidak menyentuh DB.
  • Seeder besar dijalankan di setiap test.
  • Membuat terlalu banyak data factory padahal hanya butuh satu record.
  • Memanggil service container dan boot framework penuh untuk logika sederhana.

Optimasi paling berdampak biasanya justru berasal dari penyederhanaan fixture dan pemisahan unit vs feature yang disiplin.

Optimasi CI secara realistis

Di CI, targetnya bukan hanya test lolos, tetapi juga memberikan feedback cepat dan konsisten. Beberapa praktik yang relevan:

  • Pisahkan job lint, static analysis, dan test agar kegagalan cepat terlihat.
  • Gunakan parallel testing bila test suite mulai besar dan infrastruktur mendukung.
  • Cache dependency seperti Composer untuk mengurangi waktu setup.
  • Gunakan environment test yang stabil dan minim perbedaan dengan lokal, terutama versi PHP, ekstensi, dan database.

Parallel testing bisa sangat membantu, tetapi perlu kehati-hatian terhadap test yang diam-diam berbagi state global, file sementara, atau resource eksternal. Jika setelah diparalelkan test menjadi acak gagal, biasanya masalahnya bukan fitur paralelnya, melainkan test Anda belum benar-benar independen.

Mengurangi Flaky Test: Sumber Masalah dan Cara Mengatasinya

Flaky test adalah test yang kadang lulus, kadang gagal tanpa perubahan kode relevan. Ini salah satu penyebab tim mulai tidak percaya pada test suite. Sumber flaky test yang paling umum di Laravel antara lain:

  • Ketergantungan waktu, misalnya test sensitif terhadap timezone atau detik berjalan.
  • State global yang bocor antar test.
  • Urutan eksekusi yang memengaruhi hasil test lain.
  • Ketergantungan jaringan atau service eksternal.
  • Asumsi database yang tidak stabil, misalnya mengandalkan urutan record tanpa orderBy.

Tips praktis untuk menguranginya:

  • Bekukan waktu saat menguji logika berbasis tanggal/jam bila perlu.
  • Pastikan setiap test membuat datanya sendiri dan tidak mengandalkan test lain.
  • Gunakan fake untuk mail, queue, notification, event, dan HTTP client bila tidak sedang menguji integrasi nyata.
  • Jangan mengandalkan ID tertentu atau urutan query default.
  • Jika memakai storage atau file, gunakan disk test terisolasi.

Jika sebuah test hanya gagal di CI, periksa perbedaan timezone, driver database, urutan eksekusi, dan konfigurasi environment. Masalah seperti ini lebih sering terjadi daripada bug framework.

Mengaitkan dengan Tooling Laravel Terkini

Ekosistem Laravel modern mendorong workflow yang semakin otomatis: test yang berjalan lokal dengan cepat, dieksekusi di pipeline CI, dan dipadukan dengan tooling lain seperti formatter, static analysis, dan coverage bila memang diperlukan. Dalam konteks ini, Pest cocok karena mengurangi beban menulis test sehari-hari, sementara Laravel tetap menyediakan utilitas matang untuk database, HTTP, queue, mail, notification, dan autentikasi API.

Namun, jangan terjebak mengejar coverage tinggi tanpa makna. Coverage berguna sebagai indikator, bukan tujuan utama. Lebih penting memastikan area berisiko tinggi benar-benar teruji: aturan bisnis inti, endpoint kritikal, policy, validasi input, dan integrasi yang sering berubah.

Penutup

Strategi testing Laravel modern dengan Pest tidak harus rumit. Intinya adalah membagi tanggung jawab dengan jelas: gunakan unit test untuk logika bisnis yang terisolasi, feature test untuk perilaku aplikasi dari luar, factory untuk data yang mudah dirawat, fake untuk side effect framework, dan mock hanya saat memang perlu isolasi dependency.

Jika diterapkan dengan disiplin, hasilnya bukan hanya test yang lebih enak ditulis, tetapi juga feedback loop yang lebih cepat, CI yang lebih stabil, dan proses refactor yang jauh lebih aman. Mulailah dari jalur kritikal aplikasi Anda: satu service class penting, satu endpoint API utama, lalu perluas test suite secara bertahap dengan fokus pada kualitas sinyal, bukan sekadar jumlah test.