Unit Test Hijau Semua, Tapi Konverter Tetap Rusak
Tiga bug pull-side lolos dari unit test tiap sisi yang hijau semua; cuma property test round-trip yang menangkap kontrak dua sisi yang tak sinkron.
Ringkasan
Gw kira bug-nya cuma satu fungsi pas round-trip markdown ancur padahal unit test ijo semua, ternyata kontrak push dan pull beda. Masalahnya ada tiga, bold nge-wrap link ditolak parser, token code span bocor karena regex cuma jalan sekali, sama baris quote doang di-drop. Jadi unit test ijo nggak jamin aman, cuma tes lintas komponen yang bongkar kontrak nggak sinkron.
Tebakan Salah dan Realita Kontrak
Malam itu saya jalanin property test buat ngecek round-trip konverter di corpus dokumentasi asli. Ekspektasinya simpel: ubah markdown ke HTML, terus balik lagi ke markdown, hasilnya harus sama setelah normalisasi. Eh, test-nya gagal total. Terminal nge-print deretan diff yang nunjukin format teks berubah di belasan file. Anehnya, semua unit test di sisi push dan sisi pull warnanya hijau.
Korpus itu bukan korpus main-main: belasan file dokumentasi asli yang tiap minggu diubah lewat editor Plane, jadi bug ini bukan kasus laboratorium. Tebakan pertama saya: pasti ada satu fungsi parsing yang bug. Saya trace log-nya baris per baris, ngarepin nemu satu kondisi if-else yang salah handle edge case. Ternyata dugaan saya salah besar. Masalahnya bukan satu fungsi yang error, tapi dua sisi konverter ini ngiket dua kontrak yang beda. Masing-masing sisi bener menurut test-nya sendiri, tapi nggak ada satu pun test yang ngecek kontrak di antara keduanya.
Tool yang saya bikin ini tugasnya mirror file markdown ke wiki self-hosted, lalu nariknya balik. Invariant utamanya: markdown awal dan akhir harus sama setelah normalisasi. Bug pertama muncul di elemen inline bersarang. Sisi push nge-output teks tebal yang nge-wrap sebuah link. Sisi pull, parser HTML berbasis html.parser [2], nolak mentah-mentah dengan pesan nested inline elements are not supported.
Test di sisi pull emang nge-pin penolakan itu, sementara test di sisi push nge-pin emisi teks tebalnya. Dua-duanya pass, tapi pas digabung, round-trip-nya hancur. Solusinya, sisi pull sekarang nyimpen wrap stack berisi tag, kumpulan teks, dan href. Waktu tag paling dalam ditutup, token markdown-nya dikompos ke induknya. Test pull yang lama langsung saya hapus karena nge-pin kontrak yang salah. Kalau dua test nggak sepakat, salah satu pasti ngiket spek yang keliru.
Jebakan Regex dan Aturan Escape
Bug kedua lebih nyebelin. Waktu ada code span di dalam label link, token internal bocor ke output HTML akhir. Saya awalnya nyalahin logic parser, padahal penyebabnya di cara kerja re.sub. Menurut dokumentasi resmi, fungsi ini cuma nge-replace kemunculan paling kiri yang nggak tumpang tindih [1]. Dia nggak nge-scan ulang hasil replace-nya sendiri.
html = "link with <stash_1> code"
# re.sub only replaces leftmost non-overlapping
html = re.sub(r'<stash_(\d+)>', restore_code, html)
# if restore_code reveals another stash token, it survives
# fix: loop until stable
while "<stash_" in html:
html = re.sub(r'<stash_(\d+)>', restore_code, html)
Stash token yang kebuka di pass pertama lolos, karena regex-nya udah kelewat. Fix-nya: loop substitusi sampai nggak ada stash token yang tersisa. Konteks escaping ini penting: CommonMark ngizinin backslash-escape buat tanda baca ASCII, tapi secara eksplisit bilang escape nggak jalan di dalam code span [3]. Makanya sisi push nyimpen code span dulu sebagai token sebelum nge-escape sisanya.
Spesifikasi yang Nggak Saling Ngobrol
Masalah terakhir, sisi pull nyimpen baris quote yang isinya cuma karakter lebih besar dari, sementara sisi push nge-drop baris kayak gitu. Output akhirnya diverge dari markdown asal dan bikin diff-nya kotor tiap update dokumen. Kalau kita baca spesifikasi CommonMark, marker block quote itu cuma karakter tersebut plus spasi opsional [3]. Baris yang isinya karakter itu doang itu baris kosong versi quote. Solusinya tinggal nge-canonicalize baris itu, persis kayak nge-handle baris kosong antar blok. Satu baris di parser, tapi efeknya kelihatan di semua file yang punya quote kosong.
Dari tiga bug ini, pelajaran paling mahal buat saya: unit test yang hijau cuma ngebuktiin tiap bagian sesuai speknya masing-masing. Test itu nggak menjamin spek antar bagian sinkron. Kita gampang merasa aman cuma karena laporan coverage hijau. Padahal, tes yang hijau itu valid di dalam gelembung modulnya sendiri. Cuma property test yang nyebrangin batas komponen, kayak round-trip di file asli, yang bisa nangkap kontrak dua sisi yang mulai geser antara dua modul yang ditulis di minggu yang sama.