Beranda / Kursus / Testing for Beginners / II — Praktik
II — Praktik
Artikel 8 dari 9

Praktik: TDD — Red, Green, Refactor

Membangun fitur pencarian task dari nol lewat siklus Test-Driven Development: menulis test yang pasti gagal (Red), kode paling sederhana supaya lolos (Green), lalu merapikan tanpa mengubah perilaku (Refactor).

10 Juli 2026

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 judulGET /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

  • Red — tulis test yang mendefinisikan perilaku yang diinginkan, PASTIKAN dia gagal dulu (membuktikan test-nya benar-benar menguji sesuatu).
  • Green — tulis kode SESEDIKIT MUNGKIN supaya test lolos, tahan godaan menulis versi "sempurna" di awal.
  • Refactor — rapikan kode dengan aman, selama semua test tetap Hijau, perilaku terjamin tidak berubah.
  • Urutan registrasi route di FastAPI itu penting — path spesifik (/search) harus didaftarkan SEBELUM path dinamis (/{task_id}) yang bisa menangkapnya secara tidak sengaja.
  • Test keamanan (kepemilikan resource, validasi input) harus jadi bagian dari siklus TDD sejak awal, bukan ditambahkan belakangan.
  • Topik

    TestingTDD