Strategi test untuk POC Rust yang masih cepat berubah berbeda dari strategi untuk sistem yang sudah matang. Tujuannya bukan mengejar cakupan tinggi atau menstabilkan semua perilaku, melainkan menjaga agar eksperimen tetap cepat sambil melindungi bagian yang mulai menjadi kontrak nyata: parsing, aturan domain kecil, format output penting, dan perilaku error yang memang ingin dipertahankan.

Dalam fase proof-of-concept, sumber masalah terbesar biasanya bukan kurangnya test, tetapi test yang salah sasaran: terlalu terikat ke implementasi sementara, tergantung waktu atau jaringan, memakai fixture besar, atau memverifikasi detail yang belum diputuskan. Hasilnya adalah flaky test, CI berisik, dan tim mulai mengabaikan kegagalan test. Pendekatan yang lebih sehat adalah memisahkan test kontrak dari test eksperimen, lalu memprioritaskan test yang deterministik dan murah dirawat.

Apa yang Perlu Dijaga dalam POC Rust

POC yang baik tidak harus memiliki test lengkap. Ia butuh test pada titik yang paling mudah rusak tetapi juga paling murah distabilkan. Dalam konteks Rust, area yang umumnya layak dites lebih awal adalah:

  • Fungsi murni: transformasi data, validasi, pemetaan state, perhitungan skor atau prioritas.
  • Parsing dan serialisasi: terutama jika data masuk dari file, CLI, atau API.
  • Kontrak error: misalnya input tidak valid harus menghasilkan error yang jelas, bukan panic.
  • Alur kecil yang deterministik: satu fungsi layanan dengan dependency palsu.

Sebaliknya, banyak hal belum perlu diuji ketat pada tahap ini:

  • Detail struktur internal yang kemungkinan besar akan diubah.
  • Urutan log, pesan debug, atau teks error yang belum menjadi kontrak publik.
  • Integrasi jaringan nyata ke layanan eksternal.
  • Benchmark yang diperlakukan sebagai test lulus/gagal.
  • Snapshot besar untuk output yang masih sering berubah formatnya.

Aturan praktis: jika sebuah perilaku masih sering Anda ubah sambil mencari desain yang tepat, jangan buru-buru menguncinya dengan test yang sangat spesifik.

Bedakan Test Kontrak vs Test Eksperimen

Test kontrak

Test kontrak memverifikasi perilaku yang sudah ingin Anda jaga meskipun implementasinya boleh berubah. Ini cocok untuk POC yang mulai punya bentuk API, input-output yang dipakai modul lain, atau asumsi dasar yang kalau pecah akan mengganggu eksperimen berikutnya.

Ciri test kontrak:

  • Memeriksa hasil yang penting bagi pemanggil, bukan detail internal.
  • Tetap relevan setelah refactor.
  • Biasanya cepat, kecil, dan deterministik.

Test eksperimen

Test eksperimen membantu memahami perilaku saat mengeksplorasi ide. Ia boleh sementara, boleh dibuang, dan tidak harus masuk CI utama. Bentuknya bisa berupa test lokal untuk memverifikasi hipotesis, reproduksi bug, atau dokumentasi perilaku sementara.

Ciri test eksperimen:

  • Lebih dekat ke implementasi saat ini.
  • Sering berubah atau dihapus setelah desain jelas.
  • Tidak layak menjadi sinyal kualitas jangka panjang.

Pemisahan ini penting agar CI hanya menjalankan test yang benar-benar mewakili kontrak minimum. Test eksperimen tetap berguna, tetapi jangan sampai ia membuat semua orang kehilangan kepercayaan pada hasil pipeline.

Prioritas Utama: Unit Test Deterministik

Untuk POC Rust, unit test deterministik memberi rasio manfaat-biaya terbaik. Rust sangat cocok untuk ini karena fungsi kecil, enum error yang eksplisit, dan pemisahan dependency lewat trait relatif mudah dibuat sejak awal.

Struktur modul yang memudahkan test

Usahakan logika inti tidak langsung bercampur dengan I/O. Pisahkan bagian yang murni dari bagian yang menyentuh file system, waktu, jaringan, atau generator acak.

src/
├── lib.rs
├── domain/
│   ├── mod.rs
│   └── scorer.rs
├── app/
│   ├── mod.rs
│   └── service.rs
└── infra/
    ├── mod.rs
    └── clock.rs

tests/
└── contract_cli.rs

Pola sederhananya:

  • domain/: logika murni yang paling mudah diuji.
  • app/: orkestrasi use case, boleh memakai dependency abstrak.
  • infra/: implementasi nyata untuk waktu, file, HTTP, dan sejenisnya.

Contoh unit test yang stabil

#[derive(Debug, PartialEq)]
pub enum ScoreError {
    EmptyInput,
}

pub fn compute_score(items: &[i32]) -> Result<i32, ScoreError> {
    if items.is_empty() {
        return Err(ScoreError::EmptyInput);
    }

    Ok(items.iter().sum())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn returns_sum_for_non_empty_input() {
        let score = compute_score(&[2, 3, 5]).unwrap();
        assert_eq!(score, 10);
    }

    #[test]
    fn rejects_empty_input() {
        let err = compute_score(&[]).unwrap_err();
        assert_eq!(err, ScoreError::EmptyInput);
    }
}

Test di atas kuat karena:

  • Tidak menyentuh waktu, file, jaringan, atau thread.
  • Memeriksa hasil yang memang bermakna.
  • Tidak peduli bagaimana fungsi diimplementasikan selama kontraknya sama.

Cara Mencegah Flaky Test Sejak Awal

1. Gunakan seed tetap untuk perilaku acak

Jika POC memakai randomisasi, buat jalur test yang menerima seed eksplisit. Jangan biarkan test bergantung pada generator acak global atau seed default yang tidak Anda kontrol.

pub fn pick_candidate(input: &[u32], seed: u64) -> Option<u32> {
    use rand::{rngs::StdRng, seq::SliceRandom, SeedableRng};

    let mut rng = StdRng::seed_from_u64(seed);
    input.choose(&mut rng).copied()
}

#[test]
fn pick_candidate_is_repeatable_with_fixed_seed() {
    let items = [10, 20, 30, 40];
    let first = pick_candidate(&items, 12345);
    let second = pick_candidate(&items, 12345);
    assert_eq!(first, second);
}

Kalau perilaku acaknya belum menjadi kontrak, cukup uji sifat dasarnya, misalnya hasil harus berasal dari himpunan input, bukan elemen spesifik untuk semua kasus.

2. Abstraksikan waktu dan dependency eksternal

Test sering menjadi flaky karena bergantung pada Instant::now(), SystemTime::now(), file system sementara yang tidak dibersihkan, atau HTTP nyata. Untuk POC, tidak perlu solusi mocking yang rumit. Trait kecil dan fake implementation biasanya cukup.

pub trait Clock {
    fn now_secs(&self) -> u64;
}

pub struct FixedClock {
    pub now: u64,
}

impl Clock for FixedClock {
    fn now_secs(&self) -> u64 {
        self.now
    }
}

pub fn is_expired(created_at: u64, ttl_secs: u64, clock: &dyn Clock) -> bool {
    clock.now_secs().saturating_sub(created_at) > ttl_secs
}

#[test]
fn marks_item_expired_after_ttl() {
    let clock = FixedClock { now: 120 };
    assert!(is_expired(10, 100, &clock));
}

Pola ini lebih murah dirawat daripada memaksa test menunggu waktu nyata atau mengandalkan sleep.

3. Hindari sleep tanpa alasan

sleep dalam test hampir selalu sinyal masalah. Ia membuat test lambat dan sensitif terhadap beban mesin CI. Jika Anda sedang menguji retry, scheduler, atau timeout, usahakan mekanisme waktu bisa diinjeksikan atau timeout bisa dibuat pendek dan eksplisit.

4. Pakai timeout yang jelas pada test async atau proses eksternal

Untuk test yang memang harus menyentuh async runtime atau subprocess, pasang batas waktu yang jelas agar kegagalan berhenti cepat. Tujuannya bukan menambah ketat, tetapi mencegah pipeline menggantung tanpa sinyal yang informatif.

#[tokio::test]
async fn finishes_within_reasonable_timeout() {
    let result = tokio::time::timeout(
        std::time::Duration::from_secs(2),
        async {
            // panggil fungsi async yang diuji
            42
        },
    ).await;

    assert!(result.is_ok(), "operasi melebihi timeout");
}

Nilai timeout tidak perlu terlalu agresif. Yang penting konsisten dan cukup longgar untuk lingkungan CI normal.

5. Gunakan fixture kecil

Fixture besar membuat test sulit dibaca, lambat diubah, dan sering menutupi apa yang sebenarnya diverifikasi. Untuk POC, lebih baik beberapa fixture mini yang masing-masing mewakili satu kasus penting daripada satu file besar yang dipakai semua test.

Contoh struktur fixture yang lebih sehat:

tests/
├── fixtures/
│   ├── valid_minimal.json
│   ├── invalid_missing_field.json
│   └── edge_empty_items.json
└── contract_parser.rs

Keuntungan fixture kecil:

  • Gagalnya test lebih mudah dipahami.
  • Perubahan format tidak memicu ledakan diff di banyak tempat.
  • Setiap fixture punya tujuan tunggal.

Golden Test: Pakai Seperlunya

Golden test berguna saat Anda ingin mengunci output teks, JSON, atau format serialisasi tertentu. Namun pada POC yang masih berubah cepat, golden test mudah menjadi rapuh jika dipasang terlalu dini atau pada output yang belum stabil.

Kapan golden test layak dipakai

  • Format output mulai dipakai oleh alat lain atau modul lain.
  • Perubahan kecil pada output berisiko merusak integrasi.
  • Output cukup kecil sehingga diff mudah direview.

Kapan sebaiknya ditunda

  • Nama field, urutan, atau representasi output masih sering berubah karena eksplorasi.
  • Output sangat besar dan review diff tidak realistis.
  • Data mengandung timestamp, ID acak, path sementara, atau nilai non-deterministik lain.

Jika perlu golden test, normalisasi dulu bagian yang tidak stabil. Misalnya ganti timestamp dengan nilai tetap sebelum dibandingkan.

#[test]
fn renders_stable_output() {
    let actual = render_report(sample_input());
    let normalized = actual.replace("generated_at=1700000000", "generated_at=<fixed>");
    let expected = include_str!("fixtures/report.golden");
    assert_eq!(normalized, expected);
}

Kalau Anda sering memperbarui golden file tanpa benar-benar meninjau perubahan maknanya, itu tanda golden test belum tepat waktunya.

Contoh Test Kontrak dengan Fake Dependency

Salah satu pola paling berguna untuk POC Rust adalah menguji alur aplikasi kecil dengan dependency palsu. Ini memberi perlindungan regresi lebih baik daripada hanya menguji fungsi murni, tetapi tetap jauh lebih stabil daripada integrasi penuh.

pub trait CandidateStore {
    fn all(&self) -> Vec<String>;
}

pub struct InMemoryStore {
    items: Vec<String>,
}

impl CandidateStore for InMemoryStore {
    fn all(&self) -> Vec<String> {
        self.items.clone()
    }
}

pub fn first_non_empty(store: &dyn CandidateStore) -> Option<String> {
    store.all().into_iter().find(|s| !s.trim().is_empty())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn returns_first_non_empty_candidate() {
        let store = InMemoryStore {
            items: vec![" ".into(), "alpha".into(), "beta".into()],
        };

        let result = first_non_empty(&store);
        assert_eq!(result, Some("alpha".into()));
    }
}

Pola ini cocok untuk kode yang nantinya mungkin membaca dari file, database, atau API, tetapi pada tahap POC Anda belum ingin biaya dan ketidakstabilan integrasi itu masuk ke test utama.

Workflow Verifikasi di CI yang Ringan

POC tetap perlu CI, tetapi pipeline-nya harus singkat dan dapat dipercaya. Targetnya adalah memberi sinyal cepat: apakah perubahan merusak kontrak minimum atau tidak.

Lapisan verifikasi yang disarankan

  1. Format dan lint: jalankan formatter dan linter agar perubahan dasar konsisten.
  2. Unit test kontrak: default wajib lulus, cepat, dan deterministik.
  3. Test integrasi terbatas: hanya jika ada jalur penting yang cukup stabil.
  4. Test eksperimen: opsional, manual, atau job terpisah yang tidak memblokir iterasi awal.

Contoh perintah yang umum dipakai:

cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test --lib --tests

Jika ada test yang lebih lambat atau masih eksploratif, pisahkan dengan penamaan atau atribut yang jelas, lalu jangan jadikan bagian dari jalur wajib merge sampai nilainya benar-benar terbukti.

Prinsip CI untuk POC

  • Fail fast: lebih baik sedikit test yang terpercaya daripada banyak test yang sering salah bunyi.
  • Output jelas: nama test harus menjelaskan kontrak yang gagal.
  • Tidak ada ketergantungan lingkungan tersembunyi: path, locale, timezone, dan network access sebaiknya eksplisit.
  • Waktu eksekusi pendek: jika pipeline terlalu lama, tim akan terdorong melewati test.

Tanda Test Sudah Terlalu Rapuh

Beberapa gejala berikut menunjukkan strategi test POC Anda perlu disederhanakan:

  • Test sering gagal di CI tetapi sulit direproduksi lokal.
  • Perubahan refactor kecil memaksa update banyak assertion yang tidak terkait perilaku bisnis.
  • Test memverifikasi string lengkap padahal yang penting hanya beberapa field inti.
  • Fixture sangat besar dan dipakai lintas banyak kasus berbeda.
  • Orang terbiasa me-retry pipeline tanpa menyelidiki akar masalah.
  • Menambah fitur kecil berarti memperbarui banyak golden file yang isinya tidak benar-benar ditinjau.

Saat gejala ini muncul, biasanya solusi terbaik bukan menambah retry atau memperbesar timeout, tetapi mengurangi ketergantungan pada waktu nyata, random, I/O, dan detail implementasi.

Apa yang Belum Perlu Dites pada Tahap POC

Menahan diri untuk tidak menguji hal tertentu juga bagian dari strategi. Pada tahap awal, beberapa area ini sering belum layak menjadi target test formal:

  • Optimasi performa mikro sebelum bottleneck benar-benar diketahui.
  • Kompatibilitas penuh lintas platform jika POC baru dipakai di satu lingkungan.
  • Semua cabang error internal yang masih mungkin disederhanakan atau dihapus.
  • Format output debug yang hanya dipakai saat pengembangan.
  • Perilaku konkurensi kompleks sebelum model eksekusi final mulai jelas, kecuali memang itu inti eksperimennya.

Fokuskan energi pada test yang membantu Anda bergerak maju, bukan yang membuat setiap perubahan terasa mahal.

Checklist Saat POC Rust Mulai Menuju Produksi

Begitu POC mulai dipakai lebih serius, strategi test harus dinaikkan bertahap. Gunakan checklist berikut sebagai sinyal transisi:

  • Apakah API publik atau format output sudah cukup stabil untuk dijadikan kontrak formal?
  • Apakah dependency eksternal utama sudah punya test integrasi yang terisolasi?
  • Apakah semua sumber non-determinisme utama sudah bisa dikendalikan di test?
  • Apakah timeout, retry, dan penanganan error punya verifikasi yang jelas?
  • Apakah fixture dan golden file masih kecil, terbaca, dan benar-benar direview?
  • Apakah ada test yang seharusnya dipromosikan dari eksperimen menjadi kontrak?
  • Apakah CI sudah memisahkan jalur cepat wajib dan jalur lebih berat berkala?
  • Apakah bug yang pernah lolos ke branch utama sudah direproduksi sebagai test stabil?

Pada fase ini Anda biasanya mulai menambahkan:

  • Lebih banyak integration test untuk boundary yang sudah stabil.
  • Property-oriented test untuk fungsi murni yang penting.
  • Validasi kompatibilitas format input-output.
  • Pengujian error path yang sebelumnya masih terlalu cair.

Penutup

Strategi test untuk POC Rust yang masih cepat berubah bukan soal mengetes sebanyak mungkin, melainkan memilih test yang memberi sinyal paling berguna dengan biaya perawatan terendah. Pisahkan test kontrak dari test eksperimen, utamakan unit test yang deterministik, pakai fixture kecil, seed tetap, fake dependency, dan timeout yang jelas. Golden test tetap berguna, tetapi hanya ketika output mulai benar-benar layak dianggap kontrak.

Jika dilakukan dengan disiplin, Anda bisa mencegah flaky test dan regresi tanpa memperlambat iterasi awal. Saat POC mulai mengeras menuju produksi, barulah perluasan cakupan test dilakukan secara bertahap dan berdasarkan kontrak yang memang sudah terbentuk.