← Semua pembelajaran / Go Nol → Enterprise
Fase 8 · Testing & Kualitas Kode

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 -short agar 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 isolasiKecepatanBatasan
Transaksi + rollbackTercepatTidak bisa menguji kode yang memakai transaksinya sendiri
Skema per tesCepatMigrasi harus dijalankan per skema
TRUNCATE antar tesSedangTidak bisa paralel
Container per tesPaling lambatHanya untuk tes yang mengubah konfigurasi server

Yang hanya bisa diuji dengan database sungguhan

  1. SQL-mu sendiri — sintaks, nama kolom, join. Fake tidak menguji satu karakter pun dari kuerimu.
  2. Migrasi — apakah up berjalan dari nol, dan apakah down benar.
  3. Constraint — unique, foreign key, check. Termasuk kode error 23505 (Fase 4).
  4. Transaksi dan penguncian — FOR UPDATE, SKIP LOCKED, deadlock.
  5. Tipe khusus Postgres — jsonb, array, tstzrange.
  6. Rencana kueri — EXPLAIN pada 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.