← Semua pembelajaran / Python untuk AI Engineer
Fase 6 · Data, Testing, Deploy

pytest — How to use fixtures

Fixture: setup dan teardown yang bisa dipakai ulang, dan cara mengganti panggilan LLM asli dengan yang palsu.

Sumber asli docs.pytest.org Resmi Rangkuman ~6 menit baca

Intisari

  • Fixture adalah fungsi bertanda @pytest.fixture; test memintanya dengan menuliskan namanya sebagai parameter.
  • yield di dalam fixture memisahkan setup (sebelum) dan teardown (sesudah).
  • scope mengatur seberapa sering fixture dibuat: function (default), module, session.
  • conftest.py menyediakan 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

ScopeDibuatUntuk
function (default)Tiap testData yang bisa berubah
classTiap class testJarang
moduleTiap file testKoneksi yang mahal dibuat
sessionSekali seluruh runContainer 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

FixtureFungsinya
tmp_pathFolder sementara (Path), dibersihkan otomatis
monkeypatchGanti atribut, env var, cwd — dipulihkan otomatis
caplogTangkap output logging untuk diperiksa
capsysTangkap stdout/stderr
requestMetadata 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.