Anda memiliki 90% code coverage dan tetap dipanggil jam 2 pagi.
Unit test-nya lulus. CI berwarna hijau. Bug-nya tetap masuk ke produksi. Coverage tidak berbohong, tapi tidak mengatakan kebenaran juga. Ia mengukur baris mana yang dieksekusi, bukan perilaku mana yang sebenarnya diverifikasi.
Kebanyakan tim menyadari ini dengan cara yang sulit. Mereka menulis ratusan unit test, melihat badge coverage berubah menjadi hijau, dan menganggap benteng tersebut aman. Benteng itu punya dinding. Hanya saja tidak punya atap.
Unit Test Hanya Menguji Apa yang Anda Bayangkan Bisa Salah
Unit test memvalidasi asumsi Anda tentang kode Anda. Masalahnya adalah bug tidak peduli dengan asumsi Anda.
Pertimbangkan sebuah fungsi pricing yang sederhana:
def calculate_total(items, tax_rate):
subtotal = sum(item["price"] * item["quantity"] for item in items)
tax = subtotal * tax_rate
return round(subtotal + tax, 2)
Sebuah unit test suite yang tipikal terlihat solid:
def test_calculate_total_with_tax():
items = [{"price": 10.00, "quantity": 2}]
assert calculate_total(items, 0.08) == 21.60
def test_calculate_total_empty_cart():
assert calculate_total([], 0.08) == 0.00
Keduanya lulus. Coverage-nya 100%. Fungsi tersebut dikirim.
Kemudian seorang pelanggan di Jepang checkout dengan tiga item yang berharga ¥100, ¥100, dan ¥100. Tax rate-nya adalah 0.10. Total yang diharapkan adalah ¥330. Fungsi tersebut mengembalikan ¥330.00. Baik.
Seorang pelanggan di Swiss membeli satu item seharga CHF 12.35 dengan VAT 7.7%. Yang diharapkan: CHF 13.30. Aktual: CHF 13.30. Masih baik.
Kemudian seorang pelanggan membeli dua item seharga $0.01 masing-masing di Oregon, di mana tax rate-nya adalah 0.0. Yang diharapkan: $0.02. Aktual: $0.02. Lulus.
Bug muncul ketika seorang pelanggan di sebuah jurisdiction dengan tax rate None (karena tax service mengembalikan null untuk zip code yang tidak dikenal) mencoba checkout. Fungsi tersebut mengalikan subtotal * None dan melempar TypeError. Unit test Anda tidak pernah melewatkan None sebagai tax rate karena Anda mengasumsikan itu akan selalu berupa float.
Ini adalah keterbatasan fundamental. Unit test menjalankan jalur yang Anda pikir untuk diuji. Bug hidup di jalur yang tidak Anda uji.
Empat Tempat yang Tidak Bisa Dijangkau Unit Test
Integration Boundaries
Unit test mengganti external dependencies dengan mocks. Mocks itu sopan. Mereka melakukan persis apa yang Anda perintahkan. Real API tidak sopan.
Mock database Anda mengembalikan baris dalam milidetik. Produksi mengembalikannya dalam detik, atau timeout, atau mengembalikan baris duplikat karena read replica lag yang Anda tidak tahu ada.
Mock HTTP client Anda mengembalikan JSON yang bersih. Real service mengembalikan 200 dengan body kosong pada hari Selasa.
Mocks menguji kode Anda terhadap asumsi Anda tentang sistem lain. Produksi menguji kode Anda terhadap realitas. Ini adalah test suite yang berbeda dengan pass rate yang berbeda.
Stateful and Temporal Bugs
Unit test berjalan secara isolasi. Setiap test mendapatkan state yang baru. Produksi adalah proses yang berjalan lama di mana state terakumulasi, bocor, dan berinteraksi dengan dirinya sendiri.
Sebuah memory cache yang mengevict entries saat ada beban. Sebuah connection pool yang kehabisan dirinya sendiri setelah 10.000 request. Sebuah timestamp comparison yang gagal ketika test berjalan melewati batas daylight saving. Bug-bug ini memerlukan waktu, volume, atau urutan untuk muncul. Unit test tidak memiliki satupun dari ini.
Concurrency and Race Conditions
Dua pengguna memperbarui record yang sama secara bersamaan. Satu request membaca balance, yang lain mendebitnya, yang pertama menulis kembali nilai yang stale. Uang menghilang. Unit test Anda berjalan secara sekuensial dalam single thread. Mereka tidak bisa menangkap ini.
Anda bisa menulis unit test untuk individual locking primitives. Anda tidak bisa menulis unit test yang membuktikan seluruh sistem Anda bebas race condition. State space-nya terlalu besar dan timing-nya terlalu non-deterministic.
The Environment Itself
Test Anda berjalan di Ubuntu 22.04 dengan Python 3.11, 4GB RAM, dan tidak ada firewall rules. Produksi berjalan di Alpine Linux dengan Python 3.11, 512MB RAM, dan sebuah security group yang memutuskan idle TCP connections setelah 60 detik.
Module socket berperilaku berbeda. Limit mmap lebih rendah. Locale settings menyebabkan strftime memformat tanggal dengan cara yang tidak diharapkan parser Anda. Ini bukan bug kode. Mereka adalah context bugs. Unit test tidak punya context.
Mengapa Persentase Coverage Menyesatkan
Coverage tools mengukur eksekusi baris, bukan kualitas assertion. Sebuah test bisa mengeksekusi setiap baris fungsi dan tidak memverifikasi apa pun yang bermakna.
def test_poor_coverage_quality():
result = calculate_total([{"price": 1.0, "quantity": 1}], 0.0)
# Executed 100% of lines. Verified almost nothing.
assert result is not None
Test ini memberi Anda 100% line coverage dan nol kepercayaan. Banyak tim mengoptimalkan untuk metric tersebut karena mudah diukur. Confidence sulit diukur. Jadi mereka mengukur coverage sebagai gantinya dan berharap keduanya berkorelasi.
Mereka tidak.
Apa yang Harus Diuji Sebagai Gantinya (Atau Sebagai Tambahan)
Ini bukan argumen melawan unit test. Unit test cepat, deterministik, dan sangat baik untuk memverifikasi algorithmic logic. Mereka hanya tidak lengkap.
Berikut ini yang mengisi celah-celah tersebut tanpa mengubah CI pipeline Anda menjadi sebuah liability 45 menit.
Uji di System Boundaries, Bukan Hanya Internal
Alih-alih mocking database, tulis test yang mengenai real test database. Ini lebih lambat, jadi jalankan secara selektif. Tapi mereka menangkap mismatch antara ORM queries Anda dan perilaku aktual query planner.
Alih-alih mocking HTTP client, jalankan downstream service dalam sebuah container. Ini menangkap schema drift, timeout behavior, dan retry logic yang hanya terpicu pada actual connection failures.
Tambahkan Contract Tests untuk External Services
Jika Anda tidak bisa menjalankan real dependency di CI, gunakan contract tests. Ini memverifikasi bahwa consumer expectations Anda cocok dengan actual API schema provider.
Tools seperti Pact merekam interaksi antara service Anda dan dependencies-nya. Jika provider mengubah field type atau menghapus sebuah endpoint, contract test gagal sebelum kode dideploy. Ini tidak sebaik integration testing, tapi jauh lebih baik daripada berharap mocks Anda akurat.
Gunakan Property-Based Testing untuk Edge Cases
Property-based testing tools seperti Hypothesis (Python) atau fast-check (JavaScript) menghasilkan ribuan random inputs dan memverifikasi bahwa invariants Anda terpenuhi.
from hypothesis import given, strategies as st
@given(
st.lists(st.fixed_dictionaries({
"price": st.decimals(min_value=0, max_value=10000, places=2),
"quantity": st.integers(min_value=0, max_value=1000)
})),
st.one_of(st.none(), st.decimals(min_value=0, max_value=1, places=4))
)
def test_calculate_total_invariants(items, tax_rate):
if tax_rate is None:
with pytest.raises(TypeError):
calculate_total(items, tax_rate)
return
result = calculate_total(items, tax_rate)
assert result >= 0
assert result == round(result, 2)
Test ini akan menangkap bug tax rate None tanpa Anda memikirkan untuk menulis kasus spesifik tersebut. Ia menghasilkan inputs yang tidak akan pernah Anda pertimbangkan: empty lists, giant lists, zero prices, maximum precision decimals. Ia menemukan edges dari logic Anda tanpa mengharuskan Anda membayangkannya terlebih dahulu.
Monitor Produksi Seolah-olah Itu Test Environment
Test suite yang paling jujur adalah production traffic. Jika Anda tidak bisa menangkap bug sebelum dikirim, tangkap sebelum ia menyakitkan.
Gunakan feature flags untuk meluncurkan perubahan ke 1% pengguna terlebih dahulu. Pantau error rates, latency percentiles, dan business metrics. Unit test memberitahu Anda apakah kode berperilaku seperti yang diharapkan secara isolasi. Production monitor memberitahu Anda apakah kode berperilaku seperti yang diharapkan dalam realitas.
Atur alerts pada anomali, bukan hanya hard failures. Peningkatan 5% pada 500 errors setelah deploy seringkali adalah satu-satunya sinyal bahwa race condition atau resource leak telah dimulai. Unit test tidak akan pernah menunjukkan ini kepada Anda.
Trade-off yang Jujur
Unit test murah, cepat, dan bagus untuk developer feedback loops. Integration test mahal, lambat, dan bagus untuk menangkap bug yang penting.
Anda membutuhkan keduanya. Jebakannya adalah berpikir bahwa 100% unit test coverage berarti Anda bisa melewatkan sisanya. Ini berarti Anda telah menguji bagian yang mudah secara menyeluruh. Bagian yang sulit, yang membangunkan Anda di malam hari, hidup di tempat test Anda tidak melihat.
Mulailah dengan unit test untuk logic dan algorithms. Tambahkan integration test di setiap system boundary. Gunakan property-based testing untuk menemukan inputs yang tidak Anda pikirkan. Monitor produksi untuk menangkap apa yang dilewatkan setiap test.
Coverage adalah vanity metric. Satu-satunya metric yang penting adalah apakah Anda tidur nyenyak sepanjang malam.
Jika Anda mencoba menangkap bug yang dilewatkan test Anda, mulailah dengan melihat error data Anda. Sentry menunjukkan kepada Anda apa yang rusak di produksi, dengan stack traces dan context yang tidak pernah dimiliki unit test Anda.