Flaky test pada pengujian integrasi dan unit umumnya bersumber dari dua hal: mutasi state global (monkey patching) dan ketergantungan pada pewaktuan riil (real-time clocks seperti sleep). Pada ekosistem Gleam, monkey patching tidak didukung oleh desain bahasa yang immutabel dan bertipe statis. Memaksa pengujian I/O dengan mock berbasis proses Erlang sering kali menimbulkan race condition dan overhead sinkronisasi.

Pendekatan yang lebih deterministik adalah memisahkan deskripsi efek samping dari eksekutornya menggunakan efek kontinuasi (continuation-based effects). Domain logic tidak langsung mengeksekusi operasi I/O, melainkan menghasilkan struktur data yang menangguhkan komputasi beserta fungsi kelanjutan (continuation closure). Arsitektur ini memungkinkan simulasi kegagalan jaringan dan verifikasi mekanisme retry berjalan dalam hitungan milidetik secara deterministik tanpa I/O riil.

Kerapuhan Mock Konvensional dalam Pengujian Asinkron

Pengujian konvensional pada bahasa imperatif lazimnya menggunakan library mocking untuk membajak fungsi global atau mengandalkan mutasi runtime. Pola ini rapuh karena beberapa alasan teknis:

  • Coupling pada Implementasi Internal: Mock memverifikasi bagaimana fungsi dipanggil, bukan apa hasil komputasi yang diharapkan. Refactoring minor sering merusak suite pengujian meski kontrak sistem tidak berubah.
  • Timing Flakiness: Logika retry atau timeout umumnya diuji dengan menyisipkan penundaan riil (misalnya process.sleep). Hal ini memperlambat pipeline CI dan menyebabkan kegagalan acak akibat variasi beban CPU runner pengujian.
  • Ketidakcocokan dengan Karakter Immutabel Gleam: Gleam berjalan di atas BEAM dan JavaScript tanpa variabel global yang dapat dimutasi secara bebas. Menggunakan aktor atau proses terpisah sebagai tiruan dependensi eksternal membutuhkan koordinasi pesan eksplisit yang rentan terhadap masalah timeout.

Desain Efek Kontinuasi Menggunakan Algebraic Data Types

Solusi arsitektur untuk masalah di atas adalah memodelkan setiap interaksi eksternal sebagai varian dari Algebraic Data Type (ADT). Setiap varian yang memerlukan I/O membawa parameter masukan beserta sebuah closure kelanjutan: fn(Hasil) -> Effect(a).

// Definisi tipe efek menggunakan continuation
pub type Effect(a) {
  HttpGet(url: String, resume: fn(Result(String, String)) -> Effect(a))
  Sleep(ms: Int, resume: fn(Nil) -> Effect(a))
  Pure(value: a)
}

Fungsi bisnis murni menyusun struktur ini secara deklaratif. Ketika operasi I/O diperlukan, eksekusi dihentikan sementara sampai runner luar mengevaluasi efek dan meneruskan hasilnya kembali ke dalam closure resume.

Implementasi Logika Bisnis: HTTP Fetch dengan Retry

Berikut adalah implementasi alur penarikan data HTTP dengan toleransi kegagalan. Fungsi ini tidak memanggil HTTP client aktual ataupun modul timer sistem.

pub fn fetch_with_retry(
  url: String,
  attempts_left: Int,
  delay_ms: Int,
) -> Effect(Result(String, String)) {
  HttpGet(url, fn(result) {
    case result {
      Ok(body) -> Pure(Ok(body))
      Error(err) if attempts_left > 1 ->
        Sleep(delay_ms, fn(_) {
          fetch_with_retry(url, attempts_left - 1, delay_ms)
        })
      Error(err) -> Pure(Error(err))
    }
  })
}

fetch_with_retry → skipped: [exponential backoff, non-retryable error filtering], add when [status code classification needed].

Runner Produksi vs Runner Pengujian Deterministik

Pemisahan deskripsi dari eksekusi memungkinkan penggunaan dua runner berbeda: runner produksi yang menjalankan efek secara riil dan runner pengujian yang bekerja secara sinkron tanpa dependensi eksternal.

1. Runner Produksi (Real I/O)

pub fn run_live(
  effect: Effect(a),
  http_client: fn(String) -> Result(String, String),
  sleep_fn: fn(Int) -> Nil,
) -> a {
  case effect {
    Pure(val) -> val
    HttpGet(url, resume) -> {
      let res = http_client(url)
      run_live(resume(res), http_client, sleep_fn)
    }
    Sleep(ms, resume) -> {
      sleep_fn(ms)
      run_live(resume(Nil), http_client, sleep_fn)
    }
  }
}

run_live → skipped: [tail-call optimization via loop accumulator], add when [call stack depth exceeds 10,000 steps].

2. Runner Pengujian Deterministik (Virtual Clock & Fixture Injection)

Runner pengujian memegang state simulasi. Alih-alih menunggu milidetik aktual saat menemukan efek Sleep, runner cukup memajukan nilai waktu virtual. Respon HTTP diambil langsung dari daftar skenario (fixture) yang telah disiapkan.

pub type MockState {
  MockState(
    responses: List(Result(String, String)),
    elapsed_time_ms: Int,
    call_count: Int,
  )
}

pub fn run_test(effect: Effect(a), state: MockState) -> #(a, MockState) {
  case effect {
    Pure(val) -> #(val, state)
    HttpGet(_url, resume) -> {
      case state.responses {
        [resp, ..rest] -> {
          let next_state =
            MockState(
              ..state,
              responses: rest,
              call_count: state.call_count + 1,
            )
          run_test(resume(resp), next_state)
        }
        [] -> panic as "run_test: kehabisan respons tiruan"
      }
    }
    Sleep(ms, resume) -> {
      let next_state =
        MockState(..state, elapsed_time_ms: state.elapsed_time_ms + ms)
      run_test(resume(Nil), next_state)
    }
  }
}

Verifikasi Pengujian: Simulasi Kegagalan Jaringan Instan

Pengujian berikut mengeksekusi 3 kali percobaan jaringan dengan total simulasi jeda 2000 ms. Pengujian selesai dalam fraksi milidetik tanpa sleep OS dan menghasilkan assertion yang deterministik.

pub fn retry_exhaustion_test() {
  let program = fetch_with_retry("https://api.internal/data", 3, 1000)
  
  let initial_state =
    MockState(
      responses: [
        Error("ECONNREFUSED"),
        Error("ETIMEDOUT"),
        Error("503 Service Unavailable"),
      ],
      elapsed_time_ms: 0,
      call_count: 0,
    )

  let #(result, final_state) = run_test(program, initial_state)

  // Verifikasi hasil akhir dan efek samping yang tercatat
  let assert Error("503 Service Unavailable") = result
  let assert 3 = final_state.call_count
  let assert 2000 = final_state.elapsed_time_ms
}

Evaluasi Trade-off dan Pertimbangan Arsitektur

Menerapkan pola efek berbasis kontinuasi memerlukan kompromi teknis yang perlu dihitung sebelum diadopsi secara luas:

  • Overhead Alokasi Closure: Setiap langkah rantai komputasi mengalokasikan satu fungsi kontinuasi baru di heap BEAM/JS. Untuk aplikasi dengan throughput I/O masif (jutaan operasi per detik per core), overhead alokasi memori ini lebih tinggi dibandingkan pendekatan direct I/O.
  • Keterbacaan dan Cognitive Load: Gaya penulisan fungsi domain bergeser ke bentuk continuation-passing style (CPS). Tanpa do-notation atau syntactic sugar monadik di Gleam, susunan fungsi yang kompleks memerlukan pemecahan fungsi secara eksplisit agar indentation level tidak bertumpuk terlalu dalam.
  • Kekuatan Verifikasi: Kode pengujian berubah status menjadi operasi murni (pure function). Seluruh state eksekusi dapat diinspeksi, diuji ulang dari titik tertentu, dan dijalankan secara paralel tanpa kekhawatiran bentrokan lingkungan runtime atau port TCP yang terkunci.