Ada satu hal yang bikin test suite jadi lambat dan hasilnya tidak bisa diprediksi: kode yang bergantung pada sesuatu di luar kendalimu. Panggilan HTTP ke API pihak ketiga, koneksi database, baca file dari disk, sampai datetime.now() yang nilainya terus bergeser. Kalau test menyentuh dependensi asli semacam itu, hasilnya bisa beda tiap kali dijalankan — kadang gagal padahal kode kamu tidak berubah. Solusinya bukan menghindari test, tapi memalsukan dependensinya. Teknik ini namanya mocking.

Kabar baiknya, Python sudah menyertakan modul unittest.mock bawaan. Tidak perlu install apa pun. Kalau kamu biasa menulis test dengan pytest, semua yang saya bahas di sini tetap relevan dan bisa langsung dipakai berdampingan.

Kapan mocking diperlukan

Panggilan ke API eksternal adalah kandidat paling klasik. Test tidak boleh gagal cuma karena koneksi internet putus, limit API habis, atau layanan pihak ketiga sedang down. Masalahnya, mock bukan cuma soal kecepatan — ia soal determinisme. Test yang sama harus selalu memberi hasil yang sama, kapan pun dan di mesin mana pun dijalankan.

Akses filesystem dan database juga sering di-mock. Keduanya butuh setup tambahan dan bisa meninggalkan state kotor setelah test selesai. Lalu ada waktu dan angka acak. datetime.now() membuat hasil test bergantung pada jam berapa kamu menjalankannya, sedangkan random() membuat hasil tidak bisa diulang. Memalsukan keduanya bikin test jadi stabil.

Mock dan MagicMock: objek tiruan

Objek dasar di unittest.mock adalah Mock. Ia mencatat setiap panggilan yang diterimanya, lalu mengembalikan Mock baru untuk atribut apa pun yang kamu akses. Artinya kamu bisa menelusuri apa saja tanpa error. MagicMock adalah versi lebih lengkapnya yang sudah punya implementasi magic method seperti __len__ dan __iter__.

from unittest.mock import Mock, MagicMock

m = Mock()
print(m.some_attribute)       # <Mock name='mock.some_attribute' id='...'>
print(m.some_method(1, 2))    # <Mock name='mock.some_method()' id='...'>

m.some_method(1, 2)
m.some_method.assert_called_once_with(1, 2)   # lolos tanpa error

mm = MagicMock()
print(len(mm))                # 0, karena __len__ terdefinisi

Perhatikan m.some_attribute tidak error walau atribut itu tidak pernah didefinisikan. Mock memang dirancang serba menerima. Untuk penggunaan sehari-hari saya hampir selalu memilih MagicMock, karena kode produksi sering memanggil len() atau iterasi dan Mock polos akan melempar TypeError di situ.

patch: mengganti objek di tempat ia dipakai

patch adalah cara paling umum memasang mock. Ada satu aturan yang paling sering bikin pemula tersandung: kamu harus mem-patch objek di tempat ia dipakai, bukan di tempat ia didefinisikan. Saya ulangi karena ini krusial — patch mengikuti import, bukan lokasi definisinya.

Bayangkan modul weather.py yang memanggil requests.get seperti ini:

python
# weather.py
import requests

def suhu_kota(kota: str) -> float:
    resp = requests.get(f"https://api.cuaca.dev/{kota}")
    resp.raise_for_status()
    return resp.json()["suhu"]

Di file test, kamu patch lewat path weather.requests.get, bukan requests.get. Alasannya, weather.py melakukan import requests sehingga nama requests hidup di namespace modul weather. Kalau kamu mem-patch requests.get secara global, weather.requests tetap menunjuk modul asli dan mock kamu tidak pernah terpakai.

python
# test_weather.py
from unittest.mock import patch
import weather

@patch("weather.requests.get")
def test_suhu_kota(mock_get):
    mock_get.return_value.json.return_value = {"suhu": 27.5}

    hasil = weather.suhu_kota("bandung")

    assert hasil == 27.5
    mock_get.assert_called_once_with("https://api.cuaca.dev/bandung")

Dekorator @patch menyuntikkan mock sebagai argumen ekstra ke fungsi test. mock_get.return_value menggantikan objek respons requests.get, jadi .json()-nya pun perlu kamu set sendiri. Pola berlapis ini — mock mengembalikan mock — memang terasa aneh di awal. Setelah beberapa kali menulis, kamu akan terbiasa.

Kalau kamu memakai pytest, plugin pytest-mock menyediakan fixture mocker yang membungkus patch dan otomatis membersihkannya setelah tiap test. Ada juga monkeypatch bawaan pytest untuk kasus sederhana, tapi untuk mock bertingkat saya tetap memilih unittest.mock langsung.

return_value dan side_effect

return_value menentukan nilai yang dikembalikan mock setiap kali dipanggil. side_effect lebih fleksibel: bisa berupa iterable untuk nilai bergantian, exception yang dilempar, atau fungsi yang dipanggil dengan argumen aslinya.

python
from unittest.mock import Mock

# nilai bergantian tiap panggilan
m = Mock(side_effect=[10, 20, 30])
print(m(), m(), m())              # 10 20 30

# melempar exception
m2 = Mock(side_effect=ConnectionError("timeout"))
try:
    m2()
except ConnectionError as e:
    print(e)                      # timeout

# fungsi dinamis
m3 = Mock(side_effect=lambda x: x * 2)
print(m3(21))                     # 42

Kalau return_value dan side_effect diset bersamaan, side_effect yang menang dan return_value diabaikan. Manfaatkan ini untuk meniru perilaku nyata: dua panggilan pertama sukses, panggilan ketiga melempar TimeoutError — cocok untuk menguji logika retry.

Memverifikasi panggilan mock

Nilai terbesar dari mock bukan sekadar mengembalikan nilai palsu, tapi merekam bagaimana ia dipanggil. Kamu bisa memeriksa jumlah panggilan, argumennya, sampai urutannya.

python
from unittest.mock import Mock, call

service = Mock()
service.connect("db1")
service.connect("db2")
service.disconnect()

service.assert_called()                     # lolos
service.connect.assert_any_call("db1")      # lolos
assert service.call_count == 3
assert service.connect.call_args_list == [call("db1"), call("db2")]

service.connect.assert_called_once_with("db1")  # AssertionError

Baris terakhir sengaja gagal karena connect dipanggil dua kali, bukan sekali. Pesan error dari unittest.mock lumayan membantu — ia menampilkan panggilan aktual berbanding yang diharapkan, jadi kamu tidak perlu menebak-nebak.

Jangan memasang assert_called_with untuk setiap detail kecil. Kalau test pecah setiap kali urutan argumen internal berubah padahal perilaku luar tetap sama, mock kamu terlalu ketat dan test jadi rapuh. Verifikasi hal yang benar-benar penting saja.

Mocking waktu dengan patch datetime

Fungsi yang menghitung selisih hari atau menandai timestamp susah diuji karena datetime.now() terus berubah. Solusinya sama seperti sebelumnya — patch datetime di modul yang memakainya, bukan modul datetime global.

python
# promo.py
from datetime import datetime

def masih_berlaku(batas: datetime) -> bool:
    return datetime.now() < batas
python
from unittest.mock import patch
from datetime import datetime
import promo

@patch("promo.datetime")
def test_masih_berlaku(mock_dt):
    mock_dt.now.return_value = datetime(2026, 10, 1)

    assert promo.masih_berlaku(datetime(2026, 10, 15)) is True
    assert promo.masih_berlaku(datetime(2026, 9, 1)) is False

Kamu patch promo.datetime, bukan datetime.datetime. Sekarang now() mengembalikan tanggal yang kamu tentukan, dan test menghasilkan hasil identik kapan pun dijalankan — bahkan di tengah malam pergantian tahun.

spec dan autospec: menangkap typo

Mock biasa menerima atribut apa pun, termasuk yang tidak pernah ada. Kalau kamu typo — misalnya menulis resp.jsn() alih-alih resp.json() — test tetap lolos karena mock mengembalikan Mock baru untuk jsn. Bug semacam ini lolos diam-diam. Di sinilah spec dan autospec berperan.

python
from unittest.mock import Mock, create_autospec
import requests

# tanpa spec: atribut apa pun dianggap valid
m = Mock()
m.metode_yang_tidak_ada()       # tidak error, hanya mengembalikan Mock baru

# dengan autospec: hanya atribut asli yang valid
fake_response = create_autospec(requests.Response)
fake_response.json()            # OK
# fake_response.jsn()           # AttributeError

create_autospec membuat mock yang meniru antarmuka objek aslinya, jadi atribut yang tidak ada langsung melempar AttributeError. autospec=True di patch memberi efek yang sama. Manfaatnya dua: typo ketahuan seketika, dan kalau signature fungsi asli berubah, test ikut menyesuaikan sehingga perubahan tidak lewat tanpa disadari.

Alternatif: MockTransport untuk httpx

Kalau project kamu memakai httpx sebagai HTTP client, ada cara yang lebih bersih daripada patch berlapis: httpx.MockTransport. Ia mengganti transport di level klien, jadi fungsi yang diuji tidak perlu di-patch sama sekali.

python
import httpx

def ambil_judul(client: httpx.Client, url: str) -> str:
    return client.get(url).json()["title"]

def handler(request: httpx.Request) -> httpx.Response:
    return httpx.Response(200, json={"title": "Belajar Mocking"})

with httpx.Client(transport=httpx.MockTransport(handler)) as client:
    print(ambil_judul(client, "https://contoh.dev/1"))  # Belajar Mocking

Pendekatan ini disukai banyak orang karena kode produksi tidak disentuh dan tidak ada patch ajaib yang menyembunyikan apa yang sedang terjadi. Kamu tinggal menyuntikkan klien dengan transport palsu. Untuk test FastAPI yang memanggil HTTP keluar, pola yang sama bisa digabung dengan fixture dan dependency override.

Langkah selanjutnya

Mulai dari test paling lambat di project kamu. Cari satu fungsi yang memanggil API eksternal atau membaca jam, pasang mock dengan patch, lalu bandingkan durasi test sebelum dan sesudah. Setelah pola dasarnya terasa, pelajari AsyncMock untuk kode async — fungsi async def tidak bisa di-mock dengan Mock biasa dan butuh perlakuan khusus.

Aturan praktis yang saya pegang: mock hanya untuk batas sistem — jaringan, disk, waktu, angka acak — bukan untuk logika bisnis yang kamu tulis sendiri. Kalau kamu mulai me-mock class buatan sendiri, itu pertanda desainnya perlu diperbaiki, bukan test-nya.