Endpoint FastAPI yang berjalan di production tanpa test itu seperti jembatan yang belum diinspeksi. Semua terasa aman sampai satu perubahan kecil mengubah struktur response JSON, dan yang menemukannya duluan justru klienmu, bukan kamu. Kabar baiknya: menulis test untuk FastAPI tidak lebih sulit daripada menulis endpoint-nya. FastAPI menyediakan TestClient yang dibangun di atas httpx, dan pytest yang biasa kamu pakai untuk testing Python langsung bisa dipakai di sini.
Siapkan Proyek
Mulai dari folder kosong. Saya pakai venv bawaan Python, tapi kalau kamu sudah terbiasa dengan uv hasilnya sama saja. Yang penting tiga paket ini terpasang: fastapi, pytest, dan httpx (TestClient butuh httpx di balik layar).
mkdir fastapi-testing && cd fastapi-testing
python -m venv .venv && source .venv/bin/activate
pip install fastapi pytest httpx
pytest --version
Aplikasi yang Akan Diuji
Biar punya sesuatu untuk diuji, bikin API toko sederhana dengan dua endpoint: GET untuk membaca item dan POST untuk menambah item. Kalau kamu belum pernah menyentuh FastAPI, tutorial FastAPI dari nol adalah titik awal yang lebih pas. Simpan file ini sebagai main.py.
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
def get_db():
# Di dunia nyata ini jadi koneksi SQLAlchemy ke PostgreSQL
db = {"items": {1: {"name": "Buku", "price": 50000.0}}}
yield db
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/items/{item_id}")
def read_item(item_id: int, db=Depends(get_db)):
item = db["items"].get(item_id)
if item is None:
raise HTTPException(status_code=404, detail="Item not found")
return {"id": item_id, **item}
@app.post("/items")
def create_item(item: Item, db=Depends(get_db)):
item_id = max(db["items"], default=0) + 1
db["items"][item_id] = item.model_dump()
return {"id": item_id, **item.model_dump()}
Test Pertama dengan TestClient
TestClient membungkus aplikasi FastAPI dan memungkinkan kamu memanggil endpoint lewat .get(), .post(), dan seterusnya, tanpa server yang benar-benar jalan. Responsenya mirip response httpx: ada .status_code dan .json(). Simpan sebagai test_main.py.
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_read_item_ditemukan():
response = client.get("/items/1")
assert response.status_code == 200
assert response.json()["name"] == "Buku"
def test_read_item_tidak_ada():
response = client.get("/items/999")
assert response.status_code == 404
assert response.json()["detail"] == "Item not found"
Jalankan pytest. Tiga test hijau berarti kamu sudah punya jaring pengaman pertama — siapa pun yang mengubah main.py nanti akan langsung tahu kalau ada yang rusak.
pytest -v
Fixture: Jangan Ulangi TestClient di Setiap File
Menulis client = TestClient(app) di setiap file lama-lama berulang. Pindahkan ke conftest.py supaya pytest otomatis menyediakannya sebagai fixture bernama client, lengkap dengan lifecycle yang benar lewat context manager.
import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture
def client():
with TestClient(app) as c:
yield c
Sekarang test cukup menerima client sebagai parameter. Fixture ini dipanggil ulang untuk tiap test, jadi tidak ada state yang bocor dari satu test ke test berikutnya.
Test POST dan Validasi Pydantic
POST lebih menarik karena melibatkan validasi Pydantic. Kirim JSON yang benar dan test jalur suksesnya; kirim data yang salah dan pastikan FastAPI menolak dengan status 422.
def test_create_item_sukses(client):
response = client.post("/items", json={"name": "Pensil", "price": 2500})
assert response.status_code == 200
body = response.json()
assert body["id"] == 2
assert body["name"] == "Pensil"
assert body["price"] == 2500.0
def test_create_item_harga_invalid(client):
response = client.post("/items", json={"name": "Pensil", "price": "mahal"})
assert response.status_code == 422
Lelah menjalankan seluruh test hanya untuk satu perubahan? pytest test_main.py::test_create_item_sukses -v menjalankan satu test saja. Tambahkan -x supaya pytest berhenti di kegagalan pertama.
Dependency Override: Putus Koneksi ke Database Asli
Lihat lagi get_db di main.py. Endpoint membaca data lewat dependency itu, dan di dunia nyata fungsinya terhubung ke database sungguhan. Test tidak boleh menyentuh database production, jadi kita ganti dependency-nya lewat app.dependency_overrides. Pastikan juga kamu membersihkannya setelah test selesai supaya tidak merembet ke test lain.
import pytest
from fastapi.testclient import TestClient
from main import app, get_db
@pytest.fixture
def client_db_kosong():
def override_get_db():
yield {"items": {}}
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
def test_create_item_ke_database_kosong(client_db_kosong):
response = client_db_kosong.post(
"/items", json={"name": "Pensil", "price": 2500}
)
assert response.status_code == 200
assert response.json() == {"id": 1, "name": "Pensil", "price": 2500.0}
Test yang menghantam database production atau API eksternal itu lambat dan flaky, plus kamu bisa tidak sengaja menulis data palsu ke sistem yang dipakai orang banyak. Selalu override dependency yang menyentuh dunia luar.
Ukur Coverage dengan pytest-cov
Test tanpa laporan coverage seperti berkendara tanpa speedometer — kamu tidak tahu bagian mana yang belum terlindungi. Pasang pytest-cov dan lihat baris mana yang belum tersentuh test.
pip install pytest-cov
pytest --cov=main --cov-report=term-missing
Dari sini, langkah logis berikutnya adalah mencolokkan test ke pipeline CI supaya setiap push otomatis diuji. Kalau kamu pakai GitHub, tutorial GitHub Actions ini menunjukkan cara menjalankan pytest di workflow YAML. Mulai dari endpoint yang paling kritis di aplikasimu sekarang.