Stack yang menyala pada Bagian 3 masih menyimpan dua sulap yang belum kita buka kainnya. Sulap pertama: app menuliskan PGHOST=db seolah db adalah nama host yang wajar — padahal tidak ada entri DNS di host mana pun yang bernama demikian. Sulap kedua: Kopi Torabika tertulis di database, tetapi database itu hidup di dalam container — dan container, sebagaimana kita tahu dari Konsep Persistence, tidak dirancang untuk awet. Bagian ini membongkar kedua sulap itu dengan perintah nyata: melihat network yang dibuat Compose, membuktikan DNS internal yang menerjemahkan db menjadi alamat, menjelaskan mengapa database sengaja tanpa port publik, lalu menjalankan ritual down dan up untuk membuktikan bahwa data kita bertahan melewati kematian container.
Network yang Dibuat Compose Secara Diam-diam #
Tidak satu baris pun di compose.yaml kita menyebut kata network — namun docker compose up membuatnya juga. Namanya katalog_default: nama dari kunci name: katalog di bagian atas berkas, ditambah suffix _default. Isi daemon kita kini:
$ docker network lsOutput yang Dihasilkan:
NETWORK ID NAME DRIVER SCOPE
8f2d1a9c4b7e bridge bridge local
9a7b3c5e1f2d host host local
6c1e8b4d2a9f none null local
e3a1c9f7b2d4 katalog_default bridge localEmpat network itu punya tiga cerita. Tiga baris pertama adalah jaringan bawaan Docker: bridge default (docker0 di 172.17.0.0/16), host, dan none — peta lengkapnya ada di Bridge Network. Baris keempat adalah milik kita: user-defined bridge yang dibuat Compose untuk seluruh service dalam project katalog. Perbedaan keduanya bukan formalitas — di sinilah letak sulap DNS. Default bridge tidak punya DNS internal sama sekali (warisan yang dulu dijembatani --link, kini ditinggalkan), sedangkan setiap user-defined network membawa server DNS tertanam yang menerjemahkan nama container dan nama service — persis alasan Membuat Custom Network menyarankan berpindah dari default bridge.
DNS Service di Balik Nama db #
Bukti pertama bisa dibaca langsung dari dalam container aplikasi:
$ docker compose exec app cat /etc/resolv.confOutput yang Dihasilkan:
nameserver 127.0.0.11
options ndots:0Satu baris nameserver 127.0.0.11 itu adalah kartu nama DNS tertanam Docker — hanya hidup di dalam user-defined network, dibedah tuntas di Container DNS. Setiap permintaan nama dari app ditangani oleh resolver ini, yang mengetahui seluruh container di katalog_default beserta namanya. Peta lengkap jaringan kita pun bisa dilihat dari luar:
$ docker network inspect katalog_defaultOutput yang Dihasilkan:
[
{
"Name": "katalog_default",
"Id": "e3a1c9f7b2d4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
"Created": "2026-10-13T14:43:19Z",
"Scope": "local",
"Driver": "bridge",
"EnableIPv6": false,
"IPAM": {
"Driver": "default",
"Options": null,
"Config": [
{
"Subnet": "172.18.0.0/16",
"Gateway": "172.18.0.1"
}
]
},
"Internal": false,
"Attachable": false,
"Ingress": false,
...
"Containers": {
"5f1d2c9a8b7e6f4a3d2c1b0a9e8f7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e": {
"Name": "katalog-db-1",
"EndpointID": "7a2b9c4e6d8f1a3c5e7b9d1f3a5c7e9b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a4c",
"MacAddress": "02:42:ac:12:00:02",
"IPv4Address": "172.18.0.2/16",
"IPv6Address": ""
},
"a9c3e5b7d1f4a6c8e0b2d4f6a8c0e2b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a4c6e": {
"Name": "katalog-app-1",
"EndpointID": "4b6d8f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b4d6f8a0c2e4b6d8",
"MacAddress": "02:42:ac:12:00:03",
"IPv4Address": "172.18.0.3/16",
"IPv6Address": ""
}
},
...
"Labels": {
"com.docker.compose.network": "katalog_default",
"com.docker.compose.project": "katalog"
}
}
]Output dipotong di bagian opsi teknis yang tidak kita bahas. Mari kita telaah yang tersisa:
- Subnet
172.18.0.0/16— network kita mendapat pool kedua setelah docker0 (172.17.0.0/16); pool berikutnya akan mengambil 172.19, 172.20, dan seterusnya — urutan alokasi yang dibahas di Konsep Networking Docker. Containersberisi dua anggota —katalog-db-1di 172.18.0.2 dankatalog-app-1di 172.18.0.3. Inilah tabel penerjemahan milik DNS 127.0.0.11: ketika aplikasi meminta alamatdb, jawabannya adalah 172.18.0.2.- Label Compose menautkan network ke project —
com.docker.compose.project: katalogmenjelaskan kenapadocker compose downikut membongkar network ini: ia milik project, dikelola project.
Kombinasi DNS dan label ini punya konsekuensi yang menyenangkan: alamat IP boleh bergeser kapan saja — setelah down dan up, container bisa mendapat IP baru — tetapi nama db tidak pernah berubah. Aplikasi kita sengaja hanya mengenal nama, dan itulah sebabnya tidak ada satu pun IP yang di-hardcode di source code — pola komunikasi antar container yang dibahas Komunikasi Antar Container dan Container DNS.
Database Tanpa Port Publik #
Perhatikan kembali service db pada Bagian 3: tidak ada kunci ports. Bandingkan dengan app yang menerbitkan "8080:8080" — dan lihat bagaimana Docker melaporkannya:
$ docker compose psOutput yang Dihasilkan:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
katalog-app-1 katalog-app:1.0 "/usr/local/bin/server" app 2 minutes ago Up 2 minutes (healthy) 0.0.0.0:8080->8080/tcp
katalog-db-1 postgres:18-alpine "docker-entrypoint.s…" db 2 minutes ago Up 2 minutes (healthy)Kolom PORTS pada baris app memuat pemetaan portnya; kolom yang sama pada baris db kosong. Itu bukan kelalaian melainkan keputusan keamanan: port 5432 PostgreSQL hanya bisa dijangkau dari dalam katalog_default — oleh app, dan oleh container lain yang kita masukkan ke network itu — dan tidak terlihat sama sekali dari host maupun jaringan luar. Database adalah aset paling sensitif di stack; tidak menerbitkannya mengurangi permukaan serangan persis sebagaimana dibahas Port Publishing: setiap port yang diterbitkan adalah pintu yang dijaga, dan pintu yang tidak ada tidak perlu dijaga. Bila suatu saat kita perlu alat GUI dari host, terbitkan portnya sementara sebagai pengecualian yang sadar — bukan kebiasaan.
Lalu bagaimana kita mengeksekusi psql tadi? Lewat docker compose exec db psql — perintah yang berjalan di dalam container, melewati network, tanpa satu port pun dibuka. Pola ini cukup untuk hampir semua pekerjaan harian.
Volume dan Tempat Data Sebenarnya #
Container db bisa mati, dihapus, dibangun ulang — namun docker compose down tidak menyentuh volume. Lihat daftarnya:
$ docker volume lsOutput yang Dihasilkan:
DRIVER VOLUME NAME
local katalog_db-dataVolume bernama katalog_db-data — prefix katalog_ ditambahkan Compose dari nama project — dikelola driver local di direktori volume host, terpisah dari daur hidup container mana pun yang menumpanginya. Persis inilah mesin yang dibedah Persistensi Database; sekarang kita uji dengan ritual lengkap.
Pembuktian: Data yang Lintas Kematian Container #
Ritualnya empat babak. Babak pertama, tambahkan item kedua lewat API:
$ curl -s -X POST http://localhost:8080/api/items -H 'Content-Type: application/json' -d '{"name":"Teh Melati","note":"non-kopi"}'Output yang Dihasilkan:
{"id":2,"name":"Teh Melati","note":"non-kopi","created_at":"2026-10-13T14:44:01.041777Z"}Babak kedua, catat isi tabel sebelum pembongkaran:
$ docker compose exec db psql -U katalog -d katalog -c 'SELECT id, name, note, created_at FROM items ORDER BY id;'Output yang Dihasilkan:
id | name | note | created_at
----+---------------+-------------------+-------------------------------
1 | Kopi Torabika | favorit pelanggan | 2026-10-13 14:44:01.035749+00
2 | Teh Melati | non-kopi | 2026-10-13 14:44:01.041777+00
(2 rows)Babak ketiga — bongkar stack total:
$ docker compose downOutput yang Dihasilkan:
[+] Running 3/3
✔ Container katalog-app-1 Removed 0.5s
✔ Container katalog-db-1 Removed 1.9s
✔ Network katalog_default Removed 0.2sBacalah output itu dengan teliti, karena di situlah letak ujiannya: dua container dihapus, network dihapus — dan tidak ada satu baris pun tentang volume. docker volume ls sesudahnya masih menampilkan katalog_db-data, utuh. Babak keempat, bangkitkan semuanya:
$ docker compose up -dOutput yang Dihasilkan:
[+] Running 3/3
✔ Network katalog_default Created 0.2s
✔ Container katalog-db-1 Healthy 9.2s
✔ Container katalog-app-1 Started 1.0sNetwork dibuat ulang, db menunggu sehat kembali, app menyala. Log aplikasi membuktikan sisi kedua persistensi — tabel schema_migrations juga ikut bertahan, sehingga migrasi tidak diulang:
$ docker compose logs appOutput yang Dihasilkan:
katalog-app-1 | 2026/10/13 14:44:02 migrasi 001_create_items.sql sudah diterapkan, dilewati
katalog-app-1 | 2026/10/13 14:44:02 server mendengarkan di :8080Dan momen kebenaran terakhir:
$ docker compose exec db psql -U katalog -d katalog -c 'SELECT id, name, note, created_at FROM items ORDER BY id;'Output yang Dihasilkan:
id | name | note | created_at
----+---------------+-------------------+-------------------------------
1 | Kopi Torabika | favorit pelanggan | 2026-10-13 14:44:01.035749+00
2 | Teh Melati | non-kopi | 2026-10-13 14:44:01.041777+00
(2 rows)Dua baris yang sama, dengan created_at yang sama sampai mikrodetiknya — ini bukan data yang dibuat ulang, melainkan baris yang sama yang menunggu kita di volume selama stack tidak ada. Dua sulap bagian ini kini bukan sulap lagi: nama db hidup berkat DNS di user-defined network yang dibuat ulang tiap up, dan data hidup berkat volume yang tidak ikut down.
Stack kita kini lulus uji hidup: menyala berurutan, saling menemukan tanpa alamat yang di-hardcode, dan mengingat datanya melewati pembongkaran total. Satu janji terakhir dari halaman overview belum ditebus — bahwa proyek ini “diuji”, bukan sekadar “dicoba”. Bagian penutup membayar janji itu: sebuah service Compose berprofile test yang mengangkat container golang, menembak keempat endpoint lewat HTTP persis seperti klien sungguhan, dan melaporkan lulus atau gagal dengan exit code yang bisa dipercaya mesin CI.
Referensi #
- Docker container networking
— embedded DNS
127.0.0.11, user-defined bridge, dan perilaku resolusi nama. - Manage data in Docker
— named volume, driver
local, dan daur hidupnya terhadap container. - Docker Compose CLI
— referensi
docker compose downbeserta flag-v.
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)
}
})
}