Strategi uji parser cepat tidak cukup hanya menambahkan benchmark ke test suite lalu berharap semua regresi akan tertangkap. Untuk parser berperforma tinggi, masalah utamanya ada dua: correctness harus tetap terjaga saat optimasi agresif dilakukan, dan pengukuran performa harus tetap berguna tanpa menjadi flaky benchmark yang sering gagal karena noise lingkungan.

Pendekatan yang paling praktis adalah memisahkan lapisan pengujian berdasarkan tujuan: golden test untuk perilaku yang harus stabil, property-based dan fuzz test untuk menjelajahi ruang input yang sulit dipikirkan manual, differential test untuk membandingkan hasil dengan parser pembanding, corpus regresi bug untuk memastikan bug lama tidak muncul lagi, dan benchmark terisolasi untuk mendeteksi perubahan performa dengan noise yang terkontrol. Pola ini cocok untuk parser yang dioptimalkan dengan prinsip data-oriented design, yaitu mengutamakan tata letak data, locality, dan alur eksekusi yang efisien.

Mengapa parser cepat butuh strategi uji yang berbeda

Parser berperforma tinggi sering memakai teknik yang membuat kode lebih sensitif terhadap regresi: pemindaian buffer secara linear, minim alokasi, representasi token yang padat, tabel transisi, atau pemisahan fase lexing dan parsing agar cache lebih efektif. Optimasi seperti ini biasanya mengurangi overhead, tetapi juga meningkatkan risiko bug halus seperti:

  • offset token salah satu byte,
  • penanganan akhir input yang keliru,
  • cabang error yang tidak konsisten,
  • perbedaan interpretasi whitespace atau escape sequence,
  • perubahan performa akibat alokasi tak sengaja atau branch yang memburuk.

Karena itu, pengujian parser tidak boleh hanya berisi assert terhadap beberapa contoh input kecil. Anda perlu memastikan dua hal:

  1. Semantik parser tetap benar untuk input normal, aneh, rusak, dan historis.
  2. Optimasi tidak diam-diam merusak throughput atau latensi, tetapi pengukuran performanya juga tidak dicampur ke unit test harian yang rawan noise.

Prinsip pentingnya: unit test harus memberi sinyal deterministik. Benchmark boleh otomatis, tetapi harus dijalankan dalam lingkungan dan aturan evaluasi yang berbeda.

Pembagian test suite yang tahan regresi

1. Golden tests untuk perilaku yang harus tetap stabil

Golden test memverifikasi bahwa input tertentu menghasilkan output terstruktur yang persis sama dengan yang diharapkan. Ini cocok untuk parser karena banyak bug nyata muncul pada contoh konkret: nested structure, string escape, komentar, delimiter kosong, atau pesan error pada format rusak.

Isi golden test sebaiknya tidak hanya AST akhir. Simpan juga artefak yang membantu diagnosis, misalnya:

  • daftar token,
  • AST atau parse tree yang sudah dinormalisasi,
  • lokasi span atau offset penting,
  • jenis error dan posisi error.

Agar tidak rapuh, jangan simpan detail yang tidak relevan. Misalnya, jika urutan field internal tidak memengaruhi perilaku publik, jangan jadikan itu bagian dari golden output.

function runGoldenTest(caseFile):
    input = readText(caseFile + ".input")
    expected = readText(caseFile + ".golden")

    result = parse(input)
    normalized = normalize(result)

    assert serialize(normalized) == expected

Mengapa ini efektif: golden test memberi sinyal perubahan perilaku yang cepat dan mudah ditinjau dalam code review. Jika output berubah, reviewer bisa melihat apakah itu perbaikan yang disengaja atau regresi.

Trade-off: terlalu banyak golden test dengan output mentah dapat sulit dipelihara. Solusinya adalah normalisasi output dan pengelompokan kasus per fitur parser.

2. Property-based test untuk invariant parser

Property-based test berguna ketika sulit menulis semua input manual. Alih-alih menyebutkan satu contoh, Anda mendefinisikan sifat yang harus selalu benar. Untuk parser, contoh invariant yang umum:

  • parser tidak boleh crash untuk input byte apa pun,
  • jika input valid dibangkitkan dari grammar sederhana, hasil parse harus valid,
  • serialize(parse(x)) lalu parse lagi menghasilkan representasi yang ekuivalen,
  • semua span token berada dalam batas panjang input,
  • error reporting tidak boleh menunjuk offset negatif atau di luar buffer.
property "round-trip untuk input valid":
    for each generatedDocument in generateValidDocuments():
        ast1 = parse(generatedDocument)
        text2 = serialize(ast1)
        ast2 = parse(text2)
        assert equivalent(ast1, ast2)

Mengapa ini efektif: pendekatan ini mengeksplorasi kombinasi input yang tidak terpikirkan saat menulis test manual, terutama batas panjang, nesting, karakter khusus, dan variasi pemisah.

Kesalahan umum: generator terlalu acak tanpa memahami grammar sehingga sebagian besar kasus hanya menguji jalur error. Lebih baik gabungkan generator valid dan invalid secara terpisah agar cakupan lebih bermakna.

3. Fuzz test untuk robustness dan keamanan

Fuzz test mirip property-based test, tetapi fokusnya pada ketahanan parser terhadap input rusak, bising, atau tidak terduga. Sasaran utamanya bukan kesesuaian semantik tingkat tinggi, melainkan mendeteksi crash, hang, alokasi berlebihan, atau perilaku tak aman.

Pada parser cepat, fuzzing sangat berguna untuk menemukan:

  • loop yang tidak maju pada karakter tertentu,
  • integer overflow pada perhitungan offset,
  • penggunaan memori yang meledak akibat nesting ekstrem,
  • jalur error yang mengakses token belum terinisialisasi.
function fuzzOne(inputBytes):
    result = parseBytes(inputBytes)
    assert noCrash()
    assert noInfiniteLoop()
    assert offsetsWithinBounds(result, length(inputBytes))

Jika memungkinkan, simpan input yang memicu masalah ke corpus regresi bug. Jangan biarkan hasil fuzz berhenti sebagai log sementara.

4. Differential test terhadap parser pembanding

Differential testing membandingkan hasil parser Anda dengan parser lain yang dianggap referensi. Ini sangat membantu saat Anda sedang menulis parser cepat yang semantiknya harus kompatibel dengan implementasi yang sudah matang, meskipun mungkin lebih lambat.

Alurnya sederhana:

  1. jalankan input yang sama ke parser utama dan parser pembanding,
  2. normalisasi hasil keduanya ke bentuk yang sebanding,
  3. bandingkan output atau error class.
function differentialTest(input):
    ours = normalize(parseWithOurs(input))
    reference = normalize(parseWithReference(input))

    assert equivalent(ours, reference)

Kapan ini cocok:

  • ada spesifikasi yang ambigu dan implementasi referensi lebih dipercaya,
  • Anda sedang mengganti parser lama ke parser baru yang lebih cepat,
  • format input punya banyak kasus pinggir yang sulit dicakup manual.

Keterbatasan: parser pembanding bisa punya bug juga. Karena itu, differential test sebaiknya dipakai sebagai sinyal investigasi, bukan bukti absolut. Jika hasil berbeda, periksa spesifikasi atau perilaku yang memang disengaja berbeda.

5. Corpus regresi bug yang terus bertambah

Setiap bug parser yang pernah lolos ke produksi atau ditemukan fuzzing harus berubah menjadi satu file kecil dalam corpus regresi. Ini adalah lapisan pertahanan paling murah dan paling bernilai tinggi.

Struktur corpus yang praktis:

  • valid/ untuk input yang dulu gagal tetapi seharusnya berhasil,
  • invalid/ untuk input yang seharusnya gagal dengan aman,
  • perf/ untuk sampel besar yang penting bagi benchmark,
  • metadata singkat berisi konteks bug atau issue ID.

Jangan hanya menyimpan file input. Simpan juga ekspektasi minimum: sukses/gagal, tipe error, atau ringkasan AST yang relevan.

Benchmark terisolasi di CI: perlu, tetapi jangan disamakan dengan unit test

Mengapa benchmark sering flaky

Flaky benchmark terjadi ketika hasil pengukuran berubah bukan karena kode parser berubah, melainkan karena lingkungan eksekusi berubah. Penyebab umumnya:

  • CPU shared runner sedang sibuk oleh job lain,
  • frekuensi CPU berubah karena turbo boost atau thermal throttling,
  • GC atau allocator berjalan berbeda antar run,
  • cache file system dan page cache tidak konsisten,
  • dataset benchmark terlalu kecil sehingga noise lebih dominan daripada kerja nyata,
  • waktu startup proses ikut terukur padahal yang ingin diukur hanya fungsi parse,
  • benchmark menjalankan terlalu sedikit iterasi.

Akibatnya, threshold sederhana seperti “gagal jika lebih lambat 5%” sering menghasilkan alarm palsu. Ini membuat tim kehilangan kepercayaan pada sinyal performa.

Cara menetapkan baseline yang berguna

Baseline performa sebaiknya tidak berasal dari satu angka tunggal dari satu run. Lebih aman memakai pendekatan berikut:

  • ukur pada input tetap yang representatif,
  • jalankan beberapa iterasi dan simpan distribusi hasil,
  • bandingkan terhadap baseline dari branch utama atau rilis terakhir pada lingkungan serupa,
  • pakai metrik yang jelas: throughput, waktu per byte, alokasi, atau peak memory,
  • tetapkan ambang berdasarkan variasi historis, bukan tebakan.

Jika variasi alami benchmark sekitar beberapa persen, threshold harus mempertimbangkan itu. Untuk parser, metrik tambahan seperti jumlah alokasi atau byte yang diproses per detik sering lebih stabil dibanding total waktu end-to-end dari proses yang baru start.

Noise control untuk benchmark parser

Semakin terisolasi lingkungan benchmark, semakin berguna hasilnya. Praktik yang umum dipakai:

  • jalankan benchmark di mesin khusus atau runner yang relatif stabil,
  • gunakan dataset lokal yang sama, bukan mengunduh saat test,
  • pisahkan fase warm-up dan measurement,
  • matikan kerja latar belakang yang tidak perlu bila memungkinkan,
  • ukur lebih dari satu ukuran input: kecil, sedang, besar,
  • laporkan median atau percentil, bukan hanya satu nilai minimum.
function runBenchmark(parser, dataset, iterations):
    warmUp(parser, dataset)

    samples = []
    repeat iterations times:
        start = monotonicTime()
        result = parseAll(parser, dataset)
        end = monotonicTime()

        assert result.successRate == expectedSuccessRate
        samples.append(end - start)

    return summarize(samples)   // median, p95, allocations if available

Catatan penting: benchmark tetap harus memverifikasi correctness minimum. Jangan mengukur fungsi parse yang diam-diam berhenti lebih awal atau mengabaikan error agar terlihat cepat.

Kapan benchmark dipisahkan dari unit test

Pisahkan benchmark dari unit test jika salah satu kondisi berikut terjadi:

  • hasilnya sensitif terhadap lingkungan CI umum,
  • waktu eksekusinya cukup lama,
  • memerlukan mesin atau konfigurasi khusus,
  • perlu statistik dari banyak iterasi,
  • memakai dataset besar yang tidak cocok untuk test harian.

Dalam praktiknya, unit test dan test correctness tetap berjalan di setiap pull request. Sementara benchmark dijalankan pada runner khusus, jadwal terpisah, atau hanya untuk pull request yang menyentuh area parser/performance-critical path.

Contoh alur CI yang seimbang

Tujuan CI bukan menjalankan semua hal di semua tempat, melainkan memberi sinyal yang cepat dan dapat dipercaya. Berikut alur yang umum dan praktis:

Pull request biasa

  1. lint dan static analysis,
  2. unit test parser,
  3. golden test,
  4. corpus regresi bug,
  5. property-based test dengan batas waktu atau jumlah sampel moderat,
  6. differential test untuk subset corpus penting.

Di tahap ini, fokusnya correctness dan determinisme.

Pull request yang menyentuh parser inti

  1. semua langkah di atas,
  2. fuzz smoke test singkat,
  3. microbenchmark pada runner yang lebih stabil,
  4. perbandingan terhadap baseline branch utama.

Jika benchmark menunjukkan penurunan, tandai sebagai needs investigation alih-alih langsung memblokir merge, kecuali tim Anda memang sudah punya infra benchmark yang sangat stabil.

Nightly atau scheduled job

  1. fuzz test yang berjalan lebih lama,
  2. differential test terhadap corpus yang lebih besar,
  3. benchmark penuh pada dataset representatif,
  4. publikasi tren performa dan alokasi.
pipeline:
  pr:
    - lint
    - unit-tests
    - golden-tests
    - regression-corpus
    - property-tests-short
    - differential-tests-core

  pr-parser-changes:
    - fuzz-smoke
    - isolated-benchmark

  nightly:
    - fuzz-long
    - differential-tests-full
    - benchmark-full
    - publish-performance-report

Pola ini menjaga feedback harian tetap cepat, sambil tetap menyediakan pengawasan performa yang realistis.

Strategi desain test yang selaras dengan data-oriented parser

Inspirasi dari data-oriented design relevan bukan hanya saat menulis parser, tetapi juga saat mengujinya. Jika parser Anda memisahkan tahap scanning, tokenization, dan parsing untuk efisiensi data, test suite sebaiknya mengikuti batas tersebut.

  • Uji scanner secara terpisah untuk boundary input, byte class, whitespace, dan delimiter.
  • Uji token stream untuk offset, jenis token, dan kestabilan urutan.
  • Uji parser struktural untuk tree, precedence, atau nesting.
  • Uji integrasi end-to-end hanya untuk skenario penting.

Manfaatnya ada dua. Pertama, diagnosis regresi lebih cepat karena area rusak lebih sempit. Kedua, optimasi lokal—misalnya mengganti representasi token agar lebih padat—tidak memaksa semua test level tinggi ikut berubah.

Checklist investigasi regresi parser

Ketika terjadi regresi correctness atau performa, gunakan checklist agar investigasi tidak melompat-lompat:

Jika correctness gagal

  1. Apakah perubahan ada di scanner, tokenizer, parser, atau serializer?
  2. Apakah input gagal termasuk kasus valid, invalid, atau ambigu menurut spesifikasi?
  3. Apakah offset/span salah sejak tahap tokenization?
  4. Apakah golden output berubah karena bug fix yang disengaja atau perubahan perilaku tak sengaja?
  5. Apakah parser pembanding menunjukkan hasil yang sama atau berbeda?
  6. Apakah kasus tersebut sudah masuk corpus regresi setelah diperbaiki?

Jika performa turun

  1. Apakah penurunan konsisten di beberapa run atau hanya satu sampel?
  2. Apakah dataset benchmark representatif terhadap beban nyata?
  3. Apakah ada alokasi tambahan, copy buffer baru, atau struktur data yang membesar?
  4. Apakah perubahan ada di jalur panas seperti scanning karakter, dispatch token, atau pembuatan node?
  5. Apakah correctness masih sama, atau parser sekarang melakukan kerja tambahan yang memang valid?
  6. Apakah penurunan terjadi di semua ukuran input atau hanya input kecil karena overhead tetap?

Jika benchmark tampak mencurigakan

  1. Apakah runner benchmark sedang padat?
  2. Apakah warm-up dilakukan?
  3. Apakah startup proses atau I/O ikut terukur?
  4. Apakah jumlah iterasi terlalu sedikit?
  5. Apakah baseline diambil dari lingkungan yang berbeda?

Kesalahan umum yang membuat suite parser rapuh

  • Mencampur benchmark dengan unit test. Hasilnya lambat dan sering palsu.
  • Golden test terlalu detail. Perubahan internal kecil memicu diff besar tanpa nilai diagnostik.
  • Property test tanpa invariant yang jelas. Test berjalan, tetapi tidak memberi jaminan yang berarti.
  • Fuzzing tanpa menyimpan corpus hasil minimisasi. Bug ditemukan sekali lalu hilang dari siklus pengembangan.
  • Differential test tanpa normalisasi output. Perbedaan representasi dianggap bug padahal semantik sama.
  • Benchmark hanya mengukur waktu. Kadang akar masalah justru ada di alokasi atau memori puncak.

Rekomendasi implementasi yang praktis

Jika Anda sedang membangun parser cepat dari nol atau sedang merapikan suite lama, urutan kerja berikut biasanya paling efektif:

  1. Buat golden test untuk fitur utama dan error penting.
  2. Tambahkan corpus regresi dari bug historis atau hasil review.
  3. Tambahkan property-based test untuk invariant inti seperti round-trip, bounds, dan no-crash.
  4. Jika ada parser pembanding yang cukup tepercaya, bangun differential test pada subset corpus.
  5. Siapkan benchmark terisolasi dengan dataset tetap, warm-up, beberapa iterasi, dan pelaporan median.
  6. Pisahkan benchmark dari unit test pull request biasa jika runner tidak stabil.

Dengan struktur ini, Anda mendapatkan sinyal yang cepat untuk correctness dan sinyal yang lebih hati-hati untuk performa. Itulah inti strategi uji parser cepat: jangan mengorbankan kepercayaan terhadap test suite hanya demi otomatisasi yang terlalu agresif.

Penutup

Parser berperforma tinggi sering dioptimalkan di jalur yang sangat sensitif terhadap bug dan noise pengukuran. Karena itu, strategi pengujiannya harus berlapis. Gunakan golden test untuk perilaku yang harus stabil, property-based dan fuzz test untuk eksplorasi input, differential test untuk validasi semantik terhadap pembanding, corpus regresi untuk bug nyata, dan benchmark terisolasi untuk memantau performa tanpa membuat CI harian menjadi rapuh.

Jika ada satu prinsip yang perlu dipegang, itu adalah ini: correctness harus diuji sering dan deterministik, sedangkan performa harus diuji teratur tetapi dalam lingkungan yang dikendalikan. Dengan begitu, Anda bisa terus mengoptimalkan parser tanpa kehilangan kepercayaan pada hasil test.