Kenapa Code Coverage Tidak Boleh Menjadi Tujuan Utama Unit Test
Ada satu angka yang hampir selalu muncul dalam diskusi kualitas software: code coverage. Tim engineering menetapkan target 80%, 90%, bahkan 100%. CI pipeline dikonfigurasi untuk menolak pull request yang menurunkan persentase itu. Laporan coverage ditampilkan di dashboard sebagai bukti “kita sudah melakukan testing dengan benar.” Sekilas, ini terlihat seperti praktik engineering yang matang. Tapi ada masalah fundamental di balik pendekatan ini — masalah yang tidak terlihat sampai kamu mulai membaca test-nya satu per satu.
Code coverage mengukur apakah sebuah baris kode dieksekusi, bukan apakah kode itu benar. Perbedaan dua kata ini adalah jurang yang sangat dalam, dan memahami jurang itu akan mengubah cara kamu menulis dan menilai unit test selamanya.
Apa yang Sebenarnya Diukur Code Coverage
Sebelum bicara soal masalahnya, penting untuk memahami dengan tepat apa yang code coverage ukur dan — yang jauh lebih penting — apa yang tidak ia ukur.
Code coverage melaporkan proporsi kode yang dieksekusi saat test suite berjalan. Ada beberapa jenis coverage yang umum digunakan:
| Jenis Coverage | Yang Diukur | Contoh |
|---|---|---|
| Line Coverage | Persentase baris yang dieksekusi | Baris return x + y dijalankan |
| Branch Coverage | Persentase cabang if/else yang diambil | Baik jalur true maupun false dieksekusi |
| Function Coverage | Persentase fungsi yang dipanggil | Fungsi calculateTax() pernah dipanggil |
| Statement Coverage | Persentase statement yang dieksekusi | Mirip line coverage tapi lebih granular |
| Path Coverage | Semua kombinasi jalur eksekusi | Sangat sulit dicapai, jarang digunakan |
Yang perlu kamu catat: tidak satu pun dari semua jenis coverage ini mengukur apakah hasil dari eksekusi kode itu benar. Coverage hanya menjawab: “kode ini dijalankan?” Ia tidak menjawab: “kode ini menghasilkan output yang benar?”
Ini bukan kelemahan dari alat ukur tertentu. Ini adalah batasan fundamental dari konsep coverage itu sendiri.
# Fungsi yang akan diuji
def hitung_diskon(harga: float, member: bool) -> float:
if member:
return harga * 0.9
return harga
# Test berikut mencapai 100% branch coverage
def test_hitung_diskon():
hitung_diskon(100_000, True) # mengeksekusi cabang member=True
hitung_diskon(100_000, False) # mengeksekusi cabang member=False
Test di atas mencapai 100% branch coverage. Tapi perhatikan — tidak ada satu pun assert. Kita tidak pernah memverifikasi bahwa diskon 10% dihitung dengan benar. Jika ada bug dalam rumusnya — misalnya harga * 0.99 atau harga - 0.9 — test ini tidak akan menangkapnya. Coverage 100%, zero confidence.
Jebakan coverage tanpa assertion adalah salah satu anti-pattern paling berbahaya dalam unit testing, justru karena ia tidak terlihat berbahaya. Laporan coverage terlihat hijau, CI lulus, tapi sistem tidak benar-benar divalidasi.
Goodhart’s Law dan Mengapa Coverage Rusak Saat Dijadikan Target
Ada prinsip yang dirumuskan oleh ekonom Charles Goodhart:
“When a measure becomes a target, it ceases to be a good measure.”
Prinsip ini awalnya digunakan untuk mengkritik kebijakan moneter, tapi ia berlaku sempurna untuk code coverage dalam software engineering.
Ketika coverage masih menjadi pengamatan — sesuatu yang kamu pantau sambil menulis test yang bermakna — ia memberikan informasi yang berguna. Kamu bisa melihat “oh, ada fungsi penting di sini yang belum pernah diuji sama sekali.” Itu informasi berharga.
Tapi ketika coverage menjadi target — sesuatu yang harus dicapai sebelum kamu bisa merge ke main branch — perilaku tim berubah. Dan perubahannya bukan ke arah yang lebih baik.
flowchart TD
A[Coverage Dijadikan Target] --> B[Tekanan untuk Naikkan Angka]
B --> C{Cara Tercepat?}
C --> D[Tulis test tanpa assertion bermakna]
C --> E[Test kode trivial yang tidak berisiko]
C --> F[Hindari test skenario kompleks yang sulit]
D --> G[Coverage naik ✓]
E --> G
F --> H[Edge case penting tidak teruji]
G --> I[False Confidence]
H --> I
I --> J[Bug lolos ke production]Ini adalah siklus yang nyata dan terjadi di banyak tim. Begitu ada KPI atau gate coverage, insentif bergeser dari “tulis test yang menangkap bug” menjadi “tulis test yang naikkan angka.” Dua tujuan ini terlihat sama, tapi hasilnya sangat berbeda.
Lima Anti-Pattern yang Muncul Ketika Coverage Dijadikan Tujuan
1. Test Tanpa Assertion yang Bermakna
Ini adalah anti-pattern paling langsung. Test dijalankan untuk mengeksekusi kode, tapi assertion-nya terlalu lemah atau bahkan tidak ada sama sekali.
# ANTI-PATTERN: test berjalan, coverage naik, tapi tidak ada yang diverifikasi
def test_proses_pembayaran_anti_pattern():
pembayaran = PembayaranService()
hasil = pembayaran.proses(order_id="ORD-001", jumlah=500_000)
assert hasil is not None # ini hampir selalu True; tidak berguna
# BENAR: verifikasi perilaku yang spesifik dan bermakna
def test_proses_pembayaran_berhasil():
pembayaran = PembayaranService(gateway=MockGateway(sukses=True))
hasil = pembayaran.proses(order_id="ORD-001", jumlah=500_000)
assert hasil.status == StatusPembayaran.BERHASIL
assert hasil.transaction_id is not None
assert hasil.jumlah_terpotong == 500_000
assert hasil.timestamp is not None
def test_proses_pembayaran_gagal_saldo_tidak_cukup():
pembayaran = PembayaranService(gateway=MockGateway(sukses=False, kode_error="INSUFFICIENT_FUNDS"))
with pytest.raises(SaldoTidakCukupError) as exc_info:
pembayaran.proses(order_id="ORD-001", jumlah=500_000)
assert "ORD-001" in str(exc_info.value)
2. Menguji Kode Trivial untuk Menaikkan Angka
Daripada meluangkan waktu untuk test yang sulit tapi penting, developer tergoda menguji getter, setter, dan konstruktor sederhana yang hampir tidak mungkin salah.
// ANTI-PATTERN: menguji getter/setter yang tidak punya logika
@Test
void testGetNama() {
User user = new User();
user.setNama("Alice");
assertEquals("Alice", user.getNama()); // ini hampir tidak pernah gagal
}
@Test
void testGetEmail() {
User user = new User();
user.setEmail("[email protected]");
assertEquals("[email protected]", user.getEmail());
}
// BENAR: fokus pada logika yang benar-benar bisa salah
@Test
void testValidasiEmailFormatTidakValid() {
assertThrows(EmailTidakValidException.class, () -> {
new User().setEmail("bukan-email");
});
}
@Test
void testValidasiEmailDomainDisallow() {
assertThrows(DomainDilarangException.class, () -> {
new User().setEmail("[email protected]");
});
}
@Test
void testNormalisasiEmailKeHurufKecil() {
User user = new User();
user.setEmail("[email protected]");
assertEquals("[email protected]", user.getEmail());
}
3. Hanya Menguji Happy Path
Ketika tim mengejar coverage dengan cara tercepat, mereka cenderung mengeksekusi jalur sukses saja. Satu test untuk satu fungsi sudah cukup untuk menaikkan line coverage secara signifikan. Edge case dan error handling — yang justru sering jadi sumber bug produksi — diabaikan.
// ANTI-PATTERN: hanya menguji skenario berhasil
func TestKirimEmail(t *testing.T) {
service := NewEmailService(MockSMTP{})
err := service.Kirim("[email protected]", "Halo", "Isi pesan")
if err != nil {
t.Fatal(err)
}
// coverage fungsi KirimEmail: 60%+ hanya dari satu test ini
}
// BENAR: uji semua skenario yang mungkin terjadi di production
func TestKirimEmail_Berhasil(t *testing.T) {
mock := &MockSMTP{ShouldSucceed: true}
service := NewEmailService(mock)
err := service.Kirim("[email protected]", "Halo", "Isi pesan")
assert.NoError(t, err)
assert.Equal(t, 1, mock.KirimDipanggil)
}
func TestKirimEmail_AlamatKosong(t *testing.T) {
service := NewEmailService(&MockSMTP{})
err := service.Kirim("", "Halo", "Isi pesan")
assert.ErrorIs(t, err, ErrAlamatKosong)
}
func TestKirimEmail_SMTPTimeout(t *testing.T) {
mock := &MockSMTP{ReturnError: ErrTimeout}
service := NewEmailService(mock)
err := service.Kirim("[email protected]", "Halo", "Isi pesan")
assert.ErrorIs(t, err, ErrSMTPTimeout)
}
func TestKirimEmail_RetrySetelahGagal(t *testing.T) {
mock := &MockSMTP{FailFirstN: 2, ShouldSucceed: true}
service := NewEmailService(mock)
err := service.Kirim("[email protected]", "Halo", "Isi pesan")
assert.NoError(t, err)
assert.Equal(t, 3, mock.KirimDipanggil) // 2 gagal + 1 berhasil
}
4. Test yang Terlalu Terikat Implementasi
Demi memastikan setiap baris dieksekusi, developer kadang menulis test yang memeriksa detail internal implementasi — bukan perilaku eksternal. Test seperti ini sangat rapuh dan rusak saat refactor, bahkan refactor yang tidak mengubah behavior sama sekali.
# ANTI-PATTERN: test memeriksa detail implementasi internal
def test_kalkulasi_harga_akhir():
service = HargaService()
# Test ini mengasumsikan implementasi internal tertentu
with patch.object(service, '_ambil_kurs') as mock_kurs:
with patch.object(service, '_hitung_pajak') as mock_pajak:
mock_kurs.return_value = 15_000
mock_pajak.return_value = 110_000
hasil = service.hitung_harga_akhir(produk_id="P001")
# Verifikasi metode internal dipanggil
mock_kurs.assert_called_once()
mock_pajak.assert_called_once_with(100_000, kurs=15_000)
# BENAR: test memverifikasi kontrak eksternal yang nyata
def test_kalkulasi_harga_akhir_dengan_pajak():
# Mock dependency eksternal (bukan internal)
mock_produk_repo = MockProdukRepository(harga_usd=10)
mock_kurs_api = MockKursAPI(kurs_idr=15_000)
service = HargaService(produk_repo=mock_produk_repo, kurs_api=mock_kurs_api)
hasil = service.hitung_harga_akhir(produk_id="P001")
# Verifikasi output, bukan cara mencapainya
assert hasil.harga_dasar == 150_000 # 10 USD * 15.000
assert hasil.pajak == 16_500 # 11% dari 150.000
assert hasil.total == 166_500 # dasar + pajak
5. Menghindari Kode Sulit dengan Exclusion Flag
Ketika ada bagian kode yang genuinly sulit diuji — misalnya error handling untuk kondisi langka, atau integrasi kompleks — developer kadang memilih jalan pintas: menandai kode tersebut dengan exclusion flag agar tidak dihitung dalam coverage.
# ANTI-PATTERN: melarikan diri dari test sulit dengan exclude flag
def handle_database_connection_error(error): # pragma: no cover
# logika kompleks untuk recovery dari DB failure
logger.critical(f"DB down: {error}")
notify_ops_team(error)
trigger_circuit_breaker()
# ... 50 baris logika recovery
# Ini mungkin kode yang PALING PENTING untuk diuji,
# karena ia berjalan saat sistem sedang dalam kondisi kritis
Exclusion flag memang punya use case yang legitimate — misalnya untuk kode yang memang tidak bisa diuji secara unit (platform-specific code, debug utilities). Tapi ketika digunakan untuk melarikan diri dari test sulit demi menjaga angka coverage, ini adalah bentuk manipulasi metrik yang nyata.
Rasa Aman Palsu: Bahaya yang Tidak Terlihat
Dari semua masalah yang ditimbulkan oleh obsesi coverage, yang paling berbahaya adalah false confidence — rasa aman yang tidak berdasar.
Bayangkan sebuah sistem pembayaran dengan coverage 95%. Tim merasa yakin. Setiap PR diverifikasi tidak menurunkan angka ini. Dashboard selalu hijau. Lalu suatu hari ada bug di production: kalkulasi pajak untuk produk bundle menghasilkan angka negatif di kondisi tertentu.
Ternyata, test yang ada memang mengeksekusi fungsi kalkulasi pajak — coverage-nya 100%. Tapi tidak ada satu pun test yang menguji kombinasi produk bundle dengan diskon dan pajak sekaligus. Baris kodenya dijalankan, tapi skenario kritis tidak pernah divalidasi.
sequenceDiagram
participant Dev as Developer
participant CI as CI Pipeline
participant Prod as Production
Dev->>CI: Push code (coverage 95%)
CI-->>Dev: ✓ Coverage OK, merge diizinkan
Dev->>Prod: Deploy
Note over Prod: Bug: kalkulasi pajak negatif untuk produk bundle
Prod-->>Dev: 🔥 Complaint dari user
Dev->>Dev: Investigasi: coverage tinggi, tapi skenario ini tidak pernah diujiIni bukan skenario hipotetis. Ini adalah kejadian umum. Coverage tinggi memberikan sinyal bahwa “kita sudah menguji codebase dengan baik,” padahal kita hanya memastikan setiap baris pernah dijalankan — bukan bahwa setiap skenario penting pernah divalidasi.
Coverage 100% tidak menjamin zero bug. Ia hanya menjamin bahwa setiap baris kode pernah dieksekusi oleh test suite. Dua hal yang sangat berbeda. Sistem bisa memiliki coverage sempurna dan masih memiliki bug kritis di edge case yang tidak terpikirkan.
Biaya Tersembunyi: Waktu Engineer yang Terbuang
Ada aspek lain yang sering luput dari diskusi: biaya ekonomi dari mengejar coverage sebagai target.
Waktu engineer adalah sumber daya yang sangat terbatas. Setiap jam yang dihabiskan untuk menulis test getter/setter agar coverage naik dari 78% ke 80% adalah satu jam yang tidak dihabiskan untuk:
- Menguji edge case bisnis yang kompleks
- Menulis test untuk fitur baru yang berisiko tinggi
- Memperbaiki test yang sudah ada tapi assertion-nya lemah
- Melakukan exploratory testing untuk menemukan bug yang tidak terduga
Ini adalah opportunity cost yang nyata. Dan ironisnya, dengan mengejar angka coverage, tim justru sering melewatkan bagian-bagian yang paling berisiko — karena bagian itu sulit diuji dan membutuhkan waktu lebih banyak daripada sekadar menambahkan test trivial.
flowchart LR
subgraph Waktu["Alokasi Waktu (Terbatas)"]
direction TB
W1[Tulis test untuk getter/setter]
W2[Tulis test tanpa assertion bermakna]
W3[Uji edge case bisnis penting]
W4[Uji error handling kompleks]
end
subgraph Target["Jika Coverage adalah Target"]
T1["✓ Prioritas (naikkan angka)"]
T2["✓ Prioritas (naikkan angka)"]
T3["✗ De-prioritas (sulit, butuh waktu)"]
T4["✗ De-prioritas (sulit, butuh waktu)"]
end
subgraph Ideal["Jika Coverage adalah Guide"]
I1["✗ Skip (risiko rendah)"]
I2["✗ Skip (tidak bermakna)"]
I3["✓ Prioritas utama"]
I4["✓ Prioritas utama"]
end
W1 --- T1
W2 --- T2
W3 --- T3
W4 --- T4
W1 --- I1
W2 --- I2
W3 --- I3
W4 --- I4Test yang Bermakna: Apa yang Seharusnya Diukur
Jika bukan coverage, apa yang seharusnya menjadi indikator kualitas unit test? Pertanyaan ini tidak memiliki satu jawaban tunggal, tapi ada beberapa dimensi yang lebih bermakna:
Mutation Score
Mutation testing adalah teknik di mana tool secara otomatis membuat versi “rusak” dari kode kamu — misalnya mengubah > menjadi >=, menghapus sebuah kondisi, atau mengganti nilai return. Kemudian ia memeriksa apakah test suite kamu menangkap “mutant” tersebut.
# Kode asli
def apakah_dewasa(umur: int) -> bool:
return umur >= 18
# Mutation yang dibuat tool:
# Mutant 1: return umur > 18 (boundary condition berubah)
# Mutant 2: return umur <= 18 (logika terbalik)
# Mutant 3: return True (kondisi dihapus)
# Mutant 4: return umur >= 17 (nilai threshold berubah)
# Test yang bermakna harus MEMBUNUH semua mutant ini
def test_apakah_dewasa():
assert apakah_dewasa(18) == True # membunuh Mutant 1 dan 4
assert apakah_dewasa(17) == False # membunuh Mutant 2, 3, dan 4
assert apakah_dewasa(0) == False # membunuh Mutant 3
assert apakah_dewasa(100) == True # sanity check
Mutation score (persentase mutant yang “terbunuh” oleh test) jauh lebih bermakna daripada coverage biasa, karena ia mengukur kemampuan test untuk mendeteksi perubahan yang salah — bukan sekadar kemampuannya mengeksekusi kode.
Behaviour Coverage, Bukan Line Coverage
Cara berpikir yang lebih sehat adalah fokus pada behaviour coverage: untuk setiap perilaku yang sistem seharusnya tunjukkan, apakah ada test yang memverifikasinya?
Fungsi: validasi_password(password: str) -> ValidationResult
Perilaku yang harus diuji:
✓ Password kurang dari 8 karakter → GAGAL
✓ Password tanpa huruf besar → GAGAL
✓ Password tanpa angka → GAGAL
✓ Password tanpa karakter khusus → GAGAL
✓ Password yang memenuhi semua syarat → BERHASIL
✓ Password dengan karakter unicode/emoji → (perilaku apa yang diharapkan?)
✓ Password yang merupakan string kosong → GAGAL
✓ Password yang sangat panjang (>1000 karakter) → (apakah ada batas?)
Ketika kamu memetakan perilaku yang diharapkan seperti ini, kamu bisa melihat test mana yang masih kurang tanpa perlu mengintip laporan coverage sama sekali.
Kepercayaan saat Refactor
Salah satu indikator terbaik kualitas test suite adalah: seberapa percaya diri tim ketika melakukan refactor? Jika setiap refactor kecil membuat banyak test gagal — bahkan ketika behavior eksternal tidak berubah — itu tanda test terlalu terikat implementasi. Jika tim takut refactor karena khawatir “sesuatu bisa rusak tanpa ketahuan” — itu tanda test tidak benar-benar menangkap behavior penting.
Test suite yang baik memberikan net yang menangkap bug nyata tanpa meledak saat kamu melakukan refactor yang sah.
Cara Menggunakan Coverage Secara Sehat
Coverage bukan musuh. Ia adalah alat yang berguna jika digunakan dengan tepat. Berikut cara memposisikannya secara sehat:
Coverage sebagai Deteksi, Bukan Target
Gunakan laporan coverage untuk menemukan blind spot — area kode yang sama sekali belum pernah dieksekusi. Area tersebut adalah kandidat untuk ditambahkan test, tapi dengan pertanyaan yang tepat: “apakah area ini punya logika bisnis penting yang perlu divalidasi?” bukan “bagaimana cara saya eksekusi baris ini secepat mungkin?”
flowchart TD
A[Jalankan test suite] --> B[Lihat laporan coverage]
B --> C{Ada area yang sama sekali tidak tercakup?}
C -- Tidak --> D[Lanjut, coverage hanya guide]
C -- Ya --> E[Evaluasi area tersebut]
E --> F{Apakah ada logika penting di sini?}
F -- Ya --> G[Tulis test yang bermakna untuk validasi behavior]
F -- Tidak --> H[Catat, tapi tidak perlu diprioritaskan]
G --> AHindari Coverage Gate yang Kaku
Coverage gate (CI yang gagal jika coverage turun) bisa berguna, tapi dengan catatan penting: ia mencegah penurunan coverage, bukan mencegah test yang tidak bermakna. Seseorang masih bisa menambahkan test tanpa assertion yang cukup dan tetap lulus gate tersebut.
Jika kamu menggunakan coverage gate, pastikan disertai dengan culture dan review yang memeriksa kualitas test, bukan hanya angkanya.
Tentukan Area Prioritas Berdasarkan Risiko
Tidak semua kode memiliki risiko yang sama. Logika bisnis inti — kalkulasi harga, autentikasi, transaction handling — jauh lebih kritis daripada helper utility sederhana. Alokasikan energi testing berdasarkan risiko, bukan berdasarkan seberapa mudah kode itu diuji.
Area Prioritas Tinggi (harus diuji dengan mendalam):
✓ Kalkulasi finansial (harga, pajak, diskon, bunga)
✓ Logika autentikasi dan otorisasi
✓ Validasi input dari user
✓ State machine (order flow, payment status)
✓ Error handling untuk kondisi kritis
Area Prioritas Sedang:
✓ Data transformation / mapping
✓ Integrasi dengan service eksternal
✓ Caching logic
Area Prioritas Rendah (tidak perlu test exhaustive):
✗ Getter dan setter tanpa logika
✗ Konfigurasi dan constant
✗ Debug utilities
✗ Scaffolding code yang generated
Jadikan Coverage sebagai Topik Diskusi, Bukan Gatekeeper
Coverage paling efektif digunakan dalam retrospektif atau code review sebagai bahan diskusi: “coverage di modul pembayaran turun dari 85% ke 72% setelah PR ini — apakah ada area penting yang terlewat?” Pertanyaan itu jauh lebih produktif daripada otomatis menolak PR karena angkanya turun.
Coverage dan TDD: Hubungan yang Sering Disalahpahami
Test-Driven Development (TDD) sering dikaitkan dengan coverage, padahal keduanya punya filosofi yang berbeda.
Dalam TDD, kamu menulis test sebelum menulis kode. Prosesnya adalah:
- Tulis test yang gagal untuk satu behavior
- Tulis kode sesedikit mungkin agar test lulus
- Refactor
- Ulangi
Hasilnya, coverage menjadi efek samping alami dari TDD — bukan sesuatu yang dikejar secara terpisah. Kode yang ditulis lewat TDD hampir pasti memiliki coverage tinggi, tapi lebih penting dari itu, setiap baris kode yang ada memiliki test yang bermakna di baliknya karena kode itu ditulis untuk membuat test tersebut lulus.
sequenceDiagram
participant Dev as Developer
participant Test as Test
participant Code as Code
Note over Dev: TDD Flow
Dev->>Test: Tulis test untuk behavior "X"
Test-->>Dev: ✗ Gagal (kode belum ada)
Dev->>Code: Tulis kode minimal agar X lulus
Code-->>Test: ✓ Lulus
Dev->>Code: Refactor dengan aman
Note over Test,Code: Coverage adalah efek samping, bukan tujuanIni berbeda fundamental dengan “tulis kode dulu, kemudian kejar coverage 80%.” Dalam pendekatan kedua, coverage adalah tujuan yang dikejar secara terpisah, dan hasilnya sering adalah test yang mengekor kode — bukan test yang mendefinisikan kontrak perilaku.
Studi Kasus: Coverage Tinggi, Bug di Production
Berikut adalah skenario yang mengilustrasikan masalah secara konkret.
Sebuah tim sedang membangun fitur promo. Ada sebuah fungsi untuk menghitung total harga setelah promo diterapkan:
// Kode production
fun hitungHargaSetelahPromo(
hargaAsli: Long,
diskonPersen: Int,
maxDiskon: Long
): Long {
val diskon = hargaAsli * diskonPersen / 100
return if (diskon > maxDiskon) {
hargaAsli - maxDiskon
} else {
hargaAsli - diskon
}
}
Tim menulis test:
// ANTI-PATTERN: test yang ada hanya menguji happy path
@Test
fun testHitungHargaSetelahPromo() {
// Test 1: diskon tidak melebihi batas
assertEquals(90_000L, hitungHargaSetelahPromo(100_000L, 10, 50_000L))
// Test 2: diskon melebihi batas
assertEquals(50_000L, hitungHargaSetelahPromo(100_000L, 70, 50_000L))
}
// Coverage: 100% - semua branch tercakup
Coverage 100%. Semua branch tercakup. CI lulus. Code di-deploy.
Lalu laporan masuk: ada user yang mendapat harga negatif. Ternyata ada edge case yang tidak pernah diuji:
// Bug ditemukan user: diskon 100% dengan maxDiskon kecil
hitungHargaSetelahPromo(100_000L, 100, 10_000L)
// diskon = 100_000 * 100 / 100 = 100_000
// diskon > maxDiskon (100_000 > 10_000) → true
// return hargaAsli - maxDiskon = 100_000 - 10_000 = 90_000
// Harusnya free (harga = 0), tapi user tetap kena charge!
// Edge case lain: hargaAsli sangat besar → integer overflow
hitungHargaSetelahPromo(Long.MAX_VALUE, 10, 1_000_000L)
// hargaAsli * diskonPersen bisa overflow!
Test yang bermakna seharusnya menyertakan skenario-skenario ini:
// BENAR: test yang mencakup edge case bisnis
@Test
fun testHitungHargaSetelahPromo_DiskonPenuh() {
// Diskon 100%, tidak ada maxDiskon
assertEquals(0L, hitungHargaSetelahPromo(100_000L, 100, Long.MAX_VALUE))
}
@Test
fun testHitungHargaSetelahPromo_HargaTidakNegatif() {
// Pastikan harga tidak pernah negatif
val hasil = hitungHargaSetelahPromo(100_000L, 100, 10_000L)
assertTrue(hasil >= 0, "Harga tidak boleh negatif, tapi dapat: $hasil")
}
@Test
fun testHitungHargaSetelahPromo_DiskonNol() {
assertEquals(100_000L, hitungHargaSetelahPromo(100_000L, 0, 50_000L))
}
@Test
fun testHitungHargaSetelahPromo_HargaNol() {
assertEquals(0L, hitungHargaSetelahPromo(0L, 50, 50_000L))
}
Skenario-skenario ini tidak akan banyak menaikkan coverage karena branch yang sama sudah tercakup. Tapi mereka adalah test yang sesungguhnya melindungi sistem dari bug nyata.
Ringkasan
- Coverage mengukur eksekusi, bukan kebenaran — sebuah baris kode yang dieksekusi tidak berarti kode itu menghasilkan output yang benar.
- Goodhart’s Law berlaku sempurna di sini — begitu coverage dijadikan target, ia berhenti menjadi ukuran yang baik karena perilaku tim berubah untuk memanipulasi angka, bukan meningkatkan kualitas.
- Lima anti-pattern utama muncul ketika coverage dijadikan tujuan: test tanpa assertion bermakna, menguji kode trivial, hanya happy path, terlalu terikat implementasi, dan menghindari kode sulit dengan exclusion flag.
- False confidence adalah bahaya terbesar — coverage tinggi memberikan rasa aman yang tidak berdasar, yang justru lebih berbahaya daripada tidak punya test sama sekali karena tim berhenti waspada.
- Waktu engineer adalah sumber daya terbatas — mengejar angka coverage mengorbankan waktu yang seharusnya dialokasikan untuk test yang benar-benar melindungi sistem.
- Coverage sebaiknya jadi guide, bukan goal — gunakan laporan coverage untuk menemukan blind spot dan memulai diskusi, bukan sebagai gatekeeper otomatis yang menentukan kualitas.
- Behaviour coverage lebih bermakna — petakan semua perilaku yang sistem seharusnya tunjukkan, lalu pastikan setiap perilaku itu tervalidasi oleh test.
- Test yang baik diukur dari kepercayaan — seberapa percaya diri tim saat refactor adalah indikator kualitas test suite yang jauh lebih jujur daripada persentase coverage.