Uji Luar Paket: Tiga Seam yang Dipaksa Eksplisit
Memindahkan test keluar paket bukan sekadar relokasi berkas. Tiga modul Go memaksa tiga bentuk seam: pembaca status, sleep yang disuntik, orakel cermin.
Ringkasan
Pindahin test keluar paket ternyata bukan cuma pindah folder, kompilasi langsung rusak karena identifier internal gak keliatan dari luar. Solusinya: ekspor StoredTTL biar test TTL gak perlu nunggu, RetryConnect dengan fungsi tidur yang bisa dipalsuin, sama AllModules sebagai pembanding anti tautologi. Pelajarannya: ini audit kontrak, produksi tetap aman.
Rencananya sederhana: pindahkan berkas test ke pohon terpusat, rapikan path import, selesai. Awalnya saya kira memindahkan cache_test.go ke pohon tests/unit/shared/cache cuma soal ganti direktori. Realitasnya: kompilasi langsung rusak. Berkas itu masih berklausa package cache di lokasi baru, dan Go mengompilasi semua berkas dalam satu direktori jadi satu paket [2][3]. Dari paket luar, identifier yang tidak diekspor tidak terlihat. Pola yang sama terjadi di modul database dan scope. Tiga modul, tiga kontrak yang tadinya tersembunyi di balik paket, sekarang memaksa keputusan desain satu per satu.
Konsekuensinya disiplin pengujian berubah: setiap asersi wajib melewati API yang diekspor. Bentuk keputusannya tidak seragam. Satu relokasi menghasilkan tiga rasa seam yang berbeda.
Pembaca status sebagai pengganti tidur
Test TTL di modul cache punya kontrak yang tidak punya pintu baca: item di dalam go-cache punya batas usia maksimum, dan satu-satunya cara memverifikasinya secara perilaku adalah tidur melewatinya. Test berbasis tidur seperti itu memang tidak bisa cepat sekaligus andal [1].
Jalan keluarnya mengekspor Cache.StoredTTL(key string) (time.Duration, bool): sisa TTL dibaca langsung dari Expiration item dikurangi waktu saat ini dalam satuan UnixNano. Sumber kebenarannya tetap satu, struktur data yang sama yang dipakai runtime cache, jadi tidak ada duplikasi logika kedaluwarsa di test.
Ada dua sifat go-cache yang diverifikasi langsung di source-nya: Items() hanya menyalin item yang belum kedaluwarsa, dan Expiration tersimpan sebagai timestamp UnixNano dengan nol berarti tanpa batas [4]. Efek sampingnya menguntungkan: begitu item kedaluwarsa, StoredTTL otomatis melapor gagal lewat nilai boolean keduanya. Test jadi instan dan deterministik tanpa satu pun jeda waktu nyata.
Dependency injection untuk retry tanpa menunggu
Modul database menemui masalah lain. Fungsi koneksi ber-retry menuntut MySQL hidup; setiap percobaan koneksinya yang gagal adalah network round-trip yang lambat. Jadi tidak ada jalur publik murah untuk mengujinya, dan menyalin logika retry ke test hanya menghasilkan dua implementasi yang harus dijaga sinkron. Helper murninya diekspor sebagai RetryConnect, dengan parameter fungsi pembuka koneksi, batas waktu, interval, dan fungsi tidur bertipe func(time.Duration).
Parameternya memang panjang, tapi justru di situ letak ujiannya: produksi mempassing time.Sleep, test mempassing fungsi kosong. Sirkuit retry yang sama, timeout dan interval sama, teruji instan. Perubahan kodenya cuma satu baris di pemanggil: nama helper diganti bentuk eksportnya.
Orakel cermin anti tautologi
Modul scope menghadapi risiko paling halus. Kalau ModulesFor(ALL) diuji dengan membandingkan hasilnya terhadap logika yang sama persis, test itu tidak membuktikan apa pun. Untuk itu AllModules() diekspor sebagai salinan allModules yang tidak diekspor.
Asersi jadi punya jalur kode terpisah untuk dibandingkan: hasil ModulesFor(ALL) disamakan dengan himpunan dari AllModules(), bukan dengan dirinya sendiri. Tanpa pemisahan ini, test bisa lolos selamanya meskipun logika pemfilterannya salah, karena yang dibandingkan hanyalah cermin dari dirinya. Kalau suatu saat daftar modul berubah, kedua jalur harus berubah bersama, dan ketidaksesuaian langsung kelihatan.
Yang berubah dan yang tidak perlu digarisbawahi. Produksi tidak berganti perilaku: satu-satunya penambahan di cache adalah metode baca berisi dua belas baris, dan semua asersi lama, dari batas TTL sampai eviction, dipertahankan apa adanya. Yang berubah hanya jalur bacanya: dari mengintip struktur internal, jadi memanggil pintu yang disediakan.
Satu catatan implementasi: pembaca ini menyalin seluruh item yang belum kedaluwarsa setiap kali dipanggil, sesuai perilaku Items() di go-cache [4]. Untuk jalur test itu murah dan cukup. Untuk jalur produksi yang panas, pola ini jelas bukan contoh yang mau ditiru, dan di situlah bedanya seam dengan API biasa.
Seam seperti ini sengaja dibuat kecil: satu fungsi baca, satu helper murni, satu orakel. Masing-masing diberi komentar dokumentasi yang menjelaskan kontrak yang dibukanya dan mengapa test membutuhkannya. API tetap jujur karena seam mendokumentasikan apa yang diekspos, bukan sekadar membuka pintu agar test lolos.
Pelajaran yang membekas: memindahkan test keluar paket bukan operasi berkas. Ia audit kontrak. Setiap kontrak yang tadinya bisa diintip dari dalam paket harus memilih bentuk: pembaca status, dependency injection, atau orakel cermin. Pilihan ketiganya punya kriteria yang sama: produksi tidak berubah perilaku, test tidak menunggu waktu nyata, dan yang diekspor cukup untuk satu kontrak saja.