Strategi uji bahasa baru harus dirancang sejak awal, bukan ditambahkan setelah parser dan macro mulai kompleks. Pada bahasa yang syntax-nya berkembang cepat—seperti konteks yang terlihat pada rilis Rhombus v1.0—perubahan kecil di grammar, ekspansi macro, atau prioritas parsing sering mematahkan perilaku lama tanpa terlihat jelas sampai pengguna menemukan kasus aneh di produksi atau di repositori mereka.
Masalah utamanya bukan hanya apakah parser masih bisa membaca input valid, tetapi apakah perubahan syntax tetap menjaga shape AST, pesan error tetap dapat dipahami, ekspansi macro tetap deterministik, dan fitur baru tidak merusak kompatibilitas perilaku yang sebelumnya benar. Karena itu, pengujian untuk bahasa baru perlu menggabungkan beberapa lapisan: golden test, differential testing, property-based testing, snapshot error yang stabil, serta CI yang peka terhadap perubahan grammar.
Mengapa parser dan macro mudah mengalami regresi
Parser dan macro punya karakteristik yang membuat regresi sulit dideteksi dengan tes biasa. Satu perubahan token, satu aturan prioritas operator, atau satu transformasi macro dapat memengaruhi banyak jalur kompilasi sekaligus. Efeknya sering tidak lokal.
- Grammar ambigu atau hampir ambigu: penambahan bentuk syntax baru dapat mengubah parse untuk input lama.
- Macro expansion berlapis: perubahan satu macro dasar dapat mengubah hasil ekspansi macro lain secara tidak langsung.
- Representasi internal berubah: refactor AST atau normalisasi token dapat membuat snapshot lama rusak meskipun perilaku eksternal tetap benar.
- Error path rapuh: pesan error mudah berubah karena pergeseran lokasi token, wording, atau urutan validasi.
- Kasus edge sulit dibayangkan manual: whitespace, komentar, delimiter bersarang, precedence, dan recovery error sering lolos dari unit test sederhana.
Karena itu, strategi yang baik tidak bergantung pada satu jenis tes. Anda perlu memisahkan apa yang dianggap kontrak publik dan apa yang boleh berubah secara internal.
Bangun piramida test untuk toolchain bahasa
Untuk pembuat compiler, DSL, parser, atau macro system, susunan tes yang berguna biasanya seperti ini:
- Unit test kecil untuk lexer, parser combinator, resolver nama, dan fungsi ekspansi tertentu.
- Golden test untuk input source ke output yang dianggap kontrak: AST ternormalisasi, hasil pretty-print, atau diagnostics.
- Property-based test untuk invariant yang berlaku luas, misalnya parse-pretty-parse atau expansion idempotency tertentu.
- Differential test untuk membandingkan dua implementasi, dua mode parser, atau dua versi pipeline.
- Integration test untuk file proyek nyata, termasuk import/module boundary.
- Regression corpus dari bug yang pernah ditemukan pengguna atau fuzzing.
Jika semua tes hanya berupa snapshot output mentah, suite akan rapuh. Jika semua tes hanya unit test, banyak interaksi grammar dan macro tidak tertangkap. Keseimbangannya penting.
Golden test yang tahan perubahan
Golden test berguna untuk bahasa baru karena mudah dibaca dan cepat menunjukkan apa yang berubah. Namun, golden test mudah menjadi beban jika output yang disimpan terlalu detail atau terlalu dekat dengan representasi internal.
Apa yang layak dijadikan golden output
Pilih output yang mewakili kontrak yang benar-benar ingin dijaga:
- AST ternormalisasi, bukan struktur internal lengkap dengan ID node acak.
- Hasil desugaring tingkat publik, jika macro atau syntax sugar memang bagian penting dari desain bahasa.
- Pretty-printed canonical form, bila bahasa punya formatter atau bentuk normal source.
- Diagnostics terstruktur, bukan string mentah yang terlalu sensitif.
Hindari menyimpan field yang tidak penting bagi pengguna tes, misalnya alamat memori, urutan hash map, nomor node internal, timestamp, atau path absolut.
Contoh format golden test yang stabil
Daripada menyimpan dump objek parser mentah, simpan bentuk yang sudah dinormalisasi:
# input.rh
let x = add(1, 2 * 3)
# expected.ast
(Let
name: x
value: (Call
callee: add
args: [
(Int 1)
(Binary '*'
left: (Int 2)
right: (Int 3))]))Format di atas masih manusiawi untuk ditinjau saat diff, tetapi tidak mengunci detail internal yang tidak relevan.
Kapan snapshot boleh dipakai
Snapshot atau golden output boleh dipakai jika:
- Output adalah kontrak yang memang ingin dilindungi.
- Representasinya sudah dinormalisasi dan stabil.
- Reviewer dapat menilai perubahan dengan cepat.
- Perubahan besar pada snapshot berarti perubahan perilaku yang memang perlu disetujui secara sadar.
Snapshot kurang tepat jika:
- Output penuh noise dari detail implementasi.
- Perubahan legal terlalu sering membuat pembaruan snapshot masif.
- Tes hanya memverifikasi string panjang tanpa invariant yang lebih bermakna.
Prinsip praktis: snapshot cocok untuk surface behavior yang stabil, bukan untuk mencerminkan seluruh isi objek internal compiler.
Error snapshot yang stabil dan berguna
Pengujian error sering diabaikan, padahal pada bahasa baru justru jalur gagal berubah paling sering. Tujuannya bukan membekukan setiap kata dalam pesan error, melainkan menjaga kualitas diagnosis: kategori error, rentang sumber, dan petunjuk utama.
Gunakan diagnostics terstruktur
Daripada membandingkan satu string panjang, uji struktur seperti ini:
{
"code": "parse.unexpected_token",
"severity": "error",
"span": { "line": 1, "start_col": 9, "end_col": 10 },
"primary_message": "token ')' tidak valid pada konteks ekspresi",
"notes": ["setelah operator biner, parser mengharapkan operand"]
}Lalu normalisasi bagian yang mudah berubah:
- Gunakan path relatif, bukan absolut.
- Jika lokasi byte offset berubah antar platform, prioritaskan line/column.
- Jangan snapshot ANSI color atau formatting terminal.
- Pisahkan error code dari wording agar copy edit kecil tidak merusak banyak tes.
Pola uji untuk input invalid
Untuk syntax yang berkembang, buat corpus invalid berdasarkan kelas kegagalan, bukan contoh acak semata:
- Delimiter tidak seimbang: kurung, kurawal, tanda kutip.
- Operator tanpa operand kiri/kanan.
- Keyword valid pada konteks yang salah.
- Macro invocation dengan arity salah.
- Trailing separator yang dilarang.
- Indentasi atau pemisah baris ambigu, jika bahasa sensitif terhadap layout.
- Nested construct yang dipotong di tengah file.
Uji yang baik tidak hanya memastikan parser gagal, tetapi juga memastikan ia gagal dengan alasan yang tepat dan tidak masuk ke jalur recovery yang menyesatkan.
Differential testing untuk parser dan ekspansi macro
Differential testing membandingkan dua sistem yang seharusnya setara secara semantik. Ini sangat efektif ketika Anda menulis parser baru, menambahkan syntax layer, atau memigrasikan mekanisme macro.
Sumber pembanding yang umum
- Parser lama vs parser baru pada subset syntax yang sama.
- Interpreter langsung vs hasil kompilasi untuk program kecil yang aman dijalankan.
- Mode syntax sugar aktif vs hasil desugaring manual.
- Implementasi referensi sederhana vs implementasi optimisasi.
Misalnya, jika Anda menambahkan parser baru untuk ekspresi infix, Anda dapat membandingkan AST ternormalisasi dari parser baru dengan AST dari bentuk prefix yang sudah matang setelah tahap desugaring.
input baru: a + b * c
input acuan: add(a, mul(b, c))
normalisasi kedua hasil parse/expand harus setara secara struktur.Apa yang dibandingkan
Jangan selalu membandingkan output mentah. Pilihan yang lebih tahan perubahan:
- AST setelah normalisasi.
- IR menengah setelah desugaring.
- Hasil evaluasi untuk program deterministik kecil.
- Kelas diagnostics untuk input invalid.
Trade-off-nya, differential testing butuh oracle pembanding. Jika dua implementasi salah dengan cara yang sama, tes bisa lolos. Karena itu, teknik ini sebaiknya melengkapi, bukan menggantikan, golden dan property-based test.
Property-based testing: cari bug yang tidak terpikirkan
Jika grammar dan macro sering berubah, property-based testing membantu menjelajah ruang input yang terlalu besar untuk dicakup manual. Kuncinya adalah mendefinisikan invariant yang benar-benar relevan.
Property yang sering berguna
- Round-trip parse/pretty/parse: source valid diparse, di-format ke bentuk kanonik, lalu diparse lagi menghasilkan AST yang ekuivalen.
- Parser tidak crash: untuk input acak atau hampir valid, hasilnya harus salah-terkontrol, bukan panic atau hang.
- Expansion preserves invariants: macro expansion tidak meninggalkan identifier tak terikat pada tahap tertentu, jika itu bukan perilaku yang diizinkan.
- Normalization idempotent: AST atau IR yang dinormalisasi dua kali tetap sama.
- Recovery bounded: input invalid tidak menyebabkan ledakan jumlah diagnostics atau loop tak selesai.
Contoh generator input
Generator acak murni sering menghasilkan sampah yang tidak menembus parser. Lebih efektif memakai generator berbasis grammar atau AST:
// pseudo-code
Expr := Int
| Var
| Binary(op, Expr, Expr)
| Call(Expr, List<Expr>)
| If(Expr, Expr, Expr)
property round_trip(expr):
src1 = pretty(expr)
ast1 = parse(src1)
src2 = pretty(ast1)
ast2 = parse(src2)
assert normalize(ast1) == normalize(ast2)Dengan pendekatan AST-first, Anda menghasilkan program yang pasti valid lebih sering, sehingga properti round-trip menjadi lebih bernilai.
Batasi ukuran dan kompleksitas
Masalah umum property-based test adalah kasus menjadi terlalu besar atau terlalu lambat. Terapkan:
- Batas kedalaman AST.
- Distribusi berbobot agar bentuk sederhana tetap dominan.
- Shrinking atau minimisasi kasus gagal agar bug mudah direproduksi.
- Timeout per kasus untuk mencegah parser atau macro expansion macet.
Matriks test yang praktis untuk syntax layer
Saat syntax berkembang, daftar tes ad hoc cepat berantakan. Lebih baik bangun matriks test per fitur dan per risiko. Berikut contoh matriks ringkas yang bisa dipakai tim compiler atau DSL.
| Area | Valid parse | Invalid parse | AST/expand golden | Error diag | Diff test | Property test |
|-----------------------|-------------|---------------|-------------------|------------|-----------|---------------|
| Literal & token | Ya | Ya | Opsional | Ya | Tidak | Ya |
| Precedence operator | Ya | Ya | Ya | Ya | Ya | Ya |
| Delimiter bersarang | Ya | Ya | Ya | Ya | Tidak | Ya |
| Macro dasar | Ya | Ya | Ya | Ya | Ya | Ya |
| Hygiene / binding | Ya | Opsional | Ya | Opsional | Ya | Ya |
| Modul / import | Ya | Ya | Opsional | Ya | Ya | Tidak |
| Recovery error | Tidak | Ya | Tidak | Ya | Tidak | Ya |Matriks ini membantu memastikan fitur baru tidak hanya mendapat tes happy path, tetapi juga tes invalid, diagnostics, dan perilaku lintas pipeline.
Pola input valid dan invalid yang sebaiknya selalu ada
Input valid
- Bentuk paling kecil yang sah untuk setiap konstruksi syntax.
- Kombinasi operator dengan precedence berbeda.
- Nesting dalam 2-3 level, bukan hanya satu level.
- Interaksi macro dengan binding, scope, atau import.
- Varian whitespace, komentar, dan line break.
- Kasus yang menyerupai kode pengguna nyata, bukan hanya fragmen satu baris.
Input invalid
- Token valid pada posisi salah.
- Syntax benar sebagian lalu terputus di akhir file.
- Separator ekstra atau separator hilang.
- Pola hampir valid yang rawan ambigu.
- Macro form yang valid secara parse tetapi salah secara aturan semantik awal.
- Kombinasi fitur baru dengan fitur lama yang sebelumnya stabil.
Prinsip pentingnya: setiap bug yang pernah ditemukan harus masuk corpus regresi dalam bentuk sekecil mungkin.
Minimisasi flaky test pada parser dan compiler front-end
Flaky test pada toolchain biasanya bukan karena race condition saja. Sering penyebabnya adalah output yang bergantung pada urutan map, path sistem, locale, newline, atau format error yang berbeda antar lingkungan.
Sumber flaky yang umum
- Urutan traversal struktur data tidak deterministik.
- Snapshot memuat path absolut atau nomor temporer.
- Perbedaan newline antar OS.
- Parallel test mengakses file fixture yang sama.
- Generator acak tanpa seed yang dicatat.
- Timeout terlalu ketat untuk mesin CI yang lebih lambat.
Cara menguranginya
- Sort output sebelum dibandingkan jika urutan bukan bagian dari kontrak.
- Normalisasi path, newline, encoding, dan warna terminal.
- Simpan seed untuk property/fuzz test yang gagal agar mudah direproduksi.
- Gunakan direktori kerja sementara per test.
- Pisahkan tes cepat dan tes eksploratif yang lebih mahal.
- Tetapkan batas waktu yang realistis dan ukur jalur lambat secara berkala.
Jika sebuah tes sering gagal hanya di CI, anggap itu bug pada tes atau pada determinisme toolchain, bukan sekadar gangguan infrastruktur.
Workflow CI untuk grammar dan syntax yang berubah cepat
Perubahan syntax tidak cukup dijaga dengan menjalankan seluruh suite sekali. CI yang efektif perlu memberi sinyal cepat saat grammar, parser, atau macro system disentuh.
Pipeline yang disarankan
- Lint dan build front-end.
- Unit test parser/lexer/macro helper.
- Golden test cepat untuk corpus inti.
- Differential test pada subset syntax yang sudah punya pembanding.
- Property-based/fuzz smoke run dengan batas waktu pendek di pull request.
- Run lebih panjang terjadwal harian atau mingguan untuk fuzz/property yang lebih dalam.
- Corpus regresi bug wajib dijalankan di setiap perubahan parser atau grammar.
Pemicu berbasis area perubahan
Jika repositori cukup besar, pertimbangkan aturan sederhana:
- Perubahan file grammar atau parser menjalankan suite syntax penuh.
- Perubahan macro core menjalankan expand/hygiene tests.
- Perubahan formatter menjalankan round-trip tests.
- Perubahan diagnostics menjalankan snapshot error tests.
Ini bukan untuk mengurangi kualitas, tetapi untuk menjaga umpan balik tetap cepat tanpa melewatkan area berisiko tinggi.
Review snapshot di CI
Jika snapshot berubah, CI sebaiknya menampilkan diff yang mudah dibaca. Hindari alur yang langsung menerima pembaruan snapshot tanpa tinjauan manusia. Untuk bahasa baru, perubahan syntax yang tampak kecil sering memiliki konsekuensi luas.
Checklist pencegahan regresi saat syntax berkembang
- Apakah fitur syntax baru punya tes valid dan invalid?
- Apakah precedence, associativity, dan nesting sudah diuji eksplisit?
- Apakah ada golden output dalam bentuk ternormalisasi, bukan dump internal mentah?
- Apakah diagnostics diuji secara terstruktur, termasuk error code dan span utama?
- Apakah bug lama yang serupa sudah dimasukkan ke corpus regresi?
- Apakah perubahan dapat dibandingkan dengan implementasi lama atau bentuk desugared yang setara?
- Apakah ada property penting yang perlu ditambahkan, misalnya round-trip atau non-crash?
- Apakah fixture dan snapshot sudah dinormalisasi dari path, newline, dan urutan nondeterministik?
- Apakah CI menjalankan suite yang tepat ketika grammar, parser, atau macro diubah?
- Apakah kasus gagal dari fuzz/property test dapat direproduksi dengan seed atau input minimal?
Kesalahan umum yang perlu dihindari
- Terlalu banyak snapshot mentah: cepat dibuat, tetapi mahal dirawat.
- Hanya menguji input valid: jalur error dan recovery justru sering paling rapuh.
- Menganggap formatter = oracle kebenaran: pretty-printer bisa salah dengan cara yang menutupi bug parse.
- Tidak menormalisasi AST/diagnostics: test menjadi noise-heavy dan sulit direview.
- Menambahkan fitur syntax tanpa corpus regresi: bug lama mudah hidup kembali.
- Menjalankan fuzz hanya sesekali secara manual: temuan bagus tidak masuk alur tetap tim.
Penutup
Strategi uji bahasa baru yang efektif tidak berpusat pada satu alat, melainkan pada kombinasi kontrak yang tepat. Golden test melindungi perilaku yang terlihat, differential testing membantu mendeteksi penyimpangan saat migrasi parser atau syntax layer, property-based testing menangkap ruang kasus yang terlalu besar untuk ditulis manual, dan snapshot error yang stabil menjaga kualitas diagnostics tanpa membuat suite rapuh.
Dalam konteks bahasa yang berkembang cepat seperti Rhombus v1.0, risiko regresi bukan tanda desain buruk; itu konsekuensi alami dari syntax dan macro yang makin kaya. Yang membedakan toolchain yang andal adalah disiplin dalam menentukan apa yang dianggap kontrak, bagaimana menormalkannya, dan bagaimana CI memastikan setiap perubahan grammar diuji dengan sudut pandang yang tepat.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!