Menjalankan ribuan database integration test sering kali memakan waktu menit hingga puluhan menit di pipeline CI/CD. Bottleneck utama hampir selalu berakar pada I/O disk fisik untuk Write-Ahead Logging (WAL), overhead migrasi skema berulang, setup-teardown koneksi TCP, serta operasi TRUNCATE antar test case.

Dengan mengombinasikan isolasi berbasis transaksi rollback dan pemanfaatan shared memory (RAM), test suite dapat dipangkas drastis tanpa mengorbankan integritas data antar pengujian.

Penyebab Utama Latensi pada Database Test

Sebelum menerapkan optimasi, identifikasi titik lambat pada siklus eksekusi test integration standar:

  • Disk I/O dan fsync: Database ACID secara default memanggil fsync() untuk menjamin data tertulis permanen ke disk via WAL. Pada test suite, persistensi data jangka panjang ini tidak diperlukan.
  • Koneksi TCP Handshake: Membuka dan menutup koneksi database di setiap test function menambah latensi network round-trip dan overhead autentikasi database engine.
  • State Reset via DDL/TRUNCATE: Menjalankan migrasi ulang atau mengeksekusi TRUNCATE TABLE pada puluhan tabel membutuhkan locks tingkat tinggi dan membersihkan table pages berulang kali.

Pola 1: Isolasi State via Single-Transaction Rollback

Alih-alih membersihkan database menggunakan TRUNCATE atau re-migrasi di akhir setiap test, bungkus setiap test case di dalam sebuah database transaction dan lakukan ROLLBACK setelah asersi selesai.

Database engine tidak perlu menulis data final ke disk, pointer data dibersihkan secara instan di memori, dan data terisolasi sempurna antar test case paralel maupun sekuensial.

Contoh Implementasi (Go + pgx / database/sql)

package db_test

import (
	"context"
	"database/sql"
	"testing"
	_ "github.com/jackc/pgx/v5/stdlib"
)

func runInTx(t *testing.T, db *sql.DB, testFn func(tx *sql.Tx)) {
	t.Helper()
	tx, err := db.BeginTx(context.Background(), nil)
	if err != nil {
		t.Fatalf("gagal memulai transaksi: %v", err)
	}

	defer func() {
		// Batalkan seluruh mutasi state setelah test selesai
		if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
			t.Errorf("gagal rollback transaksi: %v", err)
		}
	}()

	testFn(tx)
}

func TestCreateUser(t *testing.T) {
	// db didapat dari shared connection pool (singleton worker)
	runInTx(t, globalDB, func(tx *sql.Tx) {
		_, err := tx.Exec("INSERT INTO users (id, name) VALUES ($1, $2)", "usr_1", "Budi")
		if err != nil {
			t.Fatalf("insert gagal: %v", err)
		}

		var count int
		err = tx.QueryRow("SELECT COUNT(*) FROM users WHERE id = $1", "usr_1").Scan(&count)
		if err != nil || count != 1 {
			t.Fatalf("user gagal tersimpan dalam transaksi")
		}
	})
	// Begitu keluar dari runInTx, database bersih seketika tanpa I/O cost
}
Peringatan Trade-off: Pendekatan single-transaction rollback tidak mendukung kode aplikasi yang memanggil transaksi bertingkat eksplisit (tx.Commit() di dalam business logic) kecuali database driver Anda mengimplementasikan savepoints (nested transactions).

Pola 2: PostgreSQL via tmpfs dan Unlogged Tables

Jika aplikasi memerlukan commit eksplisit di dalam alur pengujian, letakkan cluster database pengujian di RAM menggunakan tmpfs Linux atau konfigurasi shared memory Docker container.

1. Docker Tmpfs Mount

services:
  postgres-test:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: test
      POSTGRES_PASSWORD: secret
    tmpfs:
      # Mount data directory sepenuhnya di RAM
      - /var/lib/postgresql/data:rw,noexec,nosuid,size=1024m
    command: >
      postgres
      -c synchronous_commit=off
      -c fsync=off
      -c full_page_writes=off
      -c shared_buffers=256MB

Pengaturan fsync=off dan synchronous_commit=off mematikan operasi sinkronisasi disk kernel saat mutasi terjadi. Risiko data loss akibat OS crash tidak relevan dalam konteks runner pengujian.

2. Penggunaan Unlogged Tables

Jika database instance dipakai bersama (shared) dan tidak dapat dimount ke tmpfs, ubah tabel pengujian menjadi UNLOGGED. Data pada unlogged table tidak ditulis ke WAL, memangkas latensi INSERT/UPDATE hingga 50-70%.

-- Jalankan pasca-migrasi skema test
ALTER TABLE users SET UNLOGGED;
ALTER TABLE orders SET UNLOGGED;

Pola 3: SQLite In-Memory DB

SQLite in-memory (:memory: atau file::memory:?cache=shared) mengeksekusi seluruh operasi di RAM lokal proses tanpa network overhead.

-- Menggunakan DSN Go/Python/Node
file:memdb1?mode=memory&cache=shared

Kapan Harus Menghindari In-Memory SQLite?

  • Aplikasi menggunakan fitur spesifik database production (misal: PostgreSQL JSONB containment @>, concurrency lock SELECT FOR UPDATE SKIP LOCKED, atau trigger rumit).
  • Tipe data dialek SQL tidak kompatibel (misal: SQLite loosely-typed vs Postgres strict data types). Perbedaan kompatibilitas ini dapat meloloskan bug ke production environment.

Perbandingan Performa (Benchmark Eksekusi 1000 Test)

Pengujian disimulasikan menggunakan test suite berisi 1.000 test case sederhana (CRUD + asersi) pada Postgres 16 di lingkungan CI (4 vCPU, 8 GB RAM):

Metode IsolasiRata-rata Waktu / TestTotal Durasi (1000 Test)Karakteristik Disk I/O
Standar (Migrasi + TRUNCATE + fsync ON)28.5 ms28.5 detikTinggi (continuous sync write)
Postgres tmpfs + fsync OFF2.1 ms2.1 detikNol (RAM only)
Single-Transaction Rollback (Network TCP)0.9 ms0.9 detikNol (Memory buffer)
SQLite In-Memory (cache=shared)0.12 ms0.12 detikNol (In-process memory)

Langkah Debugging & Best Practices

  1. Connection Pooling: Batasi pool koneksi test worker sesuai core CPU lokal (misal: MaxOpenConns = 4-8). Membuka ratusan koneksi secara simultan di database Postgres justru menurunkan throughput akibat CPU context-switching.
  2. Prepared Statement Caching: Pastikan driver database mengaktifkan statement caching agar query parsing SQL tidak diproses berulang-ulang pada ribuan siklus eksekusi test.
  3. Hindari Leak Transaksi: Gunakan pola defer tx.Rollback() sesaat setelah Begin() agar transaksi terhenti meskipun test panic atau menemui assertion error di tengah jalan.