Testcontainers — tes integrasi dengan database sungguhan
Fake di memori bagus untuk logika bisnis, tapi tidak menguji SQL-mu. Testcontainers menjalankan Postgres sungguhan di Docker selama tes, sehingga kueri, migrasi, dan constraint benar-benar teruji.
Intisari
- Menjalankan container sungguhan dari dalam tes Go, lalu menghentikannya otomatis.
- Satu container untuk seluruh paket lewat
TestMain; per tes cukup skema atau transaksi terpisah. - Jalankan migrasi yang sama dengan produksi — itu sekaligus menguji migrasinya.
- Isolasi antar tes: transaksi yang di-rollback (cepat) atau skema per tes (paling bersih).
- Butuh Docker; di CI, tandai dengan
-shortagar bisa dilewati saat tidak tersedia.
Satu container untuk satu paket
// internal/produk/main_test.go
var dbUji *pgxpool.Pool
func TestMain(m *testing.M) {
if testing.Short() {
os.Exit(m.Run()) // lewati; tes unit tetap jalan
}
ctx := context.Background()
kontainer, err := postgres.Run(ctx, "postgres:17-alpine",
postgres.WithDatabase("uji"),
postgres.WithUsername("uji"),
postgres.WithPassword("uji"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second)),
)
if err != nil {
log.Fatalf("jalankan postgres: %v", err)
}
dsn, err := kontainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
log.Fatal(err)
}
// Migrasi yang SAMA dengan produksi — jadi migrasinya ikut teruji.
if err := migrasi.Jalankan(dsn); err != nil {
log.Fatalf("migrasi: %v", err)
}
dbUji, err = pgxpool.New(ctx, dsn)
if err != nil {
log.Fatal(err)
}
kode := m.Run()
dbUji.Close()
_ = kontainer.Terminate(ctx)
os.Exit(kode)
}
Memulai container butuh beberapa detik, jadi lakukan sekali per paket lewat
TestMain — bukan per tes. Isolasi antar tes ditangani di lapisan yang jauh lebih murah:
transaksi atau skema.
Isolasi antar tes
// Cara tercepat: satu transaksi per tes, selalu di-rollback.
func repoUji(t *testing.T) *Repo {
t.Helper()
tx, err := dbUji.Begin(t.Context())
if err != nil {
t.Fatalf("mulai transaksi: %v", err)
}
t.Cleanup(func() { _ = tx.Rollback(context.Background()) })
return NewRepoDenganTx(tx)
}
// Paling bersih (dan bisa paralel): satu skema per tes.
func dbTerisolasi(t *testing.T) *pgxpool.Pool {
t.Helper()
skema := "uji_" + strings.ReplaceAll(t.Name(), "/", "_")
ctx := t.Context()
if _, err := dbUji.Exec(ctx, "CREATE SCHEMA "+pgx.Identifier{skema}.Sanitize()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = dbUji.Exec(context.Background(),
"DROP SCHEMA "+pgx.Identifier{skema}.Sanitize()+" CASCADE")
})
...
}
| Cara isolasi | Kecepatan | Batasan |
|---|---|---|
| Transaksi + rollback | Tercepat | Tidak bisa menguji kode yang memakai transaksinya sendiri |
| Skema per tes | Cepat | Migrasi harus dijalankan per skema |
TRUNCATE antar tes | Sedang | Tidak bisa paralel |
| Container per tes | Paling lambat | Hanya untuk tes yang mengubah konfigurasi server |
Yang hanya bisa diuji dengan database sungguhan
- SQL-mu sendiri — sintaks, nama kolom, join. Fake tidak menguji satu karakter pun dari kuerimu.
- Migrasi — apakah
upberjalan dari nol, dan apakahdownbenar. - Constraint — unique, foreign key, check. Termasuk kode error
23505(Fase 4). - Transaksi dan penguncian —
FOR UPDATE,SKIP LOCKED, deadlock. - Tipe khusus Postgres —
jsonb, array,tstzrange. - Rencana kueri —
EXPLAINpada data yang cukup banyak.
Ini alasan menguji dengan SQLite lalu menjalankan Postgres di produksi berbahaya. Perbedaannya
bocor di tempat-tempat kecil: penanganan JSON, ketatnya tipe, perilaku ALTER TABLE, dan
sensitivitas huruf besar-kecil saat membandingkan string. Yang lulus di tes bisa gagal di produksi —
kelas kegagalan paling mahal yang ada.
Wadah lain yang berguna
// Redis
rd, _ := redis.Run(ctx, "redis:7-alpine")
// LocalStack: S3, SQS, Secrets Manager — untuk tes integrasi AWS (Fase 10)
ls, _ := localstack.Run(ctx, "localstack/localstack:3")
Memisahkan tes unit dan integrasi
go test -short ./... # tanpa Docker: cepat, jalan di mana saja
go test ./... # lengkap: butuh Docker
# Langkah CI
- run: go test -short -race ./... # cepat, di setiap push
- run: go test -race ./... # lengkap, sebelum merge
Piramida yang sehat untuk aplikasi web Go: banyak tes domain murni (mikrodetik, tanpa
dependensi), sejumlah tes repository dengan Postgres sungguhan (puluhan milidetik), sejumlah tes handler
lewat httptest dengan database sungguhan, dan sangat sedikit tes ujung-ke-ujung. Yang
membuat suite tidak terpakai bukan jumlah tesnya, melainkan durasinya — di atas dua menit, orang berhenti
menjalankannya sebelum push.
Latihan: pasang TestMain dengan Postgres testcontainers, jalankan migrasimu di
sana, dan tulis satu tes repository yang menyimpan lalu membaca kembali. Lalu tambahkan unique
constraint dan tulis tes yang membuktikan penyimpanan kedua mengembalikan ErrKonflik —
jalur yang tidak mungkin diuji dengan fake.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.