pytest — How to use fixtures
Fixture: setup dan teardown yang bisa dipakai ulang, dan cara mengganti panggilan LLM asli dengan yang palsu.
Intisari
- Fixture adalah fungsi bertanda
@pytest.fixture; test memintanya dengan menuliskan namanya sebagai parameter. yielddi dalam fixture memisahkan setup (sebelum) dan teardown (sesudah).scopemengatur seberapa sering fixture dibuat:function(default),module,session.conftest.pymenyediakan fixture ke semua test di folder itu dan turunannya — tanpa import.- Mock panggilan LLM di unit test. Test harus deterministik, gratis, dan cepat.
Fixture pertama
import pytest
@pytest.fixture
def dokumen_contoh() -> list[Dokumen]:
return [
Dokumen(id="1", isi="Python adalah bahasa pemrograman"),
Dokumen(id="2", isi="RAG menggabungkan retrieval dan generation"),
]
def test_cari(dokumen_contoh):
hasil = cari("python", dokumen_contoh)
assert len(hasil) == 1
assert hasil[0].id == "1"
pytest melihat parameter dokumen_contoh, mencari fixture dengan nama itu,
menjalankannya, lalu menyerahkan hasilnya ke test.
Setup dan teardown dengan yield
@pytest.fixture
def db_sementara():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE pesan (id TEXT, isi TEXT)")
yield conn # ← test berjalan di sini
conn.close() # ← selalu dijalankan, bahkan kalau test gagal
Teardown dijamin berjalan meski test-nya gagal atau melempar exception —
sama seperti blok finally. Ini yang membuat fixture lebih andal daripada
menulis setup/cleanup di dalam badan test.
Scope
| Scope | Dibuat | Untuk |
|---|---|---|
function (default) | Tiap test | Data yang bisa berubah |
class | Tiap class test | Jarang |
module | Tiap file test | Koneksi yang mahal dibuat |
session | Sekali seluruh run | Container database, model yang dimuat |
@pytest.fixture(scope="session")
def model_embedding():
return muat_model() # mahal — cukup sekali untuk seluruh test run
Hati-hati dengan scope lebar untuk objek yang bisa berubah. Kalau satu test memodifikasi
fixture ber-scope session, test berikutnya mewarisi perubahan itu — dan kamu
mendapat test yang lulus sendiri tapi gagal saat dijalankan bersama. Untuk data, tetap pakai
function.
conftest.py
tests/
├── conftest.py ← fixture untuk SEMUA test di bawah folder ini
├── test_retrieval.py
└── integrasi/
├── conftest.py ← tambahan khusus folder integrasi
└── test_api.py
# tests/conftest.py
import pytest
@pytest.fixture
def settings_uji() -> Settings:
return Settings(
anthropic_api_key="test-key",
model="claude-haiku-4-5",
max_tokens=100,
)
Fixture di conftest.py tersedia otomatis — tidak perlu di-import.
Mocking LLM — bagian terpenting
Prinsipnya: unit test tidak boleh memanggil API LLM sungguhan. Panggilan asli itu lambat (detik), berbayar, tidak deterministik, dan bisa gagal karena rate limit. Test yang memanggil API asli akan jadi lambat, mahal, dan flaky — dan tim akan berhenti mempercayainya.
Cara 1: fixture yang mengembalikan objek palsu
from dataclasses import dataclass
@dataclass
class BlokPalsu:
type: str = "text"
text: str = "jawaban palsu"
@dataclass
class UsagePalsu:
input_tokens: int = 100
output_tokens: int = 50
cache_read_input_tokens: int = 0
cache_creation_input_tokens: int = 0
@dataclass
class ResponsPalsu:
content: list
stop_reason: str = "end_turn"
usage: UsagePalsu = None
class LlmPalsu:
def __init__(self, balasan: str = "jawaban palsu"):
self.balasan = balasan
self.panggilan = []
async def buat(self, **kw):
self.panggilan.append(kw) # rekam untuk diperiksa
return ResponsPalsu(
content=[BlokPalsu(text=self.balasan)],
usage=UsagePalsu(),
)
@pytest.fixture
def llm_palsu() -> LlmPalsu:
return LlmPalsu()
async def test_jawab_memakai_konteks(llm_palsu, dokumen_contoh):
hasil = await jawab("apa itu RAG?", dokumen_contoh, klien=llm_palsu)
assert hasil.teks == "jawaban palsu"
assert len(llm_palsu.panggilan) == 1
assert "RAG menggabungkan" in str(llm_palsu.panggilan[0]["messages"])
Cara 2: monkeypatch
@pytest.fixture
def llm_terpatch(monkeypatch):
async def create_palsu(**kw):
return ResponsPalsu(content=[BlokPalsu()], usage=UsagePalsu())
monkeypatch.setattr(
"myapp.llm.client.messages.create", create_palsu
)
monkeypatch otomatis mengembalikan keadaan semula setelah test selesai.
Cara 3: override dependency FastAPI
from fastapi.testclient import TestClient
@pytest.fixture
def client_api(llm_palsu):
app.dependency_overrides[get_llm] = lambda: llm_palsu
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
def test_endpoint_chat(client_api):
r = client_api.post("/chat", json={"pesan": "halo", "session_id": "s1"})
assert r.status_code == 200
assert r.json()["balasan"] == "jawaban palsu"
Fixture bertingkat
@pytest.fixture
def settings_uji(): ...
@pytest.fixture
def vector_store(settings_uji, tmp_path):
return ChromaStore(path=tmp_path / "chroma")
@pytest.fixture
def rag(vector_store, llm_palsu):
return RagService(store=vector_store, llm=llm_palsu)
pytest menyelesaikan grafnya sendiri; kamu cukup meminta fixture paling luar.
Fixture bawaan yang berguna
| Fixture | Fungsinya |
|---|---|
tmp_path | Folder sementara (Path), dibersihkan otomatis |
monkeypatch | Ganti atribut, env var, cwd — dipulihkan otomatis |
caplog | Tangkap output logging untuk diperiksa |
capsys | Tangkap stdout/stderr |
request | Metadata tentang test yang sedang berjalan |
def test_ingest(tmp_path):
(tmp_path / "a.md").write_text("isi dokumen", encoding="utf-8")
hasil = ingest(tmp_path)
assert len(hasil) == 1
def test_peringatan_dicatat(caplog):
proses_input_buruk()
assert "gagal parse" in caplog.text
def test_konfigurasi(monkeypatch):
monkeypatch.setenv("MAX_TOKENS", "1024")
assert Settings().max_tokens == 1024
Test async
uv add --dev pytest-asyncio
[tool.pytest.ini_options]
asyncio_mode = "auto" # tidak perlu menandai tiap test async
async def test_jawab_async(llm_palsu):
hasil = await jawab("halo", klien=llm_palsu)
assert hasil.teks
Eval bukan unit test
Menilai kualitas jawaban LLM bukan pekerjaan unit test. Itu namanya eval, dan dijalankan terpisah: punya dataset sendiri, rubrik penilaian sendiri, dan memang memanggil API asli. Mencampurnya ke dalam suite unit test membuat CI-mu lambat sekaligus tidak bisa diandalkan. Tandai terpisah:
@pytest.mark.eval
async def test_kualitas_jawaban(): ...
uv run pytest -m "not eval" # CI: cepat, gratis, deterministik
uv run pytest -m eval # dijalankan manual, berkala
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.