Kita sudah punya banyak test — unit, integration, parametrized, dengan mocking untuk dependency eksternal. Pertanyaan alami berikutnya: seberapa banyak dari kode kita yang benar-benar tercakup test? Di artikel ini kita ukur dengan test coverage, sekaligus membahas jujur apa yang angka ini TIDAK bisa katakan — supaya tidak terjebak mengejar angka yang salah.
Mengukur Coverage dengan pytest-cov
pip install pytest-cov
pytest --cov=app --cov-report=term-missing
Hasilnya, dijalankan sungguhan di task-tracker-api:
Name Stmts Miss Cover Missing
-------------------------------------------------------
app/__init__.py 0 0 100%
app/config.py 16 0 100%
app/database.py 12 6 50% 15-18, 22-23
app/dependencies.py 16 1 94% 28
app/logging_config.py 17 1 94% 17
app/main.py 29 2 93% 18-19
app/models.py 20 0 100%
app/notifications.py 8 2 75% 23-24
app/rate_limit.py 14 1 93% 10
app/routers/auth.py 26 0 100%
app/routers/tasks.py 54 3 94% 23, 107-108
app/schemas.py 29 0 100%
app/security.py 21 0 100%
-------------------------------------------------------
TOTAL 262 16 94%
94% total — kelihatannya bagus. Tapi mari kita bongkar baris app/database.py yang cuma 50%, karena di situlah pelajaran paling pentingnya.
Membaca --cov-report=term-missing
Kolom Missing menunjukkan NOMOR BARIS yang tidak pernah dieksekusi test mana pun. Untuk app/database.py, itu baris 15-18 dan 22-23:
def create_db_and_tables() -> None: # baris 14
try: # baris 15 — TIDAK tercakup
SQLModel.metadata.create_all(engine) # baris 16 — TIDAK tercakup
except (ProgrammingError, IntegrityError): # baris 17 — TIDAK tercakup
pass # baris 18 — TIDAK tercakup
def get_session(): # baris 21
with Session(engine) as session: # baris 22 — TIDAK tercakup
yield session # baris 23 — TIDAK tercakup
Kenapa fungsi-fungsi ini tidak tercakup, padahal jelas-jelas ada di kode yang jalan di production? Karena di artikel sebelumnya, kita OVERRIDE get_session dengan versi test lewat app.dependency_overrides — implementasi ASLI fungsi ini TIDAK PERNAH benar-benar dipanggil selama test berjalan, karena FastAPI selalu memakai versi override-nya.
Pelajaran Inti: Angka Tinggi Bisa Menyembunyikan Kode yang Sama Sekali Tidak Teruji
Ini poin paling penting di artikel ini: 94% coverage BUKAN berarti 94% dari PERILAKU aplikasi sudah teruji. Coverage cuma mengukur baris kode mana yang DIEKSEKUSI selama test berjalan — tidak peduli apakah hasilnya benar-benar DIPERIKSA (assert) atau tidak, dan sama sekali tidak tahu soal kode yang sengaja "dilewati" lewat mocking atau dependency override, seperti kasus database.py di atas.
Implikasinya: kalau create_db_and_tables() punya bug (misalnya salah nama exception yang di-catch), coverage report kita TIDAK AKAN PERNAH memberi tahu itu — karena fungsinya memang tidak pernah benar-benar dijalankan test mana pun. Ini bukan cacat di test suite kita (dependency override itu sendiri tujuannya justru BENAR — kita memang tidak mau test menyentuh Postgres sungguhan), tapi pengingat bahwa fungsi seperti ini butuh jenis verifikasi LAIN — misalnya, dicoba manual sekali saat setup awal project (seperti yang kita lakukan di seri-seri sebelumnya saat memverifikasi docker compose up benar-benar berhasil membuat tabel).
Kenapa 100% Coverage Bukan Tujuan
Mengejar 100% coverage sebagai target itu jebakan umum. Dua alasan:
assert apa pun tetap menaikkan angka coverage, walaupun tidak membuktikan apa-apa. Angka itu sendiri tidak membedakan test yang benar-benar memverifikasi perilaku dari test yang cuma "menyentuh" kode.__repr__ untuk debugging, atau kode yang cuma jalan di kondisi environment yang sangat spesifik — usaha untuk memaksa 100% di situ sering kali menghasilkan test yang rapuh dan mahal dipelihara, tanpa manfaat proporsional.Target yang lebih sehat: fokus pada BUSINESS LOGIC dan jalur kritis (autentikasi, otorisasi, validasi, kalkulasi penting) — persis yang sudah kita bangun sejak artikel-artikel sebelumnya. Coverage yang tinggi di area itu jauh lebih berharga daripada coverage tinggi merata di semua file tanpa pandang penting-tidaknya.
Sekilas: Mutation Testing — Melangkah Lebih Jauh dari Coverage
Kalau coverage cuma bilang "baris ini dieksekusi", ada teknik yang lebih ketat: mutation testing. Tool-nya (misalnya mutmut untuk Python) sengaja MERUSAK kode kita sedikit demi sedikit (mengubah == jadi !=, > jadi <, dst.) lalu menjalankan test suite — kalau test tetap LOLOS walau kodenya sudah dirusak, itu tanda test-nya tidak benar-benar memeriksa perilaku itu, walaupun coverage-nya bilang 100%. Ini di luar cakupan seri dasar ini, tapi baik untuk diketahui sebagai langkah lanjutan (juga disebut di roadmap artikel penutup seri ini) begitu test suite sudah cukup matang.
Rangkuman
pytest-cov mengukur baris kode mana yang dieksekusi selama test berjalan, bukan seberapa BENAR perilakunya diverifikasi.get_session asli) akan MUNCUL sebagai tidak tercakup — ini bukan bug, tapi konsekuensi wajar dari cara kita mengisolasi test.