Fitur pencarian sering terlihat sederhana dari sisi UI, tetapi bagian pengujiannya mudah menjadi rumit. Pada aplikasi Laravel yang memakai Laravel Scout, kita biasanya berhadapan dengan beberapa lapisan sekaligus: bagaimana model diubah menjadi dokumen indeks, bagaimana endpoint API mengembalikan hasil pencarian, dan bagaimana perilaku engine pencarian nyata seperti Meilisearch terhadap filter, sorting, typo, dan pagination.

Kesalahan yang umum adalah mengandalkan hanya satu jenis test. Jika semua test memakai engine nyata, suite menjadi lambat dan rentan flaky. Jika semua test memakai mock atau fake, kita bisa lolos dari bug konfigurasi indeks, typo tolerance, atau filter yang sebenarnya tidak didukung engine. Pendekatan yang lebih sehat adalah membagi tanggung jawab pengujian menjadi tiga level: unit test untuk toSearchableArray(), feature test untuk endpoint pencarian, dan integration test untuk engine nyata.

Dalam artikel ini kita akan memakai studi kasus katalog produk dan artikel. Contoh kode ditulis agar mudah diadaptasi baik untuk PHPUnit maupun Pest. Fokusnya bukan hanya pada sintaks, tetapi juga pada alasan di balik tiap pendekatan, trade-off, dan cara menghindari test yang tidak stabil.

Memahami Apa yang Perlu Diuji pada Laravel Scout

Secara umum, Scout bertugas menyinkronkan model Eloquent ke mesin pencarian dan menyediakan API seperti Model::search('keyword'). Namun perilaku pencarian tidak sepenuhnya ditentukan oleh Laravel; banyak aspek bergantung pada engine yang dipakai. Karena itu, pisahkan pengujian menjadi:

  • Unit test: memverifikasi bentuk dokumen indeks dari model, terutama toSearchableArray().
  • Feature test: memverifikasi kontrak endpoint, validasi input, struktur respons, dan integrasi aplikasi secara umum.
  • Integration test: memverifikasi perilaku engine nyata seperti typo tolerance, ranking, filterable attributes, sortable attributes, dan sinkronisasi indeks.

Pemisahan ini penting karena tiap level menjawab pertanyaan yang berbeda:

  • Apakah data yang dikirim ke indeks sudah benar?
  • Apakah endpoint aplikasi berperilaku benar terhadap request pengguna?
  • Apakah engine pencarian menghasilkan hasil yang benar dalam kondisi nyata?

Studi Kasus: Model Produk dan Artikel

Misalkan kita memiliki model Product dan Article yang sama-sama menggunakan trait Searchable.

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;

class Product extends Model
{
    use HasFactory, Searchable;

    protected $fillable = [
        'name', 'slug', 'description', 'category', 'brand',
        'price', 'stock', 'is_active'
    ];

    public function toSearchableArray(): array
    {
        return [
            'id' => (int) $this->id,
            'name' => $this->name,
            'description' => $this->description,
            'category' => $this->category,
            'brand' => $this->brand,
            'price' => (int) $this->price,
            'stock' => (int) $this->stock,
            'is_active' => (bool) $this->is_active,
        ];
    }

    public function shouldBeSearchable(): bool
    {
        return $this->is_active === true;
    }
}

Contoh endpoint API pencarian produk:

use App\Models\Product;
use Illuminate\Http\Request;

Route::get('/products/search', function (Request $request) {
    $request->validate([
        'q' => ['nullable', 'string', 'max:100'],
        'category' => ['nullable', 'string'],
        'sort' => ['nullable', 'in:price_asc,price_desc'],
        'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
    ]);

    $perPage = $request->integer('per_page', 10);
    $query = Product::search($request->string('q', ''));

    if ($request->filled('category')) {
        $query->where('category', $request->category);
    }

    if ($request->sort === 'price_asc') {
        $query->orderBy('price');
    }

    if ($request->sort === 'price_desc') {
        $query->orderBy('price', 'desc');
    }

    return $query->paginate($perPage);
});

Pada titik ini, kita sudah punya konteks yang cukup untuk menyusun strategi test.

Unit Test untuk toSearchableArray() dan Aturan Indexing

Unit test sebaiknya fokus pada logika yang sepenuhnya milik aplikasi kita, bukan perilaku engine luar. Untuk Scout, target paling jelas adalah toSearchableArray() dan shouldBeSearchable(). Test ini cepat, deterministik, dan tidak perlu service eksternal.

Menguji bentuk dokumen indeks

Dengan PHPUnit:

public function test_product_searchable_array_contains_expected_fields(): void
{
    $product = Product::factory()->make([
        'name' => 'iPhone 15 Pro',
        'description' => 'Smartphone flagship',
        'category' => 'smartphone',
        'brand' => 'Apple',
        'price' => 20999000,
        'stock' => 5,
        'is_active' => true,
    ]);

    $data = $product->toSearchableArray();

    $this->assertSame('iPhone 15 Pro', $data['name']);
    $this->assertSame('smartphone', $data['category']);
    $this->assertSame(20999000, $data['price']);
    $this->assertIsInt($data['price']);
    $this->assertTrue($data['is_active']);
}

Dengan Pest:

it('membentuk searchable array produk dengan benar', function () {
    $product = Product::factory()->make([
        'name' => 'iPhone 15 Pro',
        'price' => 20999000,
        'is_active' => true,
    ]);

    expect($product->toSearchableArray())
        ->toMatchArray([
            'name' => 'iPhone 15 Pro',
            'price' => 20999000,
            'is_active' => true,
        ]);
});

Kenapa ini penting? Karena banyak bug pencarian ternyata berasal dari transformasi data, misalnya harga tersimpan sebagai string, field kategori lupa dimasukkan, atau boolean tidak di-cast dengan benar. Engine seperti Meilisearch sensitif terhadap tipe data untuk sorting dan filtering.

Menguji model yang boleh dan tidak boleh diindeks

Jika model memakai shouldBeSearchable(), uji juga aturannya.

public function test_inactive_product_should_not_be_searchable(): void
{
    $product = Product::factory()->make(['is_active' => false]);

    $this->assertFalse($product->shouldBeSearchable());
}

Ini adalah unit test murni. Jangan campur dengan ekspektasi apakah dokumen benar-benar masuk ke engine; itu wilayah integration test.

Menguji event indexing dengan Scout::fake()

Untuk memastikan model memicu operasi indexing atau unindexing, Scout::fake() berguna. Ini bukan pengganti integration test, tetapi cocok untuk memverifikasi bahwa aplikasi kita memang meminta Scout melakukan sinkronisasi.

use Laravel\Scout\Scout;

public function test_product_is_indexed_when_created(): void
{
    Scout::fake();

    $product = Product::factory()->create(['is_active' => true]);

    Scout::assertSearchable($product);
}

public function test_product_is_unindexed_when_deleted(): void
{
    Scout::fake();

    $product = Product::factory()->create(['is_active' => true]);
    $product->delete();

    Scout::assertNotSearchable($product);
}

Nama assertion bisa sedikit berbeda tergantung versi Scout yang Anda pakai. Jika tersedia helper assertion bawaan, gunakan itu. Jika tidak, gunakan pendekatan fake/mock yang setara sesuai versi proyek Anda.

Trade-off pendekatan ini jelas: test cepat dan fokus pada intent aplikasi, tetapi tidak membuktikan bahwa engine benar-benar menerima dokumen atau memproses query dengan benar.

Feature Test Endpoint Search dengan Driver yang Terkontrol

Feature test sebaiknya menguji kontrak HTTP: parameter request, validasi, struktur JSON, pagination, dan perilaku aplikasi saat menerima input. Untuk level ini, Anda bisa memilih dua pendekatan:

  • Memakai database driver atau setup yang terkontrol untuk hasil yang deterministik.
  • Memakai fake hanya untuk memverifikasi interaksi, lalu fokus pada respons endpoint yang bisa diprediksi.

Jika tujuan Anda adalah menguji endpoint API tanpa ketergantungan engine eksternal, database driver sering lebih praktis. Namun ingat: database driver tidak merepresentasikan semua perilaku engine seperti typo tolerance dan ranking canggih.

Factory data untuk produk dan artikel

// ProductFactory
public function definition(): array
{
    return [
        'name' => fake()->words(3, true),
        'slug' => fake()->slug(),
        'description' => fake()->sentence(),
        'category' => fake()->randomElement(['smartphone', 'laptop', 'audio']),
        'brand' => fake()->company(),
        'price' => fake()->numberBetween(100000, 30000000),
        'stock' => fake()->numberBetween(0, 100),
        'is_active' => true,
    ];
}

Test endpoint pencarian dasar

use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('mengembalikan produk yang cocok dengan keyword', function () {
    Product::factory()->create(['name' => 'iPhone 15 Pro', 'category' => 'smartphone']);
    Product::factory()->create(['name' => 'Galaxy S24', 'category' => 'smartphone']);
    Product::factory()->create(['name' => 'MacBook Pro', 'category' => 'laptop']);

    $response = $this->getJson('/api/products/search?q=iPhone');

    $response->assertOk()
        ->assertJsonFragment(['name' => 'iPhone 15 Pro'])
        ->assertJsonMissing(['name' => 'Galaxy S24']);
});

Jika Anda memakai database driver, pastikan ekspektasi disesuaikan dengan kemampuan driver. Jangan menulis test yang mengasumsikan typo tolerance jika driver tersebut tidak mendukungnya.

Menguji filter

it('menerapkan filter kategori', function () {
    Product::factory()->create(['name' => 'iPhone 15 Pro', 'category' => 'smartphone']);
    Product::factory()->create(['name' => 'MacBook Pro', 'category' => 'laptop']);

    $response = $this->getJson('/api/products/search?q=Pro&category=smartphone');

    $response->assertOk()
        ->assertJsonFragment(['name' => 'iPhone 15 Pro'])
        ->assertJsonMissing(['name' => 'MacBook Pro']);
});

Menguji sorting dan pagination

it('menerapkan sorting harga ascending dan pagination', function () {
    Product::factory()->create(['name' => 'Produk A', 'price' => 300000]);
    Product::factory()->create(['name' => 'Produk B', 'price' => 100000]);
    Product::factory()->create(['name' => 'Produk C', 'price' => 200000]);

    $response = $this->getJson('/api/products/search?q=Produk&sort=price_asc&per_page=2');

    $response->assertOk()
        ->assertJsonPath('per_page', 2)
        ->assertJsonPath('data.0.name', 'Produk B')
        ->assertJsonPath('data.1.name', 'Produk C');
});

Untuk sorting, periksa juga bahwa field yang disort memang tersedia sebagai field numerik pada dokumen indeks. Bug yang sering terjadi adalah harga tersimpan sebagai string sehingga urutan menjadi salah secara leksikografis.

Menguji validasi endpoint

it('menolak per_page yang terlalu besar', function () {
    $this->getJson('/api/products/search?per_page=1000')
        ->assertStatus(422)
        ->assertJsonValidationErrors(['per_page']);
});

Feature test seperti ini sangat berharga karena tetap relevan meskipun nanti Anda mengganti engine pencarian. Kontrak HTTP aplikasi tetap terjaga.

Integration Test dengan Engine Nyata seperti Meilisearch

Integration test dipakai untuk memverifikasi hal-hal yang memang hanya bisa dibuktikan oleh engine nyata. Contohnya:

  • Typos seperti iphne tetap mengembalikan iPhone.
  • Filter hanya bekerja jika atribut dikonfigurasi sebagai filterable.
  • Sorting hanya bekerja jika atribut termasuk sortable.
  • Sinkronisasi indeks berlangsung benar setelah create, update, dan delete.

Untuk Meilisearch, Anda biasanya perlu menyiapkan setting indeks sebelum test berjalan.

use Meilisearch\Client;

beforeAll(function () {
    $client = new Client(config('scout.meilisearch.host'), config('scout.meilisearch.key'));
    $index = $client->index('products');

    $index->updateFilterableAttributes(['category', 'brand', 'is_active']);
    $index->updateSortableAttributes(['price']);
});

Setelah mengubah setting indeks, tunggu task selesai. Ini penting untuk menghindari test gagal secara acak.

function waitForMeilisearchTask(Client $client, $taskUid): void
{
    $timeout = 5;
    $start = microtime(true);

    do {
        $task = $client->getTask($taskUid);

        if ($task['status'] === 'succeeded') {
            return;
        }

        if ($task['status'] === 'failed') {
            throw new RuntimeException('Meilisearch task failed: '.json_encode($task));
        }

        usleep(200000);
    } while (microtime(true) - $start < $timeout);

    throw new RuntimeException('Timeout waiting for Meilisearch task');
}

Test typo tolerance

it('mendukung typo tolerance untuk nama produk', function () {
    $product = Product::factory()->create(['name' => 'iPhone 15 Pro', 'is_active' => true]);
    $product->searchable();

    // Tunggu indexing selesai sesuai helper proyek Anda.

    $results = Product::search('iphne')->get();

    expect($results->pluck('name'))->toContain('iPhone 15 Pro');
});

Test seperti ini tidak cocok dijalankan dengan database driver atau fake karena perilaku typo tolerance adalah fitur engine, bukan fitur Laravel Scout itu sendiri.

Test filter dan sorting pada engine nyata

it('mendukung filter kategori dan sorting harga pada meilisearch', function () {
    Product::factory()->create(['name' => 'Produk A', 'category' => 'smartphone', 'price' => 300000]);
    Product::factory()->create(['name' => 'Produk B', 'category' => 'smartphone', 'price' => 100000]);
    Product::factory()->create(['name' => 'Produk C', 'category' => 'laptop', 'price' => 200000]);

    Product::makeAllSearchable();

    $results = Product::search('Produk')
        ->where('category', 'smartphone')
        ->orderBy('price')
        ->get();

    expect($results->pluck('name')->values()->all())
        ->toBe(['Produk B', 'Produk A']);
});

Jika test ini gagal, cek beberapa kemungkinan:

  • Atribut belum didaftarkan sebagai filterable atau sortable.
  • Index belum selesai sinkron saat query dijalankan.
  • Nama index berbeda antara environment test dan lokal.
  • Dokumen yang dikirim ke indeks tidak memiliki tipe data yang sesuai.

Test unindex saat delete atau nonaktif

it('menghapus produk dari index saat dihapus', function () {
    $product = Product::factory()->create(['name' => 'AirPods Pro', 'is_active' => true]);
    $product->searchable();

    // Tunggu indexing selesai.

    $product->delete();

    // Tunggu delete task selesai.

    $results = Product::search('AirPods')->get();

    expect($results->pluck('id'))->not->toContain($product->id);
});

Pengujian Artikel: Kasus Kedua untuk Memastikan Pola Konsisten

Selain katalog produk, pola yang sama bisa diterapkan pada artikel. Misalnya, artikel memiliki title, excerpt, content, author_name, dan published_at. Pada artikel, test realistis sering berfokus pada relevansi kata kunci, status publikasi, dan typo ringan pada judul.

Unit test tetap memeriksa toSearchableArray(). Feature test tetap memeriksa endpoint seperti /api/articles/search. Integration test dapat memeriksa bahwa query laravel scot masih menemukan artikel berjudul Laravel Scout Guide jika engine mendukung toleransi typo atau tokenisasi yang sesuai.

Keuntungan memiliki dua domain, produk dan artikel, adalah Anda bisa memastikan abstraksi pencarian di aplikasi tidak terlalu sempit untuk satu model saja.

Trade-off: Test Cepat vs Test Realistis

Tidak ada satu jenis test yang cukup untuk semua kebutuhan. Berikut panduan praktis:

  • Unit test: paling cepat, paling stabil, cocok untuk logika transformasi data.
  • Feature test: cukup cepat, bagus untuk kontrak endpoint dan validasi API.
  • Integration test: paling realistis, tetapi paling lambat dan paling sensitif terhadap environment.

Pilihan yang sehat biasanya:

  1. Banyakkan unit test untuk model searchable.
  2. Punya feature test inti untuk endpoint pencarian.
  3. Sediakan beberapa integration test penting untuk kemampuan engine yang tidak bisa disimulasikan dengan benar.

Jangan mencoba memindahkan seluruh verifikasi relevansi pencarian ke unit test. Sebaliknya, jangan juga memaksa semua test HTTP memakai Meilisearch jika tujuannya hanya memeriksa validasi query parameter.

Menyiapkan CI untuk Meilisearch

Pada CI seperti GitHub Actions, jalankan Meilisearch sebagai service container. Prinsipnya adalah membuat engine tersedia di host/port yang konsisten selama test integration berjalan.

services:
  meilisearch:
    image: getmeili/meilisearch:latest
    ports:
      - 7700:7700
    env:
      MEILI_NO_ANALYTICS: 'true'
      MEILI_MASTER_KEY: 'masterKey'

Lalu set environment test, misalnya:

SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=masterKey
QUEUE_CONNECTION=sync

QUEUE_CONNECTION=sync sering membantu agar indexing tidak tertunda oleh worker terpisah. Jika aplikasi Anda memang mengantre operasi indexing, pastikan test tahu kapan job sudah selesai diproses. Untuk CI, sinkron biasanya lebih mudah dan lebih stabil.

Tips Menghindari Flaky Test Akibat Index Belum Sinkron

Masalah paling umum pada integration test Scout adalah query dijalankan sebelum index selesai diperbarui. Beberapa langkah praktis:

  • Gunakan queue sync untuk environment test jika memungkinkan.
  • Setelah operasi indexing yang penting, wait for task completion pada engine seperti Meilisearch.
  • Gunakan nama index khusus test agar data dari proses lain tidak bercampur.
  • Bersihkan index sebelum atau sesudah suite berjalan.
  • Jangan mengandalkan urutan hasil jika engine tidak menjamin urutan tersebut tanpa sorting eksplisit.

Selain itu, hindari assertion yang terlalu rapuh. Misalnya, untuk pencarian full-text, lebih aman memeriksa bahwa dokumen tertentu ada di hasil ketimbang selalu memaksa posisi ranking tertentu, kecuali Anda benar-benar menguji ranking yang sudah ditentukan oleh konfigurasi engine.

Penutup

Pengujian pencarian Laravel Scout yang baik bukan soal memilih antara fake atau engine nyata, tetapi soal menempatkan jenis test pada level yang tepat. Gunakan unit test untuk memastikan dokumen indeks benar, feature test untuk menjaga kontrak endpoint tetap stabil, dan integration test untuk memverifikasi kemampuan engine seperti filter, sorting, pagination, serta typo tolerance.

Untuk studi kasus katalog produk dan artikel, kombinasi ini memberi keseimbangan antara kecepatan dan realisme. Hasilnya adalah suite test yang tidak hanya cepat dijalankan, tetapi juga cukup kuat untuk menangkap bug yang benar-benar muncul di produksi.