Image aplikasi sudah berdiri; sekarang kita menyusun rumahnya. Stack ini adalah tempat seluruh konsep Compose bertemu sekaligus: satu service untuk PostgreSQL yang harus dinyalakan sebelum aplikasi, satu healthcheck yang mendefinisikan arti “siap”, satu volume yang menampung seluruh memori sistem, dan satu interpolasi variabel yang menjaga password keluar dari berkas. Bagian ini menulis kedua service pertama compose.yaml — db lalu app — menyalakan stack untuk pertama kalinya, lalu membuktikan lewat log, curl, dan psql bahwa migrasi benar-benar terjadi sendiri dan API benar-benar melayani. Service ketiga, test, sengaja ditunda ke Bagian 5 agar tiap berkas compose.yaml lahir bersama bagian yang membutuhkannya.
Service db #
Inilah seluruh definisi database kita di compose.yaml:
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-stoppedBersama blok volumes di level teratas berkas yang mendeklarasikan volume bernama itu:
volumes:
db-data:Mari kita telaah lima kunci terpentingnya:
image: postgres:18-alpine— PostgreSQL 18, rilis mayor terbaru saat course ini ditulis, dalam varian Alpine yang ramping. Menariknya, service ini tidak punya kuncibuild: image resmi ditarik langsung dari Docker Hub, dan Compose memakai kebijakan pull defaultmissing— tarik hanya bila belum ada di lokal — persis pembahasan Image, Build, dan Pull.- Tiga variabel
POSTGRES_*—POSTGRES_USER,POSTGRES_PASSWORD, danPOSTGRES_DBadalah kontrak entrypoint image postgres: mereka memberi tahu skrip inisialisasi user, password, dan database apa yang harus dibuat saat cluster pertama kali terbentuk. volumes: - db-data:/var/lib/postgresql/data— direktori data PostgreSQL ditumpangkan ke volume bernamadb-data, yang dideklarasikan di blok top-level paling bawah berkas. Compose otomatis memberinya prefix nama proyek sehingga nama aslinyakatalog_db-data— mesin-mesin ini dibahas Volume dan Bind Mount dan Persistensi Database.healthcheckdenganpg_isready— setiap 2 detik container menjalankanpg_isready -U katalog -d katalog, alat bawaan PostgreSQL yang memeriksa apakah server menerima koneksi.retries: 15denganinterval: 2smemberi jendela 30 detik untuk startup — angka jauh lebih gesit daripada default 30 detiknya, karena yang kita tunggu adalah inisialisasi perdana yang biasanya selesai dalam hitungan detik; semantik lengkapnya ada di Healthcheck dan Service Readiness.restart: unless-stopped— bila containerdbmati karena crash atau host reboot, Docker membangunkannya kembali; hanya penghentian manual yang dihormati — perilaku tabel Restart Policy.
Service app #
Giliran aplikasi yang menyambung:
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-stoppedMari kita telaah empat keputusan di dalamnya:
PGHOST: db— nama host database adalah nama servicenya. Compose mendaftarkan setiap service ke DNS jaringan stack, sehinggadbdiselesaikan menjadi alamat IP container PostgreSQL — pembahasan penuhnya menjadi agenda Bagian 4. Aplikasi tidak pernah tahu IP berapa pun, dan IP yang berubah setelahdown/uptidak menjadi urusannya.- Kutip ganda di nilai YAML —
PGPORT: "5432"dikutip agar tetap dibaca string, karena environment container memang harus berupa teks; entriports: - "8080:8080"dikutip karena tanpa kutip parser YAML menafsirkan polaangka:angkasebagai angka sexagesimal basis 60 — jebakan yang dibedah Struktur compose.yaml. Mengutip nilai yang berbentuk angka atau mengandung tanda titik dua adalah kebiasaan murah hari ini yang mencegah kejutan mahal besok. depends_onberyaratservice_healthy— Compose tidak sekadar menyalakandblebih dulu; ia menunggu sampai healthcheckpg_isreadybenar-benar lulus baru melanjutkan keapp. Ini lapisan kesiapan pertama. Lapisan kedua ada di dalam aplikasi sendiri:Open()dari Bagian 1 tetap mencoba ulang sepuluh kali berjeda setengah detik. Dua lapisan itu saling menutup — healthcheck bisa lulus sesaat sebelum server penuh siap, dan retry aplikasi menampung sisa waktunya — persis anjuran berlapis di depends_on dan Startup Order dan Startup dan Dependency.- Password lewat interpolasi
${POSTGRES_PASSWORD:?wajib diset dari .env}— Compose membaca variabel dari.env, dan pola:?pesanmembuat stack menolak menyala dengan pesan itu bila variabel kosong, ketimbang gagal misterius di tengah jalan — fitur interpolasi dari Variable Interpolation. Perlakuan password ini punya tiga tingkat ketat yang menaik: nilai default di Dockerfile untuk eksperimen kilat,.envuntuk mesin pengembangan, dan di produksi rahasia pindah ke secret manager atau Docker secrets — bukan environment variable — sebagaimana dibahas Secret Management dan Config dan Secret.
Dua kunci sisanya menyusun cerita: build: . memberi tahu Compose bahwa image dibangun dari Dockerfile Bagian 2 saat dibutuhkan, image: katalog-app:1.0 menamai hasilnya agar bisa dirujuk, dan ports: - "8080:8080" adalah satu-satunya pintu stack ini ke dunia luar.
Menyalakan Stack #
Ketiga berkas itu kini saling mengunci. Password ditulis sekali ke .env, lalu stack dinyalakan:
$ 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 — seluruh langkahnya CACHED karena image katalog-app:1.0 sudah dibangun pada Bagian 2. Tiga baris ✔ adalah urutan kesiapan yang kita rancang: network dibuat, lalu Compose menunggu katalog-db-1 sampai berstatus Healthy — angka 9.4 detik itu termasuk tarik image postgres, inisialisasi cluster perdana, dan healthcheck yang lulus — baru katalog-app-1 dinyalakan. Migrasi yang dijanjikan Bagian 1 bisa disaksikan dari log:
$ docker compose logs appOutput yang Dihasilkan:
katalog-app-1 | 2026/10/13 14:43:20 migrasi 001_create_items.sql diterapkan
katalog-app-1 | 2026/10/13 14:43:20 server mendengarkan di :8080Dua baris ini menceritakan startup yang sehat: migrasi diterapkan, baru server mendengarkan — tidak ada baris error, tidak ada retry koneksi. Sekarang kita ketuk keempat pintu yang tersisa. Pertama kesehatan:
$ curl -i http://localhost:8080/healthzOutput yang Dihasilkan:
HTTP/1.1 200 OK
Content-Type: application/json
Date: Tue, 13 Oct 2026 14:44:01 GMT
Content-Length: 16
{"status":"ok"}Kedua, tabel pencatat migrasi di dalam database — dibaca lewat psql yang sudah ada di dalam container, tanpa perlu klien terpasang di host:
$ docker compose exec db psql -U katalog -d katalog -c 'SELECT version, applied_at FROM schema_migrations;'Output yang Dihasilkan:
version | applied_at
----------------------+-------------------------------
001_create_items.sql | 2026-10-13 14:43:20.164951+00
(1 row)Baris tunggal itu bukti migrate() bekerja: nama berkas tercatat di schema_migrations dengan waktu terap yang berselisih detik dari log aplikasi di atas. Ketiga, item pertama lewat pintu yang sama seperti di halaman overview:
$ 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"}Dan keempat, item itu dibaca kembali dari jalur detail:
$ curl -s http://localhost:8080/api/items/1Output yang Dihasilkan:
{"id":1,"name":"Kopi Torabika","note":"favorit pelanggan","created_at":"2026-10-13T14:44:01.035749Z"}id yang sama, created_at yang sama — baris yang sama, ditulis sekali lewat INSERT dan dibaca berkali-kali lewat SELECT tanpa container app menyimpan apa pun. Sebelum menutup bagian, ada satu pemeriksaan yang sebaiknya kita lakukan justru untuk menyadari batasnya:
$ docker inspect katalog-app-1 --format '{{range .Config.Env}}{{println .}}{{end}}'Output yang Dihasilkan:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PGHOST=db
PGPORT=5432
PGUSER=katalog
PGPASSWORD=rahasia123
PGDATABASE=katalogStack hidup dengan urutan yang kita tulis, migrasi terjadi tanpa disuruh dua kali, dan keempat endpoint menjawab. Tetapi dua pertanyaan besar belum terjawab: bagaimana tepatnya nama db berubah menjadi alamat yang bisa dihubungi app, dan apa yang terjadi pada baris Kopi Torabika itu bila stack kita bongkar total? Bagian berikutnya menjawab keduanya sekaligus — membuka network katalog_default untuk melihat DNS internalnya, lalu menjalankan ritual down dan up untuk membuktikan bahwa volume benar-benar menjaga data kita.
Referensi #
- PostgreSQL di Docker Hub
— kontrak variabel
POSTGRES_*, perilaku inisialisasi perdana, dan path data/var/lib/postgresql/data. - Compose file reference
— kunci
healthcheck,depends_ondengancondition, dan deklarasivolumes. - Compose Specification
— definisi interpolasi
${VAR:?error}yang dipakai untuk password.
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)
}
})
}