Strategi test geometri 3D tidak bisa mengandalkan perbandingan angka secara naif. Pada library geometri 3D berbasis C—seperti konteks yang relevan dari pengumuman Box3D—sumber flaky test biasanya berasal dari akumulasi error floating point, percabangan collision detection yang sensitif terhadap toleransi, dan hasil yang sedikit berbeda antar compiler atau arsitektur CPU.

Solusinya bukan sekadar “menambah epsilon”. Test yang stabil membutuhkan kombinasi: assertion numerik yang tepat, fixture yang deterministik, pemisahan jenis test berdasarkan tujuan, dan pipeline CI yang sadar bahwa validasi matematis berat tidak selalu cocok dijalankan di setiap commit. Artikel ini fokus pada praktik yang bisa langsung dipakai untuk mencegah regresi tanpa membuat suite test penuh false positive.

Mengapa test geometri 3D sering flaky?

Pada engine geometri atau collision detection, hasil komputasi sering berada di batas keputusan: dua bentuk hampir bersentuhan, sebuah ray nyaris sejajar dengan plane, atau solver iteratif berhenti setelah toleransi tertentu. Dalam kondisi seperti ini, perubahan kecil pada urutan evaluasi floating point dapat mengubah hasil akhir.

Sumber utama flaky test

  • Floating point tidak asosiatif: (a + b) + c belum tentu sama dengan a + (b + c).
  • Compiler optimization: penggabungan operasi, reordering, atau pemakaian instruksi berbeda dapat mengubah bit hasil.
  • Perbedaan platform: x86, ARM, mode rounding, dan implementasi math library dapat menghasilkan nilai yang sedikit berbeda.
  • Threshold collision detection: perbandingan seperti distance < 0 atau dot > limit rentan berubah jika input dekat ambang.
  • Input random tanpa seed tetap: bug sulit direproduksi.
  • Golden output yang terlalu presisi: test gagal karena beda ulp kecil, padahal perilaku fungsional masih benar.

Masalah inti flaky test di geometri 3D biasanya bukan “algoritma salah total”, melainkan “test mengasumsikan determinisme lebih tinggi daripada yang dijamin floating point”.

Prinsip dasar: jangan bandingkan float dengan kesetaraan mentah

Kesalahan paling umum adalah menulis assertion seperti assert(a == b) untuk hasil vektor, matriks, jarak, atau waktu tumbukan. Untuk geometri 3D, bandingkan dengan toleransi yang mempertimbangkan skala nilai.

Absolute vs relative epsilon

Absolute epsilon cocok untuk nilai dekat nol. Relative epsilon lebih tepat untuk nilai besar. Dalam praktik, gabungkan keduanya.

/* C sederhana untuk perbandingan float yang lebih stabil */
#include <math.h>
#include <stdbool.h>

static bool nearly_equal(double a, double b, double abs_eps, double rel_eps)
{
    double diff = fabs(a - b);
    if (diff <= abs_eps) {
        return true;
    }

    double scale = fmax(fabs(a), fabs(b));
    return diff <= scale * rel_eps;
}

static bool vec3_nearly_equal(const double a[3], const double b[3], double abs_eps, double rel_eps)
{
    return nearly_equal(a[0], b[0], abs_eps, rel_eps) &&
           nearly_equal(a[1], b[1], abs_eps, rel_eps) &&
           nearly_equal(a[2], b[2], abs_eps, rel_eps);
}

Poin pentingnya: epsilon harus terkait domain. Epsilon untuk normal vector satuan tidak sama dengan epsilon untuk koordinat dunia berukuran kilometer.

Hindari satu epsilon global untuk semua test

Satu nilai toleransi global sering menjadi sumber dua masalah:

  • Terlalu kecil, sehingga test sering gagal padahal hasil masih valid.
  • Terlalu besar, sehingga bug nyata lolos.

Lebih aman mendefinisikan toleransi per kategori:

  • Transform dan matriks: fokus pada kestabilan orthonormalitas dan panjang basis.
  • Distance query: toleransi terhadap jarak absolut.
  • Collision manifold: cek jumlah kontak, orientasi normal, dan penetrasi dalam rentang yang wajar.
  • Ray cast / shape cast: toleransi khusus untuk time-of-impact atau fraction hit.

Test pyramid untuk math engine geometri 3D

Untuk mencegah regresi tanpa membuat pipeline lambat dan rapuh, gunakan test pyramid yang disesuaikan untuk engine matematika.

1) Unit test deterministik sebagai fondasi

Lapisan terbawah harus berisi test cepat, kecil, dan minim random. Tujuannya bukan membuktikan sistem benar untuk semua input, tetapi memastikan operasi inti tetap konsisten.

Contoh target unit test:

  • Operasi vektor: dot, cross, normalize, projection.
  • Transform: inverse, compose, transform point vs transform direction.
  • AABB/OBB primitive: overlap, expand, centroid, extents.
  • Primitif collision sederhana: sphere-sphere, ray-plane, point-triangle.

Contoh assertion yang lebih baik daripada sekadar membandingkan seluruh struktur bit-per-bit:

void test_normalize_vec3(void)
{
    double v[3] = {3.0, 4.0, 0.0};
    double out[3];

    normalize_vec3(v, out);

    double expected[3] = {0.6, 0.8, 0.0};
    assert(vec3_nearly_equal(out, expected, 1e-12, 1e-9));
    assert(nearly_equal(length_vec3(out), 1.0, 1e-12, 1e-9));
}

Selain membandingkan nilai hasil, cek juga invariant. Untuk normalisasi, invariant pentingnya adalah panjang hasil mendekati 1.

2) Property-based test untuk invariant matematis

Property-based test sangat efektif untuk geometri 3D karena banyak fungsi punya sifat umum yang bisa diuji di banyak input.

Contoh property yang berguna:

  • length(normalize(v)) ~= 1 untuk v != 0.
  • inverse(transform(T, p)) ~= p dalam toleransi tertentu.
  • Jika dua AABB overlap, hasil overlap harus simetris: overlap(a, b) == overlap(b, a).
  • Normal hasil contact seharusnya terarah konsisten terhadap urutan shape, jika kontrak API menjaminnya.
  • Distance antara dua shape tidak boleh negatif jika API mendefinisikannya sebagai jarak terpisah.

Keuntungan pendekatan ini adalah Anda tidak perlu menulis ribuan expected value manual. Yang diuji adalah hukum sistem, bukan satu contoh angka saja.

void property_overlap_is_symmetric(uint64_t seed)
{
    rng_t rng = rng_init(seed);

    for (int i = 0; i < 10000; ++i) {
        aabb_t a = random_aabb(&rng);
        aabb_t b = random_aabb(&rng);

        bool ab = aabb_overlap(a, b);
        bool ba = aabb_overlap(b, a);

        assert(ab == ba);
    }
}

Gunakan seed reproducible. Jika test gagal, log seed dan input tereduksi sehingga kasus bisa diputar ulang secara lokal.

3) Golden case untuk skenario regresi yang sudah dikenal

Golden case cocok untuk bug yang pernah terjadi: misalnya edge-edge contact yang salah normal, capsule-box sweep yang kehilangan collision, atau ray cast yang gagal di sudut tertentu.

Namun golden case untuk geometri 3D harus dirancang hati-hati:

  • Jangan simpan terlalu banyak digit jika API tidak menjamin determinisme bit-level.
  • Simpan range ekspektasi atau nilai dengan toleransi eksplisit.
  • Validasi atribut penting saja: status hit, jumlah contact, orientasi normal, monotonicity, atau penetrasi dalam rentang.

Golden case paling bernilai jika setiap file/kasus diberi nama berdasarkan bug yang dicegah, misalnya:

  • edge_edge_parallel_regression_01
  • box_triangle_grazing_contact_02
  • raycast_near_parallel_face_03

4) Differential test antar implementasi

Differential test membandingkan hasil dua implementasi yang seharusnya setara: misalnya algoritma baru versus versi lama, atau jalur scalar versus SIMD. Ini berguna saat Anda mengoptimasi collision detection namun ingin memastikan perilaku tetap konsisten.

Yang dibandingkan tidak harus identik bit-per-bit. Anda bisa mendefinisikan kontrak seperti:

  • Keduanya sama-sama menyatakan hit atau no-hit.
  • Jika hit, selisih posisi kontak di bawah toleransi.
  • Normal tidak berlawanan arah secara signifikan.
  • Fraction time-of-impact berada dalam deviasi yang diterima.
void differential_raycast_case(ray_t ray, mesh_t mesh)
{
    hit_t old_hit = raycast_reference(ray, mesh);
    hit_t new_hit = raycast_optimized(ray, mesh);

    assert(old_hit.hit == new_hit.hit);

    if (old_hit.hit) {
        assert(nearly_equal(old_hit.fraction, new_hit.fraction, 1e-9, 1e-7));
        assert(vec3_nearly_equal(old_hit.position, new_hit.position, 1e-8, 1e-6));
        assert(dot_vec3(old_hit.normal, new_hit.normal) > 0.999);
    }
}

Trade-off-nya: bila implementasi referensi juga punya bug, differential test bisa memberi rasa aman palsu. Karena itu ia harus melengkapi, bukan menggantikan, unit test dan property-based test.

5) Fuzzing untuk menemukan input “tajam”

Fuzzing berguna untuk menemukan crash, NaN propagation, infinite loop, assertion internal, atau kasus numerik ekstrem yang lolos dari test biasa. Pada geometri 3D, fuzzing sangat efektif untuk:

  • Mesh dengan degenerasi: triangle sangat tipis, vertex duplikat, face nol area.
  • Transform ekstrem: skala sangat kecil/besar, rotasi mendekati singular.
  • Sweep/cast dengan kecepatan sangat tinggi atau hampir nol.
  • Input yang memicu cabang batas: kontak tipis, sejajar, coplanar.

Fuzzing tidak harus selalu memverifikasi output eksak. Untuk tahap awal, cukup cek sifat keselamatan:

  • Tidak crash.
  • Tidak menghasilkan NaN/Inf pada field yang seharusnya finite.
  • Tidak looping melebihi batas iterasi.
  • Hasil tetap dalam domain valid.

Desain fixture test yang stabil

Fixture yang buruk membuat test sulit diinterpretasikan. Pada geometri 3D, fixture stabil biasanya lebih penting daripada menambah jumlah test.

Pilih koordinat yang mudah dipahami

Gunakan angka yang punya makna geometris jelas, misalnya titik di sumbu, ukuran kubus sederhana, atau rotasi 90 derajat. Ini membantu saat debugging dan mengurangi akumulasi error yang tidak perlu.

Contoh yang lebih stabil:

  • Box di pusat (0,0,0) dengan half-extents (1,2,3).
  • Ray dari (0,0,-10) ke arah (0,0,1).
  • Triangle pada bidang z=0 untuk test dasar barycentric atau ray hit.

Uji kasus batas secara eksplisit, jangan tercampur

Pisahkan kategori berikut:

  • Kasus umum: overlap jelas, hit jelas.
  • Kasus batas: menyentuh tepat di face, edge, atau vertex.
  • Kasus degenerat: shape nol volume, arah nol, triangle kolaps.

Jika semua digabung dalam satu test random besar, Anda tidak tahu apakah kegagalan berasal dari logika umum atau domain batas yang memang butuh kontrak khusus.

Simpan seed dan serialisasi kasus gagal

Untuk property-based test dan fuzzing, setiap kegagalan harus menyimpan:

  • Seed random.
  • Input lengkap atau representasi serialnya.
  • Compiler, arsitektur, dan mode build jika relevan.
if (!test_passed) {
    printf("FAIL seed=%llu\n", (unsigned long long)seed);
    dump_case_to_file("failed_case.json", &input_case);
    abort();
}

Ini terdengar sederhana, tetapi sangat menentukan kecepatan investigasi flaky test.

Perbedaan platform dan compiler: apa yang realistis diuji?

Pada library C, Anda perlu mengasumsikan bahwa hasil floating point bisa sedikit berbeda antar lingkungan. Karena itu, target testing harus dibagi antara yang wajib identik dan yang cukup ekuivalen secara numerik.

Yang layak dibuat deterministik ketat

  • Parsing fixture.
  • Topologi data structure.
  • Status boolean untuk kasus yang jauh dari ambang batas.
  • Jumlah output bila kontrak API menjaminkannya.

Yang sebaiknya pakai toleransi atau invariant

  • Posisi kontak exact.
  • Normal hasil iteratif.
  • Depth penetrasi.
  • Time-of-impact atau fraction hit.
  • Urutan contact point jika algoritma tidak menjamin ordering stabil.

Jika target Anda mencakup beberapa compiler, jalankan test pada lebih dari satu kombinasi, misalnya:

  • Debug dan release.
  • Minimal dua compiler utama.
  • Minimal dua arsitektur jika tersedia di CI.

Tujuannya bukan mencari identitas bit, melainkan memastikan toleransi dan invariant yang Anda pilih benar-benar portabel.

Waspadai optimisasi yang mengubah kontrak numerik

Optimisasi seperti SIMD, perubahan urutan reduksi, atau pengurangan jumlah iterasi solver bisa mempercepat code tetapi juga menggeser hasil di sekitar ambang collision. Saat mengadopsinya, differential test dan golden case regresi menjadi sangat penting.

Regression prevention: dari bug report ke test permanen

Setiap bug geometri yang sudah ditemukan seharusnya menghasilkan test permanen. Jika tidak, bug serupa hampir pasti kembali muncul saat refactor atau optimisasi.

Template respons terhadap bug numerik

  1. Reproduksi dengan fixture minimal.
  2. Tentukan apakah bug berasal dari algoritma, epsilon, atau kontrak API yang tidak jelas.
  3. Tulis satu golden case spesifik untuk bug itu.
  4. Tambahkan satu property test jika bug mewakili pola umum.
  5. Jika muncul dari random/fuzzing, simpan seed asli.

Contoh: jika collision capsule-box gagal saat arah sweep hampir sejajar dengan face, jangan hanya menambah satu fixture. Tambahkan juga property yang memeriksa stabilitas hasil pada variasi kecil sudut dan translasi di sekitar kasus tersebut.

Bedakan bug fungsional dan noise numerik

Tidak semua perbedaan output adalah regresi. Pertanyaan yang perlu dijawab:

  • Apakah status hit/no-hit berubah?
  • Apakah manifold menjadi tidak valid?
  • Apakah penetrasi berubah tanda?
  • Apakah hasil baru memicu jitter atau tunneling di integrasi lebih tinggi?

Jika hanya ada pergeseran kecil yang masih dalam kontrak toleransi, memperketat golden output justru bisa menciptakan flaky test baru.

Workflow CI yang sehat: fast checks vs heavy verification

Suite test geometri 3D sebaiknya tidak diperlakukan sebagai satu blok. Pisahkan jalur cepat untuk umpan balik developer dan jalur berat untuk verifikasi mendalam.

Fast checks pada setiap commit atau pull request

  • Unit test deterministik.
  • Subset property-based test dengan iterasi terbatas.
  • Golden case regresi prioritas tinggi.
  • Build matrix minimum: debug dan release, atau dua compiler jika masih terjangkau.

Targetnya adalah hasil cepat dan sinyal yang jelas. Jika jalur ini terlalu lambat, developer cenderung menunda validasi atau melewatkan investigasi test gagal.

Heavy verification terjadwal atau sebelum rilis

  • Property-based test dengan iterasi besar.
  • Differential test skala luas.
  • Fuzzing beberapa menit atau jam.
  • Cross-platform matrix yang lebih lengkap.

Heavy verification cocok dijalankan secara terjadwal, pada branch integrasi, atau sebagai gate menjelang release candidate.

Contoh pembagian pipeline

# Pseudocode CI
stages:
  - build
  - test-fast
  - test-heavy

build:
  - compile debug
  - compile release

test-fast:
  - run unit tests
  - run golden regression tests
  - run property tests --iterations=1000 --seed=fixed

test-heavy:
  - run property tests --iterations=100000 --seed=from-ci-job
  - run differential tests
  - run fuzzing --time-budget=20m
  - collect failed seeds/artifacts

Dua aturan praktis untuk CI:

  • Seed tetap untuk jalur cepat agar reproduksinya konsisten.
  • Seed bervariasi namun tercatat untuk jalur berat agar cakupan input bertambah dari waktu ke waktu.

Kesalahan umum yang sebaiknya dihindari

  • Mengganti semua assertion menjadi epsilon besar. Ini menyembunyikan bug, bukan menyelesaikannya.
  • Menggunakan random tanpa pencatatan seed. Hasilnya: bug tidak bisa diulang.
  • Mencampur test crash-safety dengan test exactness. Keduanya punya tujuan berbeda.
  • Menganggap platform lokal mewakili semua target. Geometri berbasis C perlu diuji lintas compiler/arsitektur bila produk memang mendukungnya.
  • Tidak menguji degenerasi. Banyak bug collision muncul justru pada input tak ideal.
  • Memaksa bitwise equality pada hasil yang tidak menjaminnya. Ini sumber flaky klasik.

Checklist praktis untuk memulai

  • Tulis helper assertion numerik untuk scalar, vector, dan matrix.
  • Definisikan toleransi per domain, bukan satu epsilon global.
  • Bangun lapisan unit test deterministik untuk operasi inti.
  • Tambahkan property-based test berbasis invariant matematis.
  • Simpan setiap bug nyata sebagai golden case regresi.
  • Pakai differential test saat refactor atau optimisasi algoritma.
  • Jalankan fuzzing untuk crash, NaN, dan input degenerat.
  • Pastikan seed random reproducible dan selalu tercatat.
  • Pisahkan fast checks dan heavy verification di CI.

Penutup

Dalam konteks library seperti Box3D, tantangan terbesar testing bukan sekadar menulis banyak kasus, tetapi membangun strategi test geometri 3D yang menghormati sifat floating point dan kompleksitas collision detection. Test yang baik tidak memaksa determinisme palsu; ia memverifikasi invariant, kontrak perilaku, dan regresi yang benar-benar penting.

Jika Anda mengelola math engine atau library geometri berbasis C, kombinasi unit test deterministik, property-based test, golden case, differential test, dan fuzzing akan memberi perlindungan yang jauh lebih kuat daripada kumpulan assertion float sederhana. Hasil akhirnya bukan hanya suite yang lebih stabil, tetapi juga proses debugging dan release yang jauh lebih dapat dipercaya.