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:
- Membuat PostgreSQL container menggunakan
testcontainers-go. - Mengekstrak connection string dinamis dari container yang aktif.
- Membuka connection pool (
*sql.DBatau*pgxpool.Pool) dan menjalankan automasi migrasi skema. - Menginisialisasi routing Go Fiber dengan handler produksi.
- Menjalankan HTTP request in-memory via
app.Test(). - 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 viatx.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 CASCADEpada 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=trueCatatan 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).
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!