Proyek sebelumnya membuktikan bahwa container bisa menyajikan berkas; proyek ini membuktikan bahwa container bisa berpikir, mengingat, dan melayani data yang berubah. Proyek: Container Web Statis menampilkan katalog Kopi Nusantara yang hanya berubah setiap kali kita membangun ulang image — sekarang katalog itu kita beri tulang punggung yang hidup: sebuah REST API Go yang menyimpan setiap item ke PostgreSQL, menerima item baru lewat POST, dan mengembalikannya lewat GET tanpa menyentuh image sama sekali. Ini proyek backend dinamis pertama kita, dan untuk pertama kalinya dua container saling bergantung: aplikasi yang tidak berguna tanpa database, dan database yang sepi tanpa aplikasi. Semua kebiasaan baik dari dua puluh section sebelumnya — image ramping, healthcheck yang jujur, konfigurasi dari environment, definisi stack di berkas — naik kelas mengikuti tuntutan aplikasi yang hidup.
Apa yang Kita Bangun #
Produknya bernama katalog: REST API sederhana untuk daftar item, ditulis dengan pustaka standar Go (net/http) dan driver PostgreSQL jackc/pgx/v5. Aplikasinya sadar-container sejak baris pertama — seluruh konfigurasi dibaca dari environment variable, skema database dimigrasikan otomatis saat startup, koneksi database dicoba ulang bila PostgreSQL belum siap, dan proses mati dengan sopan saat menerima SIGTERM. Empat endpoint yang dilayani:
| Metode | Path | Fungsi | Respons sukses |
|---|---|---|---|
| GET | /healthz | Memastikan aplikasi dan database sama-sama hidup | 200 dengan {"status":"ok"} |
| GET | /api/items | Menampilkan seluruh item | 200 berupa array JSON |
| POST | /api/items | Membuat item dari body name dan note | 201 berupa item yang tersimpan |
| GET | /api/items/{id} | Mengambil satu item berdasarkan id | 200, atau 404 bila tidak ada |
Di sekeliling keempat endpoint itu berdiri infrastruktur yang justru menjadi pelajaran utama proyek ini:
- dua container yang saling bicara —
appmenemukandblewat DNS milik network yang dibuat Compose secara otomatis; satu-satunya port yang diterbitkan ke host adalah8080milik aplikasi, - migrasi otomatis yang idempoten — tabel
schema_migrationsmencatat versi yang sudah diterapkan sehingga restart tidak menjalankan ulang skema, - kesiapan berlapis — healthcheck
pg_isreadydi database,depends_ondengancondition: service_healthydi Compose, dan retry 10 kali di aplikasi, - image multi-stage — builder
golang:1.24-alpineyang beratnya ratusan megabyte tertinggal saat build; hasil akhirnyaalpine:3.20setebal ±18MB yang berjalan sebagai user non-root, - integration test black-box — service Compose berprofile
testyang menembak HTTP aplikasi persis seperti klien sungguhan.
Hasil Akhir dalam Dua Perintah #
Seluruh proyek bermuara pada dua perintah berikut, dijalankan dari dalam folder proyek setelah lima bagian selesai. Persiapannya satu baris: password database ditulis ke berkas .env yang dibaca Compose.
$ echo 'POSTGRES_PASSWORD=rahasia123' > .env
$ docker compose up -d --buildOutput yang Dihasilkan:
[+] Building 1.9s (21/21) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
...
=> => naming to docker.io/library/katalog-app:1.0 0.0s
[+] Running 3/3
✔ Network katalog_default Created 0.2s
✔ Container katalog-db-1 Healthy 9.4s
✔ Container katalog-app-1 Started 1.1sBagian build dipotong — pada titik ini seluruh langkahnya CACHED karena image sudah dibangun pada Bagian 2. Tiga baris ✔ menceritakan urutan hidup stack: network dibuat, database menunggu sampai benar-benar sehat, baru aplikasi dinyalakan. Perintah kedua mengetuk pintu aplikasi:
$ curl -s http://localhost:8080/healthzOutput yang Dihasilkan:
{"status":"ok"}Enam belas byte itu baru kartu nama. Isi perkenalannya terlihat saat kita mengirim item pertama:
$ curl -s -X POST http://localhost:8080/api/items \
-H "Content-Type: application/json" \
-d '{"name":"Kopi Torabika","note":"favorit pelanggan"}'Output yang Dihasilkan:
{"id":1,"name":"Kopi Torabika","note":"favorit pelanggan","created_at":"2026-10-13T14:44:01.035749Z"}Perhatikan dua nilai yang tidak kita kirim: id diisi PostgreSQL dari kolom BIGSERIAL, dan created_at diisi fungsi now() di sisi database — aplikasi cukup memantulkannya kembali lewat klausa RETURNING. Baris ini kini hidup di volume db-data, dan Bagian 4 akan membuktikan bahwa ia tetap ada bahkan setelah stack dibongkar total dan dinaikkan lagi.
Tujuan Pembelajaran #
Setelah menyelesaikan proyek ini, kita mampu:
- merancang aplikasi yang lahir sadar-container: konfigurasi dari environment variable, migrasi skema otomatis saat startup, retry koneksi berkala, dan graceful shutdown saat sinyal berakhir,
- menulis Dockerfile multi-stage untuk bahasa kompilasi: tahap builder Go yang tertinggal, binary statis hasil
CGO_ENABLED=0, dan tahap runtime Alpine yang berjalan sebagai user non-root, - menyusun stack dua service di compose.yaml: PostgreSQL dengan healthcheck
pg_isready, aplikasi dengandepends_onberyaratservice_healthy, volume bernama, dan password yang diinterpolasi dari.env, - menjelaskan bagaimana dua container saling menemukan: network otomatis Compose, DNS service di
127.0.0.11, dan alasan database sengaja tidak menerbitkan port ke host, - menjalankan integration test black-box sebagai service Compose berprofile
testyang menembak HTTP aplikasi dari container terpisah dengan database yang sama.
Prasyarat #
Proyek ini tidak memperkenalkan konsep baru; ia merangkai konsep lama menjadi sistem. Chapter yang paling sering dirujuk, dikelompokkan per section:
- Section Dockerfile — Struktur Dockerfile untuk peta instruksinya, WORKDIR dan USER serta ENV dan ARG untuk instruksi yang paling banyak dipakai Dockerfile kita, dan HEALTHCHECK untuk pemeriksaan kesehatan runtime.
- Section Docker Compose — Pengantar Docker Compose dan Struktur compose.yaml untuk tata bahasanya, Environment dan env File serta Variable Interpolation untuk password dari
.env, Healthcheck dan Service Readiness dan depends_on dan Startup Order untuk urutan start, Volume dan Bind Mount untukdb-data, dan Profiles untuk service test. - Section Docker dan Database — Menjalankan PostgreSQL untuk environment
POSTGRES_*dan volume data, Persistensi Database untuk pembuktian volume, dan Migrasi Versi Database untuk pola tabel pencatat versi. - Section Jaringan Container — Membuat Custom Network dan Container DNS untuk resolusi nama service, serta Komunikasi Antar Container untuk pola aplikasi-ke-database.
Pastikan juga mesin latihan sudah lulus Verifikasi Instalasi: Docker Engine 29.9.0 dengan plugin Compose v5.6.0, serta klien curl untuk mengetok API. Anda tidak perlu fasih Go untuk mengikuti proyek ini — seluruh source code disediakan lengkap dan setiap bagian membedah kode yang relevan; yang dituntut adalah kebiasaan membaca program yang memang dirancang untuk hidup di dalam container.
Peta File Proyek #
Struktur lengkap proyek yang kita bedah bertahap:
aplikasi-go-dan-postgresql/
├── main.go
├── database.go
├── handlers.go
├── go.mod
├── go.sum
├── migrations/
│ └── 001_create_items.sql
├── tests/
│ └── integration_test.go
├── Dockerfile
├── compose.yaml
├── .dockerignore
└── README.mdPeran setiap berkas dan bagian tempat ia dibedah:
| Berkas | Peran | Dibedah di |
|---|---|---|
main.go | Konfigurasi environment, routing, graceful shutdown | Bagian 1 |
database.go | Koneksi dengan retry dan runner migrasi schema_migrations | Bagian 1 |
handlers.go | Handler HTTP untuk keempat endpoint | Bagian 1 |
migrations/001_create_items.sql | Skema awal tabel items | Bagian 1 |
Dockerfile | Resep multi-stage: builder Go, runtime Alpine | Bagian 2 |
.dockerignore | Penyaring build context | Bagian 2 |
compose.yaml | Stack app, db, dan test (profile) | Bagian 3 dan 5 |
tests/integration_test.go | Test black-box HTTP | Bagian 5 |
go.mod / go.sum | Manifest dan checksum dependensi | Bagian 2 |
Tahapan #
Proyek dikerjakan dalam lima bagian berurutan; setiap bagian berdiri di atas berkas dan keputusan bagian sebelumnya:
- Bagian 1: Desain dan Arsitektur — membaca source code aplikasi: routing, skema dan migrasi idempoten, retry koneksi, serta alur POST dari ujung ke ujung.
- Bagian 2: Membuat Dockerfile — multi-stage builder Go dan runtime Alpine, urutan manifest-first beserta bukti cache-nya, dan
.dockerignore. - Bagian 3: Konfigurasi PostgreSQL — service
dbdengan healthcheckpg_isready, serviceappdengandepends_onberlapis, dan stack pertama yang menyala. - Bagian 4: Networking dan Persistence — DNS Compose, database tanpa port publik, dan pembuktian volume yang bertahan melewati
downdanup. - Bagian 5: Pengujian Integrasi — integration test black-box sebagai service profile
test, dijalankan dari dalam stack yang sama.
Mulailah dari Bagian 1 dan pertahankan urutannya: Dockerfile pada Bagian 2 hanya masuk akal bila kita tahu apa yang dibaca aplikasi saat menyala, urutan service pada Bagian 3 adalah jawaban atas masalah kesiapan yang kita temui di Bagian 1, dan pembuktian pada Bagian 4 serta test pada Bagian 5 mengandalkan stack yang hidup dari Bagian 3.
Referensi #
- Docker Compose
— model deklaratif untuk stack
app,db, dantestyang menjadi inti proyek ini. - PostgreSQL di Docker Hub
— halaman image resmi
postgres:18-alpinebeserta kontrak variabelPOSTGRES_*. - Dockerfile Reference — semantik instruksi yang dipakai Dockerfile multi-stage pada Bagian 2.
Rancang struktur aplikasi, koneksi database, migrasi, dan peta endpoint.
Buka → # Bagian 2: Membuat DockerfileTulis Dockerfile multi-stage Go dan .dockerignore yang tepat.
Buka → # Bagian 3: Konfigurasi PostgreSQLPasang service PostgreSQL dengan healthcheck dan tunggu kesiapannya.
Buka → # Bagian 4: Networking dan PersistenceSambungkan aplikasi ke database lewat DNS Compose dan buktikan persistensi volume.
Buka → # Bagian 5: Pengujian IntegrasiJalankan integration test dari dalam container dengan profile khusus.
Buka → # Buka →Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
compose.yaml
Unduh berkasname: katalog
services:
app:
build: .
image: katalog-app:1.0
ports:
- "8080:8080"
environment:
PGHOST: db
PGPORT: "5432"
PGUSER: katalog
PGPASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
PGDATABASE: katalog
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: katalog
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
POSTGRES_DB: katalog
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U katalog -d katalog"]
interval: 2s
timeout: 2s
retries: 15
restart: unless-stopped
# Service test hanya berjalan dengan profile khusus:
# docker compose --profile test run --rm test
test:
profiles: ["test"]
image: golang:1.24-alpine
working_dir: /src
volumes:
- ./:/src
environment:
APP_URL: http://app:8080
PGHOST: db
PGPORT: "5432"
PGUSER: katalog
PGPASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
PGDATABASE: katalog
depends_on:
app:
condition: service_started
db:
condition: service_healthy
command: go test -v ./tests/
volumes:
db-data:
database.go
Unduh berkas// koneksi database dan runner migrasi sederhana.
//
// Open() mencoba terhubung hingga 10 kali dengan jeda 500 ms — pola ini
// menutup celah startup saat container database belum sepenuhnya siap.
// migrate() membaca migrations/*.sql secara berurutan dan mencatat versi
// yang sudah diterapkan ke tabel schema_migrations.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib" // mendaftarkan driver "pgx" untuk database/sql
)
// jumlahPercobaan dan jedaPercobaan mengatur retry koneksi database.
const (
jumlahPercobaan = 10
jedaPercobaan = 500 * time.Millisecond
)
// Open membuka koneksi PostgreSQL dan memastikan benar-benar hidup
// dengan PingContext sebelum dikembalikan.
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
var errTerakhir error
for percobaan := 1; percobaan <= jumlahPercobaan; percobaan++ {
db, err := sql.Open("pgx", dsn)
if err == nil {
if err = db.PingContext(ctx); err == nil {
return db, nil
}
db.Close()
}
errTerakhir = err
log.Printf("koneksi database gagal (percobaan %d/%d): %v", percobaan, jumlahPercobaan, err)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(jedaPercobaan):
}
}
return nil, fmt.Errorf("database tidak dapat dijangkau setelah %d percobaan: %w", jumlahPercobaan, errTerakhir)
}
// migrate menerapkan setiap berkas migrations/*.sql yang belum tercatat
// di schema_migrations. Satu berkas berjalan dalam satu transaksi,
// bersama pencatatan versinya, supaya keduanya sukses atau gagal bersama.
func migrate(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("membuat tabel schema_migrations: %w", err)
}
entri, err := os.ReadDir("migrations")
if err != nil {
return fmt.Errorf("membaca direktori migrations: %w", err)
}
var berkas []string
for _, e := range entri {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
berkas = append(berkas, e.Name())
}
}
sort.Strings(berkas)
for _, nama := range berkas {
var sudah bool
if err := db.QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, nama,
).Scan(&sudah); err != nil {
return fmt.Errorf("memeriksa versi %s: %w", nama, err)
}
if sudah {
log.Printf("migrasi %s sudah diterapkan, dilewati", nama)
continue
}
isi, err := os.ReadFile(filepath.Join("migrations", nama))
if err != nil {
return fmt.Errorf("membaca %s: %w", nama, err)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memulai transaksi %s: %w", nama, err)
}
if _, err := tx.ExecContext(ctx, string(isi)); err != nil {
tx.Rollback()
return fmt.Errorf("menjalankan %s: %w", nama, err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (version) VALUES ($1)`, nama,
); err != nil {
tx.Rollback()
return fmt.Errorf("mencatat %s: %w", nama, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("komit %s: %w", nama, err)
}
log.Printf("migrasi %s diterapkan", nama)
}
return nil
}
handlers.go
Unduh berkas// handler HTTP untuk API katalog.
//
// Semua respons JSON dikirim lewat writeJSON agar Content-Type konsisten.
// Validasi input dilakukan sebelum menyentuh database: name wajib terisi,
// note opsional dengan batas 200 karakter.
package main
import (
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
)
// item adalah satu baris tabel items; tag json menentukan bentuk respons.
type item struct {
ID int64 `json:"id"`
Name string `json:"name"`
Note string `json:"note"`
CreatedAt time.Time `json:"created_at"`
}
// itemBaru adalah bentuk body request POST /api/items.
type itemBaru struct {
Name string `json:"name"`
Note string `json:"note"`
}
// batasCatatan membatasi panjang field note agar data tetap wajar.
const batasCatatan = 200
// writeJSON menulis respons JSON dengan status HTTP yang diberikan.
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
// tulisError mengirim pesan error standar berbentuk {"error": "..."}.
func tulisError(w http.ResponseWriter, status int, pesan string) {
writeJSON(w, status, map[string]string{"error": pesan})
}
// handleHealthz memastikan aplikasi DAN database sama-sama hidup.
func handleHealthz(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var satu int
if err := db.QueryRowContext(r.Context(), `SELECT 1`).Scan(&satu); err != nil {
tulisError(w, http.StatusServiceUnavailable, "database tidak merespons")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
}
// handleListItems mengembalikan seluruh item sebagai array JSON.
func handleListItems(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(),
`SELECT id, name, note, created_at FROM items ORDER BY id`)
if err != nil {
tulisError(w, http.StatusInternalServerError, "gagal membaca items")
return
}
defer rows.Close()
items := []item{}
for rows.Next() {
var it item
if err := rows.Scan(&it.ID, &it.Name, &it.Note, &it.CreatedAt); err != nil {
tulisError(w, http.StatusInternalServerError, "gagal membaca baris item")
return
}
items = append(items, it)
}
writeJSON(w, http.StatusOK, items)
}
}
// handleCreateItem memvalidasi body lalu menyimpan item baru.
func handleCreateItem(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Body dibatasi 1 MB agar request besar tidak menghabiskan memori.
var body itemBaru
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
tulisError(w, http.StatusBadRequest, "body JSON tidak valid")
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
tulisError(w, http.StatusBadRequest, "name wajib diisi")
return
}
if utf8.RuneCountInString(body.Note) > batasCatatan {
tulisError(w, http.StatusBadRequest, "note maksimal 200 karakter")
return
}
var it item
err := db.QueryRowContext(r.Context(),
`INSERT INTO items (name, note) VALUES ($1, $2)
RETURNING id, name, note, created_at`,
name, body.Note,
).Scan(&it.ID, &it.Name, &it.Note, &it.CreatedAt)
if err != nil {
tulisError(w, http.StatusInternalServerError, "gagal menyimpan item")
return
}
writeJSON(w, http.StatusCreated, it)
}
}
// handleGetItem mengembalikan satu item berdasarkan {id} di path.
func handleGetItem(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
tulisError(w, http.StatusBadRequest, "id tidak valid")
return
}
var it item
err = db.QueryRowContext(r.Context(),
`SELECT id, name, note, created_at FROM items WHERE id = $1`, id,
).Scan(&it.ID, &it.Name, &it.Note, &it.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
tulisError(w, http.StatusNotFound, "item tidak ditemukan")
return
}
if err != nil {
tulisError(w, http.StatusInternalServerError, "gagal membaca item")
return
}
writeJSON(w, http.StatusOK, it)
}
}
main.go
Unduh berkas// Package katalog adalah REST API sederhana untuk daftar item.
//
// Konfigurasi dibaca dari environment variable (pola twelve-factor):
// PORT menentukan port HTTP, sedangkan PG* menentukan koneksi PostgreSQL.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// konfigurasi menyimpan seluruh nilai yang dibaca dari environment.
type konfigurasi struct {
Port string
DSN string
}
// bacaKonfigurasi mengambil PORT dan membangun DSN dari variabel PG*.
// Nilai default dipilih agar aplikasi juga bisa jalan di luar container.
func bacaKonfigurasi() konfigurasi {
ambil := func(kunci, standar string) string {
if v := os.Getenv(kunci); v != "" {
return v
}
return standar
}
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
ambil("PGHOST", "localhost"),
ambil("PGPORT", "5432"),
ambil("PGUSER", "katalog"),
os.Getenv("PGPASSWORD"),
ambil("PGDATABASE", "katalog"),
)
return konfigurasi{
Port: ambil("PORT", "8080"),
DSN: dsn,
}
}
// routes memetakan endpoint ke handler dengan pola routing Go 1.22+.
func routes(db *sql.DB) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", handleHealthz(db))
mux.HandleFunc("GET /api/items", handleListItems(db))
mux.HandleFunc("POST /api/items", handleCreateItem(db))
mux.HandleFunc("GET /api/items/{id}", handleGetItem(db))
return mux
}
func main() {
cfg := bacaKonfigurasi()
// Context dibatalkan saat proses menerima SIGINT atau SIGTERM.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := Open(ctx, cfg.DSN)
if err != nil {
log.Fatalf("membuka database: %v", err)
}
defer db.Close()
if err := migrate(ctx, db); err != nil {
log.Fatalf("menjalankan migrasi: %v", err)
}
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: routes(db),
ReadHeaderTimeout: 5 * time.Second,
}
// Saat sinyal berakhir diterima, beri waktu 10 detik untuk
// menyelesaikan request yang sedang diproses (graceful shutdown).
go func() {
<-ctx.Done()
log.Println("sinyal berakhir diterima, mematikan server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
}()
log.Printf("server mendengarkan di :%s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server: %v", err)
}
}
migrations/001_create_items.sql
Unduh berkas-- Tabel utama katalog: satu baris mewakili satu item yang bisa dibuat
-- lewat POST /api/items. id dibangkitkan otomatis oleh BIGSERIAL.
CREATE TABLE IF NOT EXISTS items (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
note TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now()
);
tests/integration_test.go
Unduh berkas// Test black-box untuk API katalog.
//
// Test ini tidak memanggil fungsi internal aplikasi; yang diperiksa adalah
// perilaku HTTP persis seperti yang dirasakan klien sungguhan. Aplikasi
// harus sudah berjalan di APP_URL (default http://localhost:8080) dengan
// database yang dapat dijangkau lewat TEST_DATABASE_URL atau variabel PG*.
package tests
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
)
// appURL adalah alamat aplikasi yang sedang diuji.
var appURL string
// TestMain memastikan aplikasi siap sebelum seluruh test dijalankan.
func TestMain(m *testing.M) {
appURL = os.Getenv("APP_URL")
if appURL == "" {
appURL = "http://localhost:8080"
}
if err := tungguSiap(30 * time.Second); err != nil {
fmt.Println("aplikasi tidak siap:", err)
os.Exit(1)
}
os.Exit(m.Run())
}
// tungguSiap melakukan polling /healthz sampai aplikasi merespons
// atau batas waktu terlampaui — container sering butuh beberapa detik
// sebelum benar-benar siap menerima request.
func tungguSiap(batas time.Duration) error {
mulai := time.Now()
for {
resp, err := http.Get(appURL + "/healthz")
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
if time.Since(mulai) > batas {
return fmt.Errorf("timeout menunggu %s/healthz setelah %s", appURL, batas)
}
time.Sleep(500 * time.Millisecond)
}
}
// dsnTest menyusun DSN untuk reset database: TEST_DATABASE_URL diutamakan,
// jika kosong variabel PG* dirangkai dengan nilai default lokal.
func dsnTest() string {
if dsn := os.Getenv("TEST_DATABASE_URL"); dsn != "" {
return dsn
}
ambil := func(kunci, standar string) string {
if v := os.Getenv(kunci); v != "" {
return v
}
return standar
}
return fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable",
ambil("PGHOST", "localhost"),
ambil("PGPORT", "5432"),
ambil("PGUSER", "katalog"),
ambil("PGDATABASE", "katalog"),
)
}
// resetItems mengosongkan tabel items dan mengembalikan counter id ke 1
// sehingga setiap test mulai dari kondisi yang sama.
func resetItems(t *testing.T) {
t.Helper()
ctx := context.Background()
conn, err := pgx.Connect(ctx, dsnTest())
if err != nil {
t.Fatalf("koneksi database untuk reset: %v", err)
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, `TRUNCATE items RESTART IDENTITY`); err != nil {
t.Fatalf("reset tabel items: %v", err)
}
}
// postJSON mengirim body JSON dan mengembalikan respons mentahnya.
func postJSON(t *testing.T, path string, body any) *http.Response {
t.Helper()
isi, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
resp, err := http.Post(appURL+path, "application/json", bytes.NewReader(isi))
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
t.Cleanup(func() { resp.Body.Close() })
return resp
}
// getJSON mengirim GET dan mengurai hasilnya ke target.
func getJSON(t *testing.T, path string, target any) *http.Response {
t.Helper()
resp, err := http.Get(appURL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
t.Cleanup(func() { resp.Body.Close() })
if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
t.Fatalf("decode respons GET %s: %v", path, err)
}
return resp
}
func TestHealthz(t *testing.T) {
var body struct {
Status string `json:"status"`
}
resp := getJSON(t, "/healthz", &body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, ingin 200", resp.StatusCode)
}
if body.Status != "ok" {
t.Fatalf("status body = %q, ingin \"ok\"", body.Status)
}
}
func TestCreateListGetItem(t *testing.T) {
resetItems(t)
t.Run("create", func(t *testing.T) {
resp := postJSON(t, "/api/items", map[string]string{
"name": "Kopi Torabika",
"note": "untuk pengujian",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, ingin 201", resp.StatusCode)
}
var it struct {
ID int64 `json:"id"`
Name string `json:"name"`
Note string `json:"note"`
}
if err := json.NewDecoder(resp.Body).Decode(&it); err != nil {
t.Fatalf("decode item: %v", err)
}
if it.ID != 1 {
t.Fatalf("id = %d, ingin 1 (tabel baru di-reset)", it.ID)
}
if it.Name != "Kopi Torabika" {
t.Fatalf("name = %q, ingin \"Kopi Torabika\"", it.Name)
}
})
t.Run("list", func(t *testing.T) {
var items []struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
resp := getJSON(t, "/api/items", &items)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, ingin 200", resp.StatusCode)
}
if len(items) != 1 || items[0].Name != "Kopi Torabika" {
t.Fatalf("list = %+v, ingin satu item Kopi Torabika", items)
}
})
t.Run("get", func(t *testing.T) {
var it struct {
ID int64 `json:"id"`
Name string `json:"name"`
Note string `json:"note"`
}
resp := getJSON(t, "/api/items/1", &it)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, ingin 200", resp.StatusCode)
}
if it.Name != "Kopi Torabika" || it.Note != "untuk pengujian" {
t.Fatalf("item = %+v, name/note tidak sesuai", it)
}
})
}
func TestGetItemNotFound(t *testing.T) {
resetItems(t)
var body map[string]string
resp := getJSON(t, "/api/items/999", &body)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, ingin 404", resp.StatusCode)
}
if body["error"] == "" {
t.Fatal("respons 404 seharusnya memuat pesan error")
}
}
func TestCreateItemValidasi(t *testing.T) {
resetItems(t)
t.Run("nama kosong", func(t *testing.T) {
resp := postJSON(t, "/api/items", map[string]string{
"name": "",
"note": "",
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, ingin 400", resp.StatusCode)
}
})
t.Run("catatan terlalu panjang", func(t *testing.T) {
resp := postJSON(t, "/api/items", map[string]string{
"name": "Kopi Gayo",
"note": strings.Repeat("x", 201),
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, ingin 400", resp.StatusCode)
}
})
}