Sejauh ini semua dependency yang kita "ganti" saat test (database, Redis) diganti dengan versi yang BENAR-BENAR BEKERJA — SQLite in-memory beneran menyimpan data, FakeRedis beneran menghitung counter. Tapi ada satu jenis dependency yang tidak bisa (dan tidak boleh) diganti dengan versi kerja: panggilan ke layanan EKSTERNAL yang di luar kendali kita. Di artikel ini kita bahas mocking — istilah yang sudah disinggung di artikel tentang anatomi test yang baik, sekarang kita praktikkan sungguhan.
Fitur Baru: Notifikasi Saat Task Selesai
task-tracker-api sekarang punya fitur baru — mengirim notifikasi (simulasi webhook) setiap kali task ditandai selesai:
# app/notifications.py
import httpx
NOTIFICATION_ENDPOINT = "https://notifications.example-service.test/notify"
def send_task_completed_notification(task_title: str) -> None:
response = httpx.post(
NOTIFICATION_ENDPOINT,
json={"message": f"Task selesai: {task_title}"},
timeout=5.0,
)
response.raise_for_status()
Dipanggil dari endpoint update:
# app/routers/tasks.py
from app.notifications import send_task_completed_notification
@router.patch("/{task_id}", response_model=TaskRead)
def update_task(...):
...
if not was_done and task.done:
send_task_completed_notification(task.title)
return task
Membuktikan Kenapa Kita TIDAK BISA Memanggilnya Sungguhan Saat Test
NOTIFICATION_ENDPOINT sengaja diarahkan ke domain yang tidak benar-benar ada. Coba jalankan test TANPA mocking apa pun:
def test_without_mock_real_notification_call_fails(client: TestClient):
token = register_and_login(client)
created = client.post("/api/tasks", json={"title": "Tanpa mock"}, headers=auth_headers(token))
task_id = created.json()["id"]
with pytest.raises(httpx.HTTPError):
client.patch(f"/api/tasks/{task_id}", json={"done": True}, headers=auth_headers(token))
Test ini LOLOS justru karena kita MENGHARAPKAN error — pytest.raises(httpx.HTTPError) memverifikasi bahwa memang terjadi error koneksi. Ini membuktikan masalahnya nyata: kalau test lain memanggil endpoint yang sama TANPA antisipasi ini, test itu akan gagal (atau di kasus domain yang benar-benar merespons lambat, jadi lambat) karena mencoba menghubungi layanan yang tidak pernah akan merespons dengan benar. Bayangkan kalau ini API pembayaran sungguhan — kita jelas tidak mau test kita benar-benar memproses pembayaran setiap kali dijalankan.
Mocking dengan pytest-mock
Solusinya: ganti fungsi send_task_completed_notification dengan versi PALSU khusus untuk test itu, yang tidak benar-benar menghubungi jaringan:
def test_completing_task_sends_notification(client: TestClient, mocker):
mock_send = mocker.patch("app.routers.tasks.send_task_completed_notification")
token = register_and_login(client)
created = client.post("/api/tasks", json={"title": "Belajar mocking"}, headers=auth_headers(token))
task_id = created.json()["id"]
client.patch(f"/api/tasks/{task_id}", json={"done": True}, headers=auth_headers(token))
mock_send.assert_called_once_with("Belajar mocking")
mocker adalah fixture dari plugin pytest-mock (instalasi: pip install pytest-mock), pembungkus yang lebih nyaman dari unittest.mock bawaan Python. mocker.patch(...) mengganti fungsi target dengan objek Mock — objek yang menerima pemanggilan apa pun tanpa benar-benar melakukan apa-apa, TAPI mencatat setiap pemanggilan itu (persis definisi "mock" dari artikel tentang anatomi test, beda dengan "fake" yang benar-benar bekerja).
mock_send.assert_called_once_with("Belajar mocking") adalah inti dari mocking: kita tidak cuma memverifikasi HASIL AKHIR (response API), tapi memverifikasi INTERAKSI — bahwa fungsi notifikasi benar-benar dipanggil, TEPAT SATU KALI, dengan argumen yang benar.
Detail Krusial: "Patch di Tempat Dipakai", Bukan di Tempat Didefinisikan
Perhatikan baik-baik path yang di-patch: "app.routers.tasks.send_task_completed_notification" — BUKAN "app.notifications.send_task_completed_notification", walaupun fungsi itu SEBENARNYA didefinisikan di app/notifications.py.
Ini kesalahan paling umum saat belajar mocking di Python. Alasannya soal cara Python meng-import: baris from app.notifications import send_task_completed_notification di tasks.py membuat NAMA BARU send_task_completed_notification di dalam namespace modul app.routers.tasks, menunjuk ke fungsi yang sama. Kalau kita patch di app.notifications, kita cuma mengganti nama di TEMPAT ASALNYA — nama yang sudah di-import ke app.routers.tasks tetap menunjuk ke fungsi ASLI, tidak ikut berubah.
Aturan praktis: patch selalu di modul TEMPAT fungsi itu DIPAKAI (dipanggil), bukan tempat dia didefinisikan.
Memverifikasi Kasus Negatif Juga
def test_creating_task_does_not_send_notification(client: TestClient, mocker):
mock_send = mocker.patch("app.routers.tasks.send_task_completed_notification")
token = register_and_login(client)
client.post("/api/tasks", json={"title": "Task baru"}, headers=auth_headers(token))
mock_send.assert_not_called()
def test_marking_already_done_task_does_not_notify_again(client: TestClient, mocker):
mock_send = mocker.patch("app.routers.tasks.send_task_completed_notification")
token = register_and_login(client)
created = client.post("/api/tasks", json={"title": "Belajar mocking"}, headers=auth_headers(token))
task_id = created.json()["id"]
client.patch(f"/api/tasks/{task_id}", json={"done": True}, headers=auth_headers(token))
client.patch(f"/api/tasks/{task_id}", json={"done": True}, headers=auth_headers(token)) # PATCH lagi, masih "done"
mock_send.assert_called_once() # bukan dua kali
Test kedua ini membuktikan logic if not was_done and task.done: di endpoint kita bekerja benar — notifikasi cuma terkirim saat task TRANSISI dari belum selesai ke selesai, bukan setiap kali endpoint update dipanggil.
Rangkuman
mocker.patch(...) dari pytest-mock mengganti fungsi dengan objek Mock yang mencatat setiap pemanggilannya.assert_called_once_with(...), assert_not_called() memverifikasi INTERAKSI, bukan cuma hasil akhir.