Akar Masalah: State Sharing pada Cargo Test

Secara default, cargo test mengeksekusi fungsi-fungsi pengujian secara paralel menggunakan multi-threading. Saat integration test Actix Web berinteraksi langsung dengan database sentral (seperti PostgreSQL atau MySQL), race condition tidak terhindarkan.

Pengujian A yang sedang memodifikasi record dapat terbaca atau terhapus oleh Pengujian B yang berjalan bersamaan. Hasil pengujian menjadi tidak deterministik (flaky test): lulus saat dijalankan dengan cargo test -- --test-threads=1, tetapi gagal berkala pada pipeline CI/CD yang multi-threaded.

Mekanisme Transaksi Rollback via RAII di SQLx

Solusi paling efisien untuk mengatasi state sharing tanpa mengorbankan kecepatan eksekusi paralel adalah Transaction Rollback Pattern.

Prinsip kerjanya memanfaatkan mekanisme RAII (Resource Acquisition Is Initialization) bawaan Rust:

  1. Koneksi database membuka transaksi baru (pool.begin()) sebelum test runner mengeksekusi request HTTP.
  2. Transaksi diinjeksikan ke dalam state aplikasi Actix Web (web::Data).
  3. Seluruh mutasi data yang dipicu endpoint HTTP terjadi di dalam transaksi tersebut.
  4. Test runner memverifikasi response body dan status HTTP.
  5. Instance transaksi keluar dari scope (dropped) tanpa pemanggilan .commit(). Secara otomatis, implementasi Drop pada sqlx::Transaction mengirimkan sinyal ROLLBACK ke server database. Database kembali bersih tanpa menyisakan artefak data.

Implementasi State Aplikasi Berbasis Executor

Actix Web mensyaratkan state di dalam web::Data<T> memiliki trait bound Send + Sync + 'static. Objek sqlx::Transaction membutuhkan penanganan khusus jika dibagikan ke multi-worker Actix Web. Pola paling praktis adalah membungkus transaksi ke dalam Arc<tokio::sync::Mutex<Transaction>> atau membuat abstraction layer.

1. Desain State Database

Definisikan enum atau struct state yang mampu beroperasi dalam mode pool (production) maupun mode transaksi (test):

use sqlx::{PgPool, Postgres, Transaction};
use std::sync::Arc;
use tokio::sync::Mutex;

#[derive(Clone)]
pub enum DbState {
    Pool(PgPool),
    TestTx(Arc<Mutex<Transaction<'static, Postgres>>>),
}

2. Handler Endpoint

Handler mengekstrak state dan menjalankan query menggunakan branch yang sesuai:

use actix_web::{web, HttpResponse, Responder};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct CreateUserPayload {
    pub username: String,
    pub email: String,
}

pub async fn create_user(
    state: web::Data<DbState>,
    payload: web::Json<CreateUserPayload>,
) -> impl Responder {
    let result = match state.get_ref() {
        DbState::Pool(pool) => {
            sqlx::query!(
                "INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id",
                payload.username,
                payload.email
            )
            .fetch_one(pool)
            .await
        }
        DbState::TestTx(tx) => {
            let mut tx_guard = tx.lock().await;
            sqlx::query!(
                "INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id",
                payload.username,
                payload.email
            )
            .fetch_one(&mut **tx_guard)
            .await
        }
    };

    match result {
        Ok(record) => HttpResponse::Created().json(serde_json::json!({ "id": record.id })),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

Setup Integration Test dengan test::init_service

Gunakan actix_web::test untuk menginisialisasi service dan jalankan request secara terisolasi.

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::{test, web, App};
    use sqlx::postgres::PgPoolOptions;

    async fn get_test_pool() -> PgPool {
        let db_url = std::env::var("DATABASE_URL")
            .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/test_db".to_string());
        PgPoolOptions::new()
            .max_connections(5)
            .connect(&db_url)
            .await
            .expect("Gagal koneksi ke DB test")
    }

    #[actix_web::test]
    async fn test_create_user_isolated() {
        let pool = get_test_pool().await;

        // 1. Buka transaksi terisolasi
        let tx = pool.begin().await.expect("Gagal memulai transaksi");
        let tx_state = DbState::TestTx(Arc::new(Mutex::new(tx)));

        // 2. Inisialisasi test service dengan App State terisolasi
        let app = test::init_service(
            App::new()
                .app_data(web::Data::new(tx_state))
                .route("/users", web::post().to(create_user)),
        )
        .await;

        // 3. Eksekusi Request HTTP
        let payload = CreateUserPayload {
            username: "john_doe".to_string(),
            email: "[email protected]".to_string(),
        };

        let req = test::TestRequest::post()
            .uri("/users")
            .set_json(&payload)
            .to_request();

        let resp = test::call_service(&app, req).await;

        // 4. Verifikasi Response HTTP
        assert!(resp.status().is_success());

        let body: serde_json::Value = test::read_body_json(resp).await;
        assert!(body.get("id").is_some());

        // 5. Teardown:
        // tx_state keluar dari scope di sini.
        // Implementasi Drop pada sqlx::Transaction memicu ROLLBACK otomatis.
    }
}

Trade-Off: Transaksi Rollback vs Dedicated Schema per-Thread

Pola transaksi rollback bukan satu-satunya solusi isolasi data. Evaluasi karakteristik kedua pendekatan berikut sebelum menentukan arsitektur pengujian:

1. Pola Transaksi Rollback (In-Transaction)

  • Kelebihan: Eksekusi sangat cepat karena tidak ada overhead DDL (pembuatan tabel baru); penggunaan disk minimal; teardown instan via abort/rollback connection.
  • Kekurangan: Tidak mendukung endpoint yang di dalamnya secara eksplisit memanggil transaksi mandiri (nested transactions tidak didukung langsung oleh sebagian besar SQL driver tanpa savepoint); tidak dapat menguji perubahan DDL/migrasi schema.

2. Pola Dedicated Schema / Database per Thread

Pendekatan ini membuat schema baru (contoh: CREATE SCHEMA test_uuid) atau database SQLite/PostgreSQL terpisah untuk setiap unit thread pengujian.

  • Kelebihan: Isolasi total; mendukung multi-transaksi internal aplikasi secara normal; kompatibel penuh dengan background workers atau multi-connection flow.
  • Kekurangan: Setup lambat karena harus menjalankan migration script di setiap schema/DB baru; beban I/O tinggi pada database server test.

Panduan Pemilihan

Gunakan pola transaksi rollback untuk sebagian besar CRUD integration test endpoint REST API standar guna mempertahankan kecepatan pipeline cargo test. Alihkan ke pola dedicated schema atau dedicated test container hanya untuk skenario pengujian yang mencakup transaksi terdistribusi, migration testing, atau operasi asynchronous background task yang tidak dapat membagikan handle koneksi yang sama.