Pengujian unit dengan mocking pada layer basis data sering kali menyembunyikan masalah nyata: sintaks query spesifik vendor yang tidak kompatibel, kegagalan foreign key constraint, atau skema migrasi yang tidak sinkron. Mock cenderung rapuh (brittle) dan membutuhkan pemeliharaan kode pengujian yang intensif tanpa memberi jaminan tinggi terhadap keandalan saat aplikasi dideploy ke lingkungan produksi.

Solusi standar industri untuk masalah ini adalah integration testing menggunakan basis data riil via testcontainers-go. Dikombinasikan dengan metode pengujian internal framework Go Fiber (app.Test()), Anda dapat memverifikasi alur HTTP hingga penulisan data fisik secara deterministik.

Arsitektur Pengujian Integrasi

Pola pengujian ini menggunakan container PostgreSQL ephemeral yang dijalankan langsung oleh runtime Go selama pengujian berlangsung, lalu dihancurkan setelah selesai. Siklus hidup pengujian meliputi:

  1. Membuat PostgreSQL container menggunakan testcontainers-go.
  2. Mengekstrak connection string dinamis dari container yang aktif.
  3. Membuka connection pool (*sql.DB atau *pgxpool.Pool) dan menjalankan automasi migrasi skema.
  4. Menginisialisasi routing Go Fiber dengan handler produksi.
  5. Menjalankan HTTP request in-memory via app.Test().
  6. Membersihkan data (isolasi) setiap skenario uji selesai.

Setup Container PostgreSQL Ephemeral

Gunakan modul resmi testcontainers-go/modules/postgres untuk menyederhanakan konfigurasi container, port binding otomatis, dan pengecekan kesiapan container (readiness probe).

package tests

import (
	"context"
	"database/sql"
	"testing"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib"
	"github.com/testcontainers/testcontainers-go"
	tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/wait"
)

func setupPostgresContainer(ctx context.Context, tb testing.TB) (*tcpostgres.PostgresContainer, *sql.DB) {
	tb.Helper()

	pgContainer, err := tcpostgres.Run(ctx,
		"postgres:16-alpine",
		tcpostgres.WithDatabase("testdb"),
		tcpostgres.WithUsername("postgres"),
		tcpostgres.WithPassword("postgres"),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).
				WithStartupTimeout(30*time.Second),
		),
	)
	if err != nil {
		tb.Fatalf("gagal menjalankan postgres container: %s", err)
	}

	connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
	if err != nil {
		tb.Fatalf("gagal mendapatkan connection string: %s", err)
	}

	db, err := sql.Open("pgx", connStr)
	if err != nil {
		tb.Fatalf("gagal membuka koneksi database: %s", err)
	}

	db.SetMaxOpenConns(10)
	db.SetMaxIdleConns(5)

	if err := db.PingContext(ctx); err != nil {
		tb.Fatalf("ping database gagal: %s", err)
	}

	return pgContainer, db
}

Automasi Migrasi Database pada Test Startup

Skema pengujian harus identik dengan skema produksi. Hindari hardcoding DDL di dalam kode pengujian. Eksekusi file migrasi mentah (SQL murni) atau integrasikan engine migrasi seperti golang-migrate tepat setelah container siap menerima koneksi.

func runMigrations(ctx context.Context, tb testing.TB, db *sql.DB) {
	tb.Helper()

	schema := `
	CREATE TABLE IF NOT EXISTS users (
		id SERIAL PRIMARY KEY,
		name VARCHAR(100) NOT NULL,
		email VARCHAR(100) UNIQUE NOT NULL,
		created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
	);`

	if _, err := db.ExecContext(ctx, schema); err != nil {
		tb.Fatalf("gagal mengeksekusi migrasi skema: %s", err)
	}
}

Isolasi Data: Truncate vs Rollback Transaksi

Setiap skenario pengujian harus berjalan dalam keadaan bersih agar tidak terjadi kegagalan acak (test pollution). Terdapat dua pendekatan umum:

  • Rollback Transaksi: Seluruh operasi pengujian dibungkus dalam db.Begin() dan dibatalkan di akhir pengujian via tx.Rollback(). Pendekatan ini sangat cepat, namun memiliki keterbatasan jika kode aplikasi Anda mengeksekusi commit eksplisit, migrasi skema DDL, atau goroutine paralel.
  • Truncate Table: Menghapus seluruh baris data menggunakan TRUNCATE TABLE ... RESTART IDENTITY CASCADE pada fase pembersihan (tb.Cleanup()). Sedikit lebih lambat, namun menguji perilaku commit data secara nyata sesuai alur produksi.

Implementasi fungsi pembersihan dengan truncate:

func truncateTables(ctx context.Context, tb testing.TB, db *sql.DB, tables ...string) {
	tb.Helper()
	tb.Cleanup(func() {
		for _, table := range tables {
			_, err := db.ExecContext(ctx, "TRUNCATE TABLE "+table+" RESTART IDENTITY CASCADE;")
			if err != nil {
				tb.Errorf("gagal membersihkan tabel %s: %s", table, err)
			}
		}
	})
}

Menjalankan Pengujian Fiber via app.Test()

Fiber menyediakan utilitas app.Test() yang mengalirkan HTTP request langsung ke router Fasthttp tanpa mengikat (bind) TCP port lokal sistem operasi. Ini mengeliminasi risiko port collision pada lingkungan continuous integration (CI).

package tests

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"net/http"
	"testing"

	"github.com/gofiber/fiber/v2"
)

func buildServer(db *sql.DB) *fiber.App {
	app := fiber.New()

	app.Post("/users", func(c *fiber.Ctx) error {
		var req struct {
			Name  string `json:"name"`
			Email string `json:"email"`
		}
		if err := c.BodyParser(&req); err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
		}

		var id int
		err := db.QueryRowContext(c.Context(),
			"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
			req.Name, req.Email,
		).Scan(&id)
		if err != nil {
			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
		}

		return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": id})
	})

	return app
}

func TestCreateUser_Integration(t *testing.T) {
	ctx := context.Background()

	// Inisialisasi container & pool (biasanya diabstraksikan via TestMain atau fixture)
	pgContainer, db := setupPostgresContainer(ctx, t)
	t.Cleanup(func() {
		_ = db.Close()
		_ = pgContainer.Terminate(ctx)
	})

	runMigrations(ctx, t, db)
	truncateTables(ctx, t, db, "users")

	app := buildServer(db)

	payload := map[string]string{
		"name":  "Gopher",
		"email": "[email protected]",
	}
	body, _ := json.Marshal(payload)

	req, _ := http.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")

	// Eksekusi in-memory request dengan timeout 2 detik
	resp, err := app.Test(req, 2000)
	if err != nil {
		t.Fatalf("eksekusi app.Test gagal: %s", err)
	}

	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("status code mismatch: expect 201, got %d", resp.StatusCode)
	}

	respBody, _ := io.ReadAll(resp.Body)
	var result map[string]int
	if err := json.Unmarshal(respBody, &result); err != nil || result["id"] == 0 {
		t.Fatalf("respons ID tidak valid: %s", string(respBody))
	}

	// Verifikasi persistensi fisik ke basis data
	var count int
	err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE email = $1", payload["email"]).Scan(&count)
	if err != nil || count != 1 {
		t.Fatalf("data tidak tersimpan secara permanen di database")
	}
}

Optimasi Performa CI dan Container Reuse

Menjalankan container baru untuk setiap fungsi pengujian individual akan memperlambat pipeline CI secara drastis. Terapkan strategi berikut untuk mengoptimalkan efisiensi waktu eksekusi:

1. Pola TestMain untuk Shared Container

Jalankan satu instance container untuk seluruh package pengujian menggunakan fungsi TestMain(m *testing.M). Setiap sub-test kemudian hanya perlu melakukan truncate data.

var (
	sharedDB        *sql.DB
	sharedContainer *tcpostgres.PostgresContainer
)

func TestMain(m *testing.M) {
	ctx := context.Background()
	var err error

	// Inisialisasi container sekali untuk seluruh suite
	sharedContainer, sharedDB = setupGlobalPostgres(ctx)
	runMigrations(ctx, sharedDB)

	code := m.Run()

	// Graceful shutdown
	_ = sharedDB.Close()
	_ = sharedContainer.Terminate(ctx)

	os.Exit(code)
}

2. Testcontainers Reuse Feature

Saat menjalankan pengujian secara lokal, aktifkan fitur container reuse agar Docker tidak membuat container baru berulang kali saat Anda menjalankan go test ./.... Tambahkan opsi testcontainers.WithReuse() pada definisi container, lalu aktifkan konfigurasi sistem operasi berikut pada shell lokal:

export TESTCONTAINERS_REUSE_ENABLE=true
Catatan Resource: Pastikan container runtime (Docker Desktop, Colima, atau Podman) memiliki alokasi memori minimal 2 GB dan 2 CPU core. Testcontainers bergantung pada container Ryuk untuk memastikan resource container dibersihkan otomatis jika proses go test berhenti secara abnormal (misalnya akibat panic atau interupsi SIGINT).