Macro #[sqlx::test] mempermudah pengujian integrasi database di Rust dengan menyediakan database terisolasi dan migrasi otomatis untuk setiap test function. Namun, ketika test suite berkembang melampaui beberapa lusin fungsi pengujian, waktu incremental rebuild melonjak drastis. Perubahan kecil pada kode aplikasi dapat memicu siklus kompilasi dan linking hingga puluhan detik atau beberapa menit.

Masalah ini bukan disebabkan oleh performa runtime database, melainkan beban kompilasi yang dihasilkan oleh ekspansi macro di compiler Rust.

Akar Masalah: Macro Expansion, LLVM IR Bloat, dan Linker Churn

Saat compiler memproses atribut #[sqlx::test], macro tersebut mengekspansi sejumlah besar kode boilerplate ke dalam setiap fungsi pengujian individual:

  • Inisialisasi runtime asynchronous (biasanya Tokio).
  • Pembuatan koneksi pool khusus ke server database.
  • Penyusunan dan eksekusi seluruh migrasi skema melalui Migrator::run.
  • Logika pembuatan nama database unik dan pembersihan (teardown).

Jika terdapat 100 test case yang dianotasi dengan #[sqlx::test], compiler harus melakukan kompilasi, type checking, dan monomorphization terhadap kode migrasi dan setup pool sebanyak 100 kali. Hal ini melipatgandakan ukuran LLVM Intermediate Representation (IR), memperbesar objek file (.o / .rlib), dan membebani fase linking.

Beban bertambah parah jika file pengujian diletakkan secara terpisah di dalam direktori tests/, karena Cargo memperlakukan setiap file sebagai crate executable integration test independen.

Strategi 1: Konsolidasi ke Single Test Binary

Secara default, setiap file tests/*.rs dikompilasi menjadi binary terpisah. Jika ada 10 file pengujian, linker harus mengikat dependensi berat (seperti Tokio, SQLx, dan TLS) sebanyak 10 kali secara terpisah.

Konsolidasikan seluruh integration test ke dalam satu binary menggunakan struktur modular:

tests/
├── integration.rs
└── integration/
    ├── auth_test.rs
    ├── common.rs
    └── user_test.rs

Deklarasikan test target secara eksplisit pada Cargo.toml:

[[test]]
name = "integration"
path = "tests/integration.rs"

Panggil modul-modul pengujian dari tests/integration.rs:

mod integration {
    mod auth_test;
    mod common;
    mod user_test;
}

Langkah ini memastikan Cargo hanya menghasilkan satu test binary, memangkas waktu linking secara signifikan saat kompilasi paralel maupun incremental.

Strategi 2: Mengganti #[sqlx::test] dengan Shared Test Harness

Untuk menghilangkan duplikasi kode migrasi di level IR, delegasikan setup database ke fixture bersama yang diinisialisasi sekali per execution run, lalu gunakan transaksi terisolasi yang di-rollback setelah test selesai.

use sqlx::{PgPool, Postgres, Transaction};
use std::sync::OnceLock;

static POOL: OnceLock<PgPool> = OnceLock::new();

pub async fn get_test_pool() -> &'static PgPool {
    if let Some(pool) = POOL.get() {
        return pool;
    }

    let db_url = std::env::var("DATABASE_URL")
        .unwrap_or_else(|_| "postgres://postgres:password@localhost:5432/test_db".to_string());
    let pool = PgPool::connect(&db_url).await.expect("Failed to connect DB");

    // Eksekusi migrasi skema satu kali untuk seluruh suite
    sqlx::migrate!("./migrations")
        .run(&pool)
        .await
        .expect("Migration failed");

    let _ = POOL.set(pool);
    POOL.get().unwrap()
}

// ponytail: transaction rollback harness; upgrade to unique database isolation if concurrent tests require full schema mutations.
pub async fn run_in_txn<F, Fut>(test_fn: F)
where
    F: FnOnce(Transaction<'static, Postgres>) -> Fut,
    Fut: std::future::Future<Output = ()>,
{
    let pool = get_test_pool().await;
    let txn = pool.begin().await.expect("Failed to begin transaction");
    test_fn(txn).await;
    // Transaksi didrop otomatis di sini tanpa commit, data rollback
}

Refaktor pengujian individual dari macro #[sqlx::test] ke pengujian Tokio standar:

#[tokio::test]
async fn test_create_user() {
    crate::common::run_in_txn(|mut txn| async move {
        let result = sqlx::query!(
            "INSERT INTO users (name) VALUES ($1) RETURNING id",
            "Alice"
        )
        .fetch_one(&mut *txn)
        .await;

        assert!(result.is_ok());
    })
    .await;
}

Kode migrasi kini hanya dikompilasi sekali di satu modul, bukan diduplikasi di ratusan fungsi pengujian.

Strategi 3: Aktifkan SQLx Offline Mode

Macro query compile-time SQLx (query!, query_as!) secara default menghubungi database aktif melalui TCP untuk memeriksa validitas tipe data dan skema. Ini menambah overhead I/O saat analisis kode.

Gunakan SQLx offline mode dengan menyimpan metadata query ke file sqlx-data.json:

cargo install sqlx-cli --no-default-features --features postgres
cargo sqlx prepare -- --all-targets

Aktifkan offline mode pada environment lokal dan CI:

export SQLX_OFFLINE=true
cargo test --test integration

Compiler akan membaca validasi query dari file JSON lokal tanpa menyentuh soket database saat pengecekan skema, mengurangi variasi latensi I/O saat kompilasi.

Strategi 4: Gunakan Linker Cepat (mold / lld)

Linker default (GNU ld) bekerja secara single-threaded untuk operasi-operasi besar dan tidak efisien menangani artifact debug Rust yang besar. Gunakan mold (Linux) atau lld (macOS/Windows).

Tambahkan konfigurasi pada .cargo/config.toml:

[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]

[target.x86_64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

Evaluasi dan Verifikasi Hasil

Gunakan fitur bawaan Cargo untuk mengukur bottleneck sebelum dan sesudah refaktor:

# Ukur waktu build per crate dan test target
cargo clean
cargo build --tests --timings

# Profiling incremental rebuild tanpa eksekusi test
cargo test --test integration --no-run

Dengan mengonsolidasi binary test, mengganti ekspansi macro berulang dengan shared pool fixture, dan mengganti linker, waktu linking dan kompilasi LLVM IR untuk integration test dapat dipangkas secara signifikan.