Unit test di artikel sebelumnya menguji fungsi murni. Sekarang kita naik satu lapis: integration test — menguji endpoint API sungguhan lewat HTTP request, termasuk interaksinya dengan database. Ini butuh sesuatu yang belum kita bahas: cara menyiapkan (dan membersihkan) kondisi test secara konsisten, tanpa harus mengulang kode setup di setiap test.
Fixture — Kode Setup yang Bisa Dipakai Ulang
Fixture adalah fungsi yang menyiapkan sesuatu yang dibutuhkan test, dan pytest otomatis "menyuntikkannya" ke test yang memintanya lewat parameter:
import pytest
@pytest.fixture(name="session")
def session_fixture():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
Perhatikan yield, bukan return — ini pola penting. Kode SEBELUM yield adalah setup, kode SETELAH yield (kalau ada) adalah teardown, dijalankan otomatis setelah test selesai, APA PUN hasil test-nya (lolos atau gagal). Fixture ini dipakai cukup dengan menyebut namanya sebagai parameter:
def test_something(session: Session):
# `session` di sini SUDAH jadi objek Session yang siap pakai
...
Kenapa SQLite In-Memory, Bukan Postgres Sungguhan?
Aplikasi production kita pakai Postgres (lihat seri Scalability dan Security), tapi test kita pakai SQLite in-memory. Ini keputusan sengaja, langsung menerapkan prinsip Fast dan Independent dari artikel tentang anatomi test yang baik:
sqlite://) hidup cuma selama proses test berjalan, tidak pernah menyentuh disk atau perlu proses database terpisah — jauh lebih cepat untuk setup/teardown berulang-ulang.session dipanggil ulang tiap test, secara default), jadi tidak ada satu test yang "bocor" state ke test lain — prinsip Independent.poolclass=StaticPool penting di sini — SQLite in-memory secara default membuat database BARU tiap kali koneksi dibuka, yang akan merusak konsistensi kalau tidak diatur supaya tetap satu koneksi yang sama dipakai selama test itu berjalan.
Dependency Override — Mengganti Dependency Aplikasi Sungguhan dengan Versi Test
Ingat Depends(get_session) yang sudah kita pakai sejak awal seri FastAPI? Sekarang manfaatnya benar-benar terasa. FastAPI menyediakan app.dependency_overrides, sebuah dictionary yang memetakan dependency ASLI ke versi PENGGANTI:
from app.database import get_session
from app.main import app
@pytest.fixture(name="client")
def client_fixture(session: Session, cache: FakeRedis):
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_redis] = lambda: cache
client = TestClient(app)
yield client
app.dependency_overrides.clear()
Begitu get_session di-override, SETIAP endpoint yang memakai Depends(get_session) — tanpa terkecuali, tanpa perlu mengubah satu baris pun kode endpoint — otomatis memakai session versi test kita, bukan koneksi Postgres sungguhan. Ini bukti nyata kenapa dependency injection (dibahas sejak seri FastAPI) bukan cuma "gaya penulisan kode yang rapi" — ini yang membuat aplikasi bisa DIUJI tanpa mengubah kode aplikasinya sama sekali.
app.dependency_overrides.clear() di akhir (setelah yield) itu WAJIB — tanpa ini, override akan "bocor" ke test lain yang berjalan setelahnya, melanggar prinsip Independent.
Jebakan yang Kami Temukan Sendiri: Lifespan yang Menyasar Database Sungguhan
Satu detail yang gampang terlewat, dan sempat benar-benar bikin test kami gagal saat menyiapkan seri ini:
client = TestClient(app) # BUKAN: with TestClient(app) as client:
Kenapa TIDAK dipakai sebagai context manager (with ... as client:)? Karena main.py kita punya lifespan yang memanggil create_db_and_tables() saat startup — dan fungsi itu memakai engine ASLI yang menunjuk ke Postgres, BUKAN session versi test kita (dependency override cuma berlaku untuk Depends(...), bukan kode yang dipanggil langsung di lifespan). Kalau TestClient dipakai sebagai context manager, lifespan itu benar-benar dijalankan, mencoba konek ke Postgres yang tidak ada saat test berjalan — dan test gagal total, bukan karena bug aplikasi, tapi karena kesalahan setup test itu sendiri.
Solusinya sederhana: jangan pakai context manager, supaya lifespan tidak pernah terpicu. Tabel test sudah dibuat manual lewat SQLModel.metadata.create_all(engine) di fixture session. Ini pengingat bagus: kesalahan di test itu sendiri sama mungkinnya dengan kesalahan di kode aplikasi — kalau test tiba-tiba gagal semua tanpa alasan jelas, curigai juga setup test-nya, bukan langsung berasumsi kode aplikasinya yang rusak.
Menulis Integration Test Pertama
def test_register_creates_user(client: TestClient):
response = client.post("/auth/register", json={"username": "alice", "password": "supersecret123"})
assert response.status_code == 201
assert response.json()["username"] == "alice"
def test_protected_endpoint_rejects_without_token(client: TestClient):
response = client.get("/api/tasks")
assert response.status_code == 401
Perhatikan betapa singkatnya test ini SETELAH fixture-nya siap — client sudah otomatis terhubung ke database test yang bersih, tanpa test itu sendiri perlu tahu detail bagaimana itu terjadi. Ini yang membuat fixture begitu berharga: kompleksitas setup ditulis SEKALI di conftest.py, dipakai ulang oleh puluhan test.
Jalankan seluruh suite:
pytest -v
tests/integration/test_auth.py::test_register_creates_user PASSED
tests/integration/test_auth.py::test_protected_endpoint_rejects_without_token PASSED
...
======================= 41 passed in 20.02s =======================
Rangkuman
@pytest.fixture) adalah kode setup yang bisa dipakai ulang, dengan yield memisahkan setup (sebelum) dari teardown (sesudah).app.dependency_overrides mengganti dependency aplikasi sungguhan dengan versi test, TANPA mengubah kode endpoint sama sekali.with TestClient(app) as client: kalau lifespan aplikasi menyentuh resource sungguhan (seperti Postgres) yang tidak tersedia saat test.