Dockerfile dan compose.yaml adalah bagian akhir dari cerita ini; bagian awalnya adalah aplikasi yang memang dirancang untuk hidup di dalam container. Aplikasi sembarangan tidak akan nyaman di container: ia harus membaca konfigurasi dari environment variable karena berkas konfigurasi tidak ikut terkirim ke image, tidak boleh meng-hardcode alamat database karena alamat itu berubah setiap kali stack menyala, harus siap menerima kegagalan koneksi sesaat karena tetangganya belum tentu siap, dan harus mati dengan sopan saat diberi sinyal berakhir. Bagian ini membaca source code proyek — tiga berkas Go, satu berkas SQL — dan membedah keputusan desainnya satu per satu, karena setiap barisnya akan berbuah di Dockerfile Bagian 2 dan compose.yaml Bagian 3.
Peta Arsitektur #
Sebelum membaca kode, mari lihat bentuk keseluruhan sistem yang kita bangun:
Diagram ini memuat seluruh keputusan arsitektur proyek dalam empat kotak. Klien hanya berbicara dengan app lewat port 8080 yang diterbitkan ke host; app berbicara dengan db lewat port 5432 yang tidak pernah keluar dari jaringan internal; dan db menumpangkan seluruh cluster-nya di volume katalog_db-data milik host. Pembagian kerjanya tegas — persis pembedaan stateless dan stateful dari Arsitektur Multi-Container: app tidak menyimpan apa pun sehingga boleh dibuang dan dibangun ulang kapan saja, sementara seluruh memori sistem hidup di db dan bertahan di volume. Pola aplikasi-ke-database seperti ini juga yang dibedah pada Application dan Database; proyek kita mengambil pola itu dan melengkapi detailnya satu per satu.
Peta Endpoint dan Routing #
Seluruh endpoint dideklarasikan di satu fungsi kecil dalam main.go:
// 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
}Mari kita telaah tiga hal dari sembilan baris ini:
- Metode dan path menjadi satu string —
"GET /healthz"bukan sekadar path;ServeMuxversi Go 1.22 ke atas mencocokkan pasangan metode dan jalur sekaligus. PermintaanDELETE /api/itemstidak akan tersesat ke handler mana pun, melainkan ditolak otomatis dengan405 Method Not Allowed— perilaku yang kita dapat gratis tanpa satu baris validasi. {id}adalah wildcard bernama — pola"GET /api/items/{id}"menangkap segmen jalur terakhir, dan handler mengambilnya lewatr.PathValue("id"). Satu deklarasi ini yang membuatGET /api/items/1danGET /api/items/999mendarat di handler yang sama dengan nilai berbeda.- Handler menerima
*sql.DBlewat closure — setiap handler dibungkus bersama koneksi database tanpa variabel global sedikit pun. Fungsiroutesmengembalikanhttp.Handlersiap pakai, sehingga menyusun ulang peta endpoint cukup menyunting satu tempat ini.
Skema Database dan Migrasi Idempoten #
Satu-satunya berkas SQL di proyek ini mendefinisikan tabel utama:
-- 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()
);Empat kolomnya sengaja sederhana. id BIGSERIAL PRIMARY KEY adalah bilangan 64-bit yang nilainya ditarik dari sequence otomatis — itulah asal angka 1 dan 2 pada respons POST kita. created_at TIMESTAMPTZ DEFAULT now() meminta PostgreSQL mengisi waktu saat baris dibuat, lengkap dengan zona waktu, sehingga aplikasi tidak perlu mencatat jam sendiri. Klausa IF NOT EXISTS adalah kunci idempotensi: skrip ini boleh dijalankan berulang tanpa merusak tabel yang sudah ada.
Yang menjalankan berkas ini adalah fungsi migrate() di database.go, dan desainnya melengkapi IF NOT EXISTS dengan dua lapis pengaman lain. Pertama, sebelum membaca direktori migrations/, fungsi membuat tabel schema_migrations yang mencatat nama berkas yang sudah diterapkan; berkas yang namanya sudah tercatat dilewati dengan pesan log tersendiri. Kedua, setiap berkas dijalankan dalam satu transaksi bersama pencatatan versinya — bila CREATE TABLE gagal di tengah jalan, pencatatan versinya ikut dibatalkan, sehingga mustahil ada versi tercatat yang skemanya tidak benar-benar diterapkan. Gabungan ketiganya membuat migrasi aman dijalankan setiap kali container menyala: build pertama menerapkan skema, restart berikutnya melewatinya. Pola tabel pencatat versi ini sendiri dibahas tuntas pada Migrasi Versi Database, dan alasan mengapa inisialisasi lewat aplikasi — bukan manual — berhubungan dengan Inisialisasi Database.
Koneksi Database dengan Retry #
Fungsi Open() di database.go tidak sekadar membuka koneksi — ia memastikan database benar-benar hidup sebelum aplikasi melangkah:
// 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)
}Mari kita telaah tiga keputusan di dalamnya:
sql.Opentidak benar-benar menyambung — panggilan itu hanya memvalidasi DSN dan menyiapkan pool; koneksi sungguhan baru terjadi saatPingContextmengirim querySELECT 1ke server. Tanpa ping, aplikasi akan menganggap dirinya sehat padahal database tidak pernah dijawab — jebakan klasikdatabase/sqlyang ditutup oleh satu baris ini.- Sepuluh percobaan berjeda 500 milidetik — total lima detik kesabaran untuk menutup celah startup. Container database bisa saja sudah “menyala” tetapi masih menyelesaikan inisialisasi internalnya;
pg_isreadydi sisi Compose membantu, namun lapisan retry di aplikasi memastikan tidak ada race kecil yang lolos. selectdengan dua cabang — selama menunggu jeda, fungsi mendengarkan dua sinyal sekaligus:ctx.Done()yang menyala saat aplikasi menerima SIGTERM, dantime.Afteruntuk jeda normal. Bila stack dimatikan saat sedang retry, aplikasi langsung menyerah daripada membuat Docker menunggu masa tenggang penuh — kelak pasangan sinyal ini juga yang menghentikan HTTP server dengan sopan dimain.
Alur POST dari Ujung ke Ujung #
Dengan routing, skema, dan koneksi di tangan, mari lacak satu request POST /api/items dari keyboard sampai baris database — enam langkah yang masing-masing kita sudah kenali bentuknya:
- Klien mengirim request —
curlmenulis headerContent-Type: application/jsondan body{"name":"Kopi Torabika","note":"favorit pelanggan"}kelocalhost:8080; Docker meneruskan porta itu ke containerapp. - Router mencocokkan pola —
ServeMuxmencocokkan string"POST /api/items"secara persis dan menyerahkan kehandleCreateItem; method lain di jalur yang sama tidak pernah sampai ke sini. - Validasi menyaring body — body didekode JSON lewat
io.LimitReadersetengah megabyte agar request raksasa tidak menghabiskan memori;namedipangkas spasinya dan wajib tidak kosong;notedihitung per karakter UTF-8 dan ditolak bila melebihi 200 — dua tolakan ini kelak diuji pada Bagian 5. - Database menyimpan sekaligus membalas — satu pernyataan
INSERT INTO items (name, note) VALUES ($1, $2) RETURNING id, name, note, created_atmenulis baris dan membaca hasilnya dalam satu round-trip;iddancreated_atdiisi PostgreSQL, bukan aplikasi. - Respons dirakit —
writeJSONmengirim status201 CreateddenganContent-Type: application/jsondan body item lengkap; itulah baris JSON yang kita terima pada bagian Hasil Akhir di halaman overview. - Data menetap — baris itu kini milik volume
db-datalewat containerdb; setiapGET /api/itemsdanGET /api/items/{id}berikutnya membacanya kembali tanpa containerappmenyimpan apa pun.
Peran Berkas dan Tempat Hidupnya #
Satu pertanyaan penutup yang sering mengejutkan pemula: setelah proyek berjalan, di mana sebenarnya tiap berkas hidup? Jawabannya menegaskan batas antara mesin build, image, container, dan host:
| Berkas | Peran | Hidup di mana |
|---|---|---|
main.go, database.go, handlers.go | Source aplikasi: konfigurasi, routing, koneksi, migrasi, handler | Dikompilasi menjadi /usr/local/bin/server di dalam image |
go.mod, go.sum | Manifest dan checksum dependensi | Hanya tahap builder — tidak ikut ke image akhir |
migrations/001_create_items.sql | Skema awal tabel items | Disalin ke /app/migrations/ di image, dibaca saat startup |
tests/integration_test.go | Test black-box HTTP | Di host, di-bind mount ke service test — tidak masuk image |
Dockerfile, .dockerignore | Resep dan penyaring build | Di host, hanya relevan saat docker build |
compose.yaml | Deklarasi stack app, db, test | Di host, dibaca Docker Compose setiap perintah |
.env | Password untuk interpolasi Compose | Di host, dibaca Compose — tidak pernah terkirim ke builder |
Desain sudah utuh di atas kertas: aplikasi tahu cara membaca environment, menyapa database dengan sabar, dan memigrasikan skemanya sendiri. Tiga keputusan itu kini menuntut satu wadah yang tepat — image yang memuat binary tanpa membawa toolchain, berkas migrasi tanpa membawa source code. Bagian berikutnya menulis Dockerfile multi-stage yang memenuhi tuntutan itu, lalu membuktikan dengan dua build berturut-turut bahwa urutan penulisannya bukan kebetulan estetis melainkan strategi cache.
Referensi #
- Dokumentasi Go
— paket
net/http,database/sql, dan pola routingServeMuxyang dipakai aplikasi ini. - Dokumentasi PostgreSQL
— tipe
BIGSERIALdanTIMESTAMPTZserta perilakuCREATE TABLE IF NOT EXISTS. - Docker container networking
— model jaringan yang mendasari arsitektur
appdandb.
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)
}
})
}