Unit Testing Aplikasi LLM: Cara Mock Panggilan Model

Menjalankan test suite yang benar-benar memanggil API model itu lambat, mahal, dan hasilnya tidak deterministik. Logika di sekitar panggilan model — validasi, retry, penanganan error — justru yang paling penting untuk diuji, dan itu bisa dilakukan tanpa menyentuh model sama sekali. Berikut caranya, plus satu jebakan mock yang terbukti nyata dari pengujian.

Kalau Anda sudah terbiasa menulis unit test untuk kode backend biasa, insting pertama saat menguji fitur yang memanggil LLM biasanya sama: panggil fungsinya, cek hasilnya. Masalahnya, memanggil model sungguhan di setiap test run berarti test jadi lambat (menunggu jaringan), mahal (token terpakai setiap kali test dijalankan), dan tidak deterministik (jawaban model bisa berbeda setiap kali, membuat assertion sulit ditulis). Ini beda pembahasan dari evaluasi kualitas output LLM — evaluasi mengukur kualitas jawaban model itu sendiri, sementara unit testing di artikel ini menguji kode di sekitar model: apakah retry bekerja, apakah validasi menolak input yang salah, apakah error ditangani dengan benar.

Prinsipnya: Pisahkan Kode yang Diuji dari Panggilan Model Sungguhan

Kuncinya adalah membungkus panggilan ke SDK model di balik satu class atau fungsi tipis, supaya saat testing, bagian itu bisa diganti dengan tiruan (mock) tanpa mengubah kode aplikasi.

class KlienModel:
    """Wrapper tipis di atas SDK model sungguhan - inilah yang di-mock saat testing."""
    async def buat_teks(self, prompt: str, model: str = "gemini-3.8-flash") -> str:
        # implementasi asli memanggil API sungguhan di sini
        ...

class LayananRingkasan:
    def __init__(self, klien: KlienModel):
        self.klien = klien

    async def ringkas(self, teks: str, maks_percobaan: int = 3) -> str:
        if not teks.strip():
            raise ValueError("Teks tidak boleh kosong")
        percobaan = 0
        while True:
            percobaan += 1
            try:
                hasil = await self.klien.buat_teks(f"Ringkas: {teks}")
                if not hasil or len(hasil) > 500:
                    raise ErrorModel("Output model tidak valid")
                return hasil
            except ErrorModel:
                if percobaan >= maks_percobaan:
                    raise
                await asyncio.sleep(0.01 * percobaan)

LayananRingkasan menerima KlienModel lewat constructor (dependency injection), bukan membuatnya sendiri di dalam. Pola ini yang membuat testing tanpa API asli menjadi mungkin.

Menguji Logika Bisnis Tanpa Memanggil Model Sungguhan

from unittest.mock import AsyncMock
import pytest

@pytest.mark.asyncio
async def test_ringkas_sukses():
    klien_palsu = AsyncMock(spec=KlienModel)
    klien_palsu.buat_teks.return_value = "Ini ringkasan singkat."
    layanan = LayananRingkasan(klien_palsu)

    hasil = await layanan.ringkas("Teks panjang yang perlu diringkas.")

    assert hasil == "Ini ringkasan singkat."
    klien_palsu.buat_teks.assert_called_once()

@pytest.mark.asyncio
async def test_ringkas_retry_setelah_output_invalid():
    klien_palsu = AsyncMock(spec=KlienModel)
    # Percobaan 1: output kosong (invalid), percobaan 2: sukses
    klien_palsu.buat_teks.side_effect = ["", "Ringkasan valid setelah retry."]
    layanan = LayananRingkasan(klien_palsu)

    hasil = await layanan.ringkas("Teks uji retry.")

    assert hasil == "Ringkasan valid setelah retry."
    assert klien_palsu.buat_teks.call_count == 2

Diuji dan lolos semua: skenario sukses, input kosong yang ditolak sebelum sempat memanggil model sama sekali, retry setelah output tidak valid (memakai side_effect untuk mensimulasikan urutan respons berbeda di tiap panggilan), dan kegagalan setelah mencapai batas maksimum percobaan. Seluruh 4 test ini selesai dalam 0,07 detik — dibandingkan detik-menit kalau harus menunggu API sungguhan, dan tanpa token yang terpakai sama sekali.

Jebakan yang Terbukti Nyata: Mock Tanpa spec Menyembunyikan Typo

Diuji dan terbukti: membuat mock tanpa spec=KlienModel, lalu memanggil method yang salah ketik (buat_teksss, bukan buat_teks) — test tetap lolos tanpa error sama sekali. AsyncMock tanpa spec otomatis membuat method palsu untuk nama apa pun yang dipanggil, termasuk yang sebenarnya tidak ada di class aslinya.
# TANPA spec - typo lolos diam-diam
klien_palsu = AsyncMock()
hasil = await klien_palsu.buat_teksss("halo")  # method ini TIDAK ADA di KlienModel asli
# Tidak error! Mock membuat method palsu untuk nama apa pun.

# DENGAN spec - typo terdeteksi
klien_palsu = AsyncMock(spec=KlienModel)
await klien_palsu.buat_teksss("halo")  # AttributeError - benar terdeteksi
Dampaknya nyata: kalau kode aplikasi ternyata salah ketik nama method (atau method itu diganti namanya saat refactor tapi kode pemanggilnya lupa diupdate), test yang pakai mock tanpa spec tetap hijau — memberi rasa aman yang palsu. Test yang sama dengan spec=KlienModel langsung gagal dengan AttributeError, persis seperti yang akan terjadi di production kalau kesalahan itu benar-benar terjadi pada object asli.

Apa yang Layak Diuji, Apa yang Tidak Perlu

Yang layak diuji dengan mockYang tidak perlu (atau tidak bisa) diuji lewat unit test
Validasi input sebelum memanggil modelApakah jawaban model “bagus” secara kualitas (itu tugas evaluasi, bukan unit test)
Logika retry dan backoff saat model gagalPerilaku model sungguhan terhadap prompt tertentu
Penanganan output yang tidak valid (kosong, kepanjangan, format salah)Latensi atau ketersediaan API vendor
Alur kode setelah model merespons (parsing, penyimpanan, pemanggilan tool)Konsistensi jawaban model di berbagai run (itu sifat model, bukan bug kode)

Pertanyaan yang Sering Muncul

Apakah unit test dengan mock ini menggantikan kebutuhan tes integrasi ke API asli?

Tidak — keduanya melengkapi, bukan menggantikan. Unit test dengan mock menjamin logika kode benar dengan cepat dan murah, dijalankan setiap kali ada perubahan kode. Tes integrasi (memanggil API sungguhan, biasanya lebih jarang dijalankan) tetap diperlukan untuk memastikan asumsi tentang bentuk respons API benar-benar sesuai dengan API yang sebenarnya.

Kenapa harus AsyncMock, bukan Mock biasa?

Karena KlienModel.buat_teks adalah fungsi async def — memanggilnya menghasilkan coroutine yang harus di-await. Mock biasa tidak menghasilkan objek yang bisa di-await, sehingga akan menyebabkan error saat kode aplikasi mencoba melakukan await klien.buat_teks(...). AsyncMock dirancang khusus untuk mensimulasikan fungsi async dengan benar.

Apakah wajib membungkus SDK model dalam class sendiri seperti KlienModel?

Tidak wajib secara teknis — bisa saja mock langsung SDK aslinya. Tapi membungkusnya dalam wrapper tipis milik sendiri membuat kode aplikasi tidak terikat erat ke detail SDK vendor tertentu, dan kalau suatu saat pindah vendor model, cukup ubah implementasi wrapper tanpa menyentuh LayananRingkasan atau test-nya sama sekali.

Bagaimana menguji prompt yang dikirim ke model, bukan cuma hasilnya?

Gunakan klien_palsu.buat_teks.call_args setelah pemanggilan untuk memeriksa argumen persis yang dikirim — ini berguna untuk memastikan prompt yang dibangun dari input pengguna sudah benar sebelum dikirim, tanpa perlu benar-benar mengirimkannya ke model.

Kesimpulan

Unit testing aplikasi LLM bukan soal menguji apakah model “pintar” — itu bukan sesuatu yang bisa (atau perlu) dipastikan lewat unit test. Yang bisa dan harus diuji adalah kode di sekitarnya: validasi, retry, penanganan error, dan alur setelah model merespons — semuanya lewat mock yang cepat dan tidak menyentuh API sungguhan. Pengujian di atas juga menunjukkan hal yang mudah terlewat: mock yang dibuat tanpa spec bisa memberi rasa aman palsu karena diam-diam menerima method yang salah ketik. Detail kecil ini yang membedakan test suite yang benar-benar melindungi dari regresi dan yang cuma terlihat hijau.

Artikel terkait: Python untuk AI engineer dan skill backend untuk AI engineering.

Sudah bisa menguji kode-nya, sekarang mau ukur kualitas jawaban modelnya?

Baca cara membangun golden dataset dan mengukur kualitas output LLM.

Baca Evaluasi LLM
Bagikan: