Sejauh ini, di setiap artikel praktik, kita menulis test untuk kode yang SUDAH ADA. Di artikel ini kita balik urutannya: menulis test SEBELUM fitur itu sendiri ditulis sama sekali. Ini disebut Test-Driven Development (TDD), dengan siklus tiga langkah: Red (tulis test yang gagal), Green (tulis kode sesedikit mungkin supaya lolos), Refactor (rapikan tanpa mengubah perilaku).
Fitur yang kita bangun: pencarian task berdasarkan judul — GET /api/tasks/search?q=<kata kunci>.
Red — Tulis Test yang PASTI Gagal
Sebelum satu baris kode endpoint ditulis, kita tulis dulu bagaimana kita EKSPEKTASIKAN fitur ini bekerja:
# tests/integration/test_search_tasks.py
def test_search_returns_matching_tasks(client: TestClient):
token = register_and_login(client)
client.post("/api/tasks", json={"title": "Belajar FastAPI"}, headers=auth_headers(token))
client.post("/api/tasks", json={"title": "Belajar Testing"}, headers=auth_headers(token))
client.post("/api/tasks", json={"title": "Beli kopi"}, headers=auth_headers(token))
response = client.get("/api/tasks/search", params={"q": "belajar"}, headers=auth_headers(token))
assert response.status_code == 200
titles = {task["title"] for task in response.json()}
assert titles == {"Belajar FastAPI", "Belajar Testing"}
Jalankan:
pytest tests/integration/test_search_tasks.py::test_search_returns_matching_tasks -v
FAILED — assert 404 == 200
Merah, seperti diharapkan. Endpoint /api/tasks/search belum ada sama sekali, jadi FastAPI mencoba mencocokkannya ke /{task_id} (dengan task_id="search"), gagal validasi tipe int, dan mengembalikan error. Titik ini penting: kita BELUM menulis kode fitur, tapi kita SUDAH punya definisi jelas soal "fitur ini dianggap selesai kalau test ini lolos".
Kenapa Menulis Test Duluan?
Manfaat yang langsung terasa: menulis test SEBELUM implementasi memaksa kita berpikir dari sudut pandang PEMAKAI endpoint (apa input-nya, apa output yang diharapkan) sebelum terjebak detail implementasi. Ini juga otomatis memverifikasi bahwa test kita BENAR-BENAR bisa gagal — kalau langsung kita tulis implementasinya dulu baru test-nya, ada risiko test itu "kebetulan lolos" walau logic-nya salah, tanpa kita pernah tahu.
Green — Tulis Kode SESEDIKIT MUNGKIN Supaya Lolos
Godaan di titik ini adalah langsung menulis versi "sempurna". Tahan dulu — fokus cuma membuat test tadi LOLOS:
@router.get("/search", response_model=List[TaskRead])
def search_tasks(
q: str = Query(min_length=1, max_length=200),
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
query = select(Task).where(
Task.owner_id == current_user.id,
Task.title.ilike(f"%{q}%"),
)
return session.exec(query).all()
Jalankan lagi:
PASSED
Hijau. Tapi sebelum lanjut, ada satu detail yang HARUS diperhatikan — urutan registrasi route:
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
@router.get("", response_model=List[TaskRead])
def list_tasks(...): ...
@router.get("/search", response_model=List[TaskRead]) # WAJIB sebelum "/{task_id}"
def search_tasks(...): ...
@router.get("/{task_id}", response_model=TaskRead) # kalau ini duluan, "/search" akan
def get_task(...): ... # tertangkap sebagai task_id="search"
FastAPI (lewat Starlette) mencocokkan path secara BERURUTAN sesuai urutan didaftarkan — path pertama yang cocok yang dipakai. Kalau /{task_id} didaftarkan LEBIH DULU dari /search, request ke /api/tasks/search akan tertangkap sebagai task_id="search", gagal validasi karena "search" bukan integer. Ini persis jenis bug yang MUDAH lolos kalau cuma dites manual sekali dengan urutan "kebetulan benar", tapi test otomatis seperti ini akan langsung menangkapnya kalau urutan registrasinya salah.
Menambah Lebih Banyak Test — Tetap Siklus Red-Green
TDD bukan cuma satu putaran. Kita lanjutkan dengan skenario berikutnya:
def test_search_is_case_insensitive(client: TestClient):
token = register_and_login(client)
client.post("/api/tasks", json={"title": "Belajar FastAPI"}, headers=auth_headers(token))
response = client.get("/api/tasks/search", params={"q": "FASTAPI"}, headers=auth_headers(token))
assert response.status_code == 200
assert len(response.json()) == 1
Kebetulan, implementasi kita (Task.title.ilike(...)) SUDAH case-insensitive dari awal (ilike = "insensitive like"), jadi test ini langsung Hijau tanpa perlu perubahan kode. Ini juga valid dalam TDD — kadang implementasi yang sudah ada ternyata sudah mencakup skenario baru, dan test barunya berfungsi sebagai DOKUMENTASI HIDUP yang membuktikan itu, bukan cuma asumsi.
Dua skenario keamanan yang tidak boleh dilewatkan (ingat Security for Beginners):
def test_search_only_returns_own_tasks(client: TestClient):
alice_token = register_and_login(client, username="alice")
bob_token = register_and_login(client, username="bob")
client.post("/api/tasks", json={"title": "Rahasia Alice"}, headers=auth_headers(alice_token))
response = client.get("/api/tasks/search", params={"q": "rahasia"}, headers=auth_headers(bob_token))
assert response.json() == []
def test_search_empty_query_rejected(client: TestClient):
token = register_and_login(client)
response = client.get("/api/tasks/search", params={"q": ""}, headers=auth_headers(token))
assert response.status_code == 422
Test pertama memverifikasi filter Task.owner_id == current_user.id tidak terlupa di endpoint baru ini — kalau kita lupa menambahkannya, test ini langsung Merah, menangkap regresi keamanan SEBELUM sempat di-deploy. Test kedua terjadi otomatis lolos karena Query(min_length=1, ...) — validasi Pydantic yang sudah kita bahas prinsipnya sejak seri FastAPI.
Refactor — Rapikan Tanpa Mengubah Perilaku
Dengan semua test Hijau, sekarang saat yang AMAN untuk refactor — misalnya, kalau ada duplikasi antara list_tasks dan search_tasks yang ingin dirapikan, atau nama variabel yang ingin diperjelas. Kuncinya: refactor TIDAK mengubah assert di test manapun. Setelah refactor, jalankan ulang seluruh suite:
pytest -v
======================= 41 passed in 20.02s =======================
Kalau semua tetap Hijau, refactor berhasil TANPA merusak perilaku yang sudah terverifikasi — inilah confidence to change yang dibahas di artikel pertama seri ini, sekarang dirasakan langsung, bukan cuma teori.
Rangkuman
/search) harus didaftarkan SEBELUM path dinamis (/{task_id}) yang bisa menangkapnya secara tidak sengaja.