Lapisan data adalah bagian aplikasi yang paling sering berubah sekaligus paling mudah rusak diam-diam: satu WHERE yang hilang mengubah hasil query tanpa membatalkan kompilasi. Test yang menguji lapisan ini punya tantangan tersendiri — query asli butuh database asli, sementara go test harus tetap hijau di mesin tanpa PostgreSQL. Chapter ini menyusun strateginya berlapis: unit test dengan fake di memori dan berkas sementara, integrasi test yang melompat rapi ketika variabel lingkungan kosong, lalu pola pembersihan data agar test integrasi tidak saling menimpa.
Hirarki Test Lapisan Data #
Test lapisan data tersebar di tiga tingkat dengan biaya dan kecepatan yang berbeda:
| Tingkat | Yang diuji | Kecepatan | Butuh database |
|---|---|---|---|
| Unit | Logika pemanggilan repository dengan fake | Milidetik | Tidak |
| Integration | Query SQL asli terhadap database uji | Detik | Ya |
| End-to-end | Seluruh aplikasi lewat HTTP | Menit | Ya |
Unit test berjalan setiap kali menyimpan berkas; integration test dijalankan terjadwal atau sebelum rilis; end-to-end menjaga alur kritis. Semua test pada chapter ini berada di dua tingkat pertama — versi HTTP lengkapnya ada pada proyek REST API PostgreSQL .
Unit Test dengan Fake Berbasis Berkas #
Fake tidak harus berupa map: berkas JSON di folder sementara memberi kejutan yang lebih dekat ke database nyata — data bertahan antar panggilan, dan bisa “hilang”. Berkas filestore-demo.go membangun FileStore semacam itu lengkap dengan method CRUD dan demo main:
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"slices"
"sync"
)
// ErrNotFound adalah error domain untuk data yang tidak ditemukan.
var ErrNotFound = errors.New("pelanggan tidak ditemukan")
// Pelanggan adalah struct domain murni tanpa dependensi database.
type Pelanggan struct {
ID int `json:"id"`
Nama string `json:"nama"`
Email string `json:"email"`
}
// FileStore menyimpan pelanggan pada berkas JSON.
// Tipe ini menggantikan database pada unit test: test membuat berkas
// di t.TempDir() sehingga setiap test mendapat folder bersih sendiri.
type FileStore struct {
mu sync.Mutex
berkas string
berikut int
}
func NewFileStore(berkas string) *FileStore {
return &FileStore{berkas: berkas, berikut: 1}
}
// muat membaca seluruh pelanggan dari berkas; berkas yang belum ada
// berarti penyimpanan masih kosong, bukan error.
func (s *FileStore) muat() ([]Pelanggan, error) {
data, err := os.ReadFile(s.berkas)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var daftar []Pelanggan
if err := json.Unmarshal(data, &daftar); err != nil {
return nil, err
}
return daftar, nil
}
func (s *FileStore) simpan(daftar []Pelanggan) error {
data, err := json.MarshalIndent(daftar, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.berkas, data, 0o600)
}
func (s *FileStore) Create(nama, email string) (Pelanggan, error) {
s.mu.Lock()
defer s.mu.Unlock()
daftar, err := s.muat()
if err != nil {
return Pelanggan{}, err
}
p := Pelanggan{ID: s.berikut, Nama: nama, Email: email}
daftar = append(daftar, p)
if err := s.simpan(daftar); err != nil {
return Pelanggan{}, err
}
s.berikut++
return p, nil
}
func (s *FileStore) Get(id int) (Pelanggan, error) {
s.mu.Lock()
defer s.mu.Unlock()
daftar, err := s.muat()
if err != nil {
return Pelanggan{}, err
}
for _, p := range daftar {
if p.ID == id {
return p, nil
}
}
return Pelanggan{}, ErrNotFound
}
func (s *FileStore) List() ([]Pelanggan, error) {
s.mu.Lock()
defer s.mu.Unlock()
daftar, err := s.muat()
if err != nil {
return nil, err
}
slices.SortFunc(daftar, func(a, b Pelanggan) int {
return a.ID - b.ID
})
return daftar, nil
}
func (s *FileStore) Delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
daftar, err := s.muat()
if err != nil {
return err
}
for i, p := range daftar {
if p.ID == id {
daftar = slices.Delete(daftar, i, i+1)
return s.simpan(daftar)
}
}
return ErrNotFound
}
func main() {
// Demo memakai folder sementara supaya tidak meninggalkan jejak.
// Pada test, folder ini digantikan t.TempDir().
direktori, err := os.MkdirTemp("", "filestore-demo-*")
if err != nil {
fmt.Println("galat:", err)
return
}
defer os.RemoveAll(direktori)
store := NewFileStore(direktori + "/pelanggan.json")
p, err := store.Create("Budi", "[email protected]")
if err != nil {
fmt.Println("galat:", err)
return
}
fmt.Printf("buat : ID=%d %s <%s>\n", p.ID, p.Nama, p.Email)
if _, err := store.Create("Siti", "[email protected]"); err != nil {
fmt.Println("galat:", err)
return
}
daftar, _ := store.List()
for _, pl := range daftar {
fmt.Printf("list : ID=%d %s <%s>\n", pl.ID, pl.Nama, pl.Email)
}
if err := store.Delete(p.ID); err != nil {
fmt.Println("galat:", err)
return
}
fmt.Println("hapus : OK")
if _, err := store.Get(p.ID); err != nil {
fmt.Println("get(1):", err)
}
}Jalankan demonya dari direktori chapter:
go run filestore-demo.goOutput yang Dihasilkan:
buat : ID=1 Budi <[email protected]>
list : ID=1 Budi <[email protected]>
list : ID=2 Siti <[email protected]>
hapus : OK
get(1): pelanggan tidak ditemukanMari kita telaah bagian-bagian penting berkas tersebut:
- Baris 25-29 (
type FileStore): state hanya berupa path berkas dan penghitung ID, dilindungisync.Mutexkarena method dipanggil dari goroutine test yang berbeda. - Baris 37-48 (
muat): berkas yang belum ada dianggap penyimpanan kosong lewaterrors.Is(err, os.ErrNotExist)— bukan error. Pembandingan error selalu memakaierrors.Is, pola dari chapter errors.Is dan errors.As . - Baris 52-58 (
simpan): seluruh daftar ditulis ulang denganjson.MarshalIndent; strategi sederhana yang cukup untuk data test. - Baris 60-74 (
Create): menambah pelanggan lalu menyimpan — perubahan langsung terlihat pada panggilanmuatberikutnya, seperti row yang tertulis ke tabel.
Test Table-Driven dengan t.TempDir #
Berkas filestore_test.go menguji FileStore secara table-driven. Setiap kasus memanggil t.TempDir() — folder unik per test yang dihapus otomatis Go saat test selesai, sehingga kasus tidak berbagi berkas dan tidak meninggalkan jejak di disk:
package main
import (
"database/sql"
"errors"
"os"
"path/filepath"
"testing"
)
// TestFileStore menguji FileStore secara table-driven.
// Setiap kasus memakai berkas sendiri di t.TempDir() sehingga
// test tidak berbagi data dan gagal karena bentrok.
func TestFileStore(t *testing.T) {
tests := []struct {
nama string
jalankan func(t *testing.T)
}{
{
nama: "Create lalu Get mengembalikan pelanggan sama",
jalankan: func(t *testing.T) {
store := NewFileStore(filepath.Join(t.TempDir(), "pelanggan.json"))
dibuat, err := store.Create("Budi", "[email protected]")
if err != nil {
t.Fatalf("Create gagal: %v", err)
}
diambil, err := store.Get(dibuat.ID)
if err != nil {
t.Fatalf("Get gagal: %v", err)
}
if diambil != dibuat {
t.Errorf("Get = %+v; ingin %+v", diambil, dibuat)
}
},
},
{
nama: "Get ID yang tidak ada mengembalikan ErrNotFound",
jalankan: func(t *testing.T) {
store := NewFileStore(filepath.Join(t.TempDir(), "pelanggan.json"))
_, err := store.Get(99)
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v; ingin ErrNotFound", err)
}
},
},
{
nama: "List terurut naik berdasarkan ID",
jalankan: func(t *testing.T) {
store := NewFileStore(filepath.Join(t.TempDir(), "pelanggan.json"))
store.Create("Budi", "[email protected]")
store.Create("Siti", "[email protected]")
store.Create("Ayu", "[email protected]")
daftar, err := store.List()
if err != nil {
t.Fatalf("List gagal: %v", err)
}
for i, p := range daftar {
if p.ID != i+1 {
t.Errorf("daftar[%d].ID = %d; ingin %d", i, p.ID, i+1)
}
}
},
},
{
nama: "Delete menghapus data dari penyimpanan",
jalankan: func(t *testing.T) {
store := NewFileStore(filepath.Join(t.TempDir(), "pelanggan.json"))
dibuat, _ := store.Create("Budi", "[email protected]")
if err := store.Delete(dibuat.ID); err != nil {
t.Fatalf("Delete gagal: %v", err)
}
if _, err := store.Get(dibuat.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("Get setelah Delete = %v; ingin ErrNotFound", err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.nama, func(t *testing.T) {
tt.jalankan(t)
})
}
}
// TestPostgresIntegration hanya berjalan bila TEST_DATABASE_URL diisi.
// Tanpa variabel tersebut, test ini melompat (SKIP) — bukan gagal —
// sehingga `go test` tetap hijau di lingkungan tanpa database.
func TestPostgresIntegration(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL kosong — lewati uji integrasi PostgreSQL")
}
// Driver "pgx" teregistrasi lewat blank import di proyek nyata
// (lihat proyek rest-api-postgresql); materi ini membiarkannya
// stdlib-only sehingga test dijalankan hanya di lingkungan lengkap.
db, err := sql.Open("pgx", url)
if err != nil {
t.Fatalf("buka database uji: %v", err)
}
defer db.Close()
var satu int
if err := db.QueryRow("SELECT 1").Scan(&satu); err != nil {
t.Fatalf("query pemanasan: %v", err)
}
}Jalankan seluruh test di folder chapter:
go test -v -count=1 ./...Output yang Dihasilkan:
=== RUN TestFileStore
=== RUN TestFileStore/Create_lalu_Get_mengembalikan_pelanggan_sama
=== RUN TestFileStore/Get_ID_yang_tidak_ada_mengembalikan_ErrNotFound
=== RUN TestFileStore/List_terurut_naik_berdasarkan_ID
=== RUN TestFileStore/Delete_menghapus_data_dari_penyimpanan
--- PASS: TestFileStore (0.00s)
--- PASS: TestFileStore/Create_lalu_Get_mengembalikan_pelanggan_sama (0.00s)
--- PASS: TestFileStore/Get_ID_yang_tidak_ada_mengembalikan_ErrNotFound (0.00s)
--- PASS: TestFileStore/List_terurut_naik_berdasarkan_ID (0.00s)
--- PASS: TestFileStore/Delete_menghapus_data_dari_penyimpanan (0.00s)
=== RUN TestPostgresIntegration
filestore_test.go:92: TEST_DATABASE_URL kosong — lewati uji integrasi PostgreSQL
--- SKIP: TestPostgresIntegration (0.00s)
PASSPerhatikan baris --- SKIP: go test menyatakan test tersebut melompat, bukan gagal. Konvensi ini yang membuat go test tetap hijau di mesin tanpa database — pola yang sama dipakai pada proyek REST API PostgreSQL
.
Integrasi dengan t.Skip #
Test integrasi membaca TEST_DATABASE_URL di awal dan memanggil t.Skip bila variabel tersebut kosong (baris 89-93 pada berkas test di atas). Di lingkungan lengkap, variabel itu diisi dengan connection string database uji — misalnya postgres://budi:YOUR_DATABASE_PASSWORD@localhost:5432/toko_test — lalu test menjalankan query sungguhan.
Membersihkan Data Antar Test #
Test integrasi yang berbagi tabel akan saling menimpa. Tiga pola pembersihan umum, dari paling sederhana ke paling ketat:
// Pola 1: truncate seluruh tabel di awal test.
func bersihkanTabel(t *testing.T, db *sql.DB) {
t.Helper()
if _, err := db.Exec("TRUNCATE items RESTART IDENTITY CASCADE"); err != nil {
t.Fatalf("truncate: %v", err)
}
}
// Pola 2: bungkus test dalam transaksi yang selalu di-rollback.
tx, err := db.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback() // perubahan tidak pernah permanen
// Pola 3: data unik per test, untuk test yang berjalan paralel.
nama := fmt.Sprintf("item-%s", t.Name()) // unik per testPola 1 paling mudah dibaca dan cocok untuk test yang berjalan berurutan. Pola 2 menjaga test paralel tidak saling melihat data: setiap test membungkus kerjanya dalam transaksi yang dibatalkan di akhir. Pola 3 dipakai bila test memang berbagi tabel — setiap test membuat baris dengan nama atau ID yang tidak mungkin sama.
Dengan strategi ini, test lapisan data bisa berjalan di mana saja — laptop tanpa database pun tetap hijau — sementara CI dengan PostgreSQL aktif menjalankan query aslinya. Satu lapisan terakhir menentukan kesehatan aplikasi di produksi: pengelolaan siklus hidup koneksi, pembahasan chapter penutup section ini.
Referensi #
- Package testing: T.TempDir — folder sementara per test yang dihapus otomatis.
- Package testing
—
t.Skip,t.Helper, dant.Parallel. - Tutorial: Accessing relational databases — akses database resmi dengan contoh query.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
filestore_test.go
Unduh berkaspackage main
import (
"database/sql"
"errors"
"os"
"path/filepath"
"testing"
)
// TestFileStore menguji FileStore secara table-driven.
// Setiap kasus memakai berkas sendiri di t.TempDir() sehingga
// test tidak berbagi data dan gagal karena bentrok.
func TestFileStore(t *testing.T) {
tests := []struct {
nama string
jalankan func(t *testing.T)
}{
{
nama: "Create lalu Get mengembalikan pelanggan sama",
jalankan: func(t *testing.T) {
store := NewFileStore(filepath.Join(t.TempDir(), "pelanggan.json"))
dibuat, err := store.Create("Budi", "[email protected]")
if err != nil {
t.Fatalf("Create gagal: %v", err)
}
diambil, err := store.Get(dibuat.ID)
if err != nil {
t.Fatalf("Get gagal: %v", err)
}
if diambil != dibuat {
t.Errorf("Get = %+v; ingin %+v", diambil, dibuat)
}
},
},
{
nama: "Get ID yang tidak ada mengembalikan ErrNotFound",
jalankan: func(t *testing.T) {
store := NewFileStore(filepath.Join(t.TempDir(), "pelanggan.json"))
_, err := store.Get(99)
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v; ingin ErrNotFound", err)
}
},
},
{
nama: "List terurut naik berdasarkan ID",
jalankan: func(t *testing.T) {
store := NewFileStore(filepath.Join(t.TempDir(), "pelanggan.json"))
store.Create("Budi", "[email protected]")
store.Create("Siti", "[email protected]")
store.Create("Ayu", "[email protected]")
daftar, err := store.List()
if err != nil {
t.Fatalf("List gagal: %v", err)
}
for i, p := range daftar {
if p.ID != i+1 {
t.Errorf("daftar[%d].ID = %d; ingin %d", i, p.ID, i+1)
}
}
},
},
{
nama: "Delete menghapus data dari penyimpanan",
jalankan: func(t *testing.T) {
store := NewFileStore(filepath.Join(t.TempDir(), "pelanggan.json"))
dibuat, _ := store.Create("Budi", "[email protected]")
if err := store.Delete(dibuat.ID); err != nil {
t.Fatalf("Delete gagal: %v", err)
}
if _, err := store.Get(dibuat.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("Get setelah Delete = %v; ingin ErrNotFound", err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.nama, func(t *testing.T) {
tt.jalankan(t)
})
}
}
// TestPostgresIntegration hanya berjalan bila TEST_DATABASE_URL diisi.
// Tanpa variabel tersebut, test ini melompat (SKIP) — bukan gagal —
// sehingga `go test` tetap hijau di lingkungan tanpa database.
func TestPostgresIntegration(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL kosong — lewati uji integrasi PostgreSQL")
}
// Driver "pgx" teregistrasi lewat blank import di proyek nyata
// (lihat proyek rest-api-postgresql); materi ini membiarkannya
// stdlib-only sehingga test dijalankan hanya di lingkungan lengkap.
db, err := sql.Open("pgx", url)
if err != nil {
t.Fatalf("buka database uji: %v", err)
}
defer db.Close()
var satu int
if err := db.QueryRow("SELECT 1").Scan(&satu); err != nil {
t.Fatalf("query pemanasan: %v", err)
}
}
filestore-demo.go
Unduh berkaspackage main
import (
"encoding/json"
"errors"
"fmt"
"os"
"slices"
"sync"
)
// ErrNotFound adalah error domain untuk data yang tidak ditemukan.
var ErrNotFound = errors.New("pelanggan tidak ditemukan")
// Pelanggan adalah struct domain murni tanpa dependensi database.
type Pelanggan struct {
ID int `json:"id"`
Nama string `json:"nama"`
Email string `json:"email"`
}
// FileStore menyimpan pelanggan pada berkas JSON.
// Tipe ini menggantikan database pada unit test: test membuat berkas
// di t.TempDir() sehingga setiap test mendapat folder bersih sendiri.
type FileStore struct {
mu sync.Mutex
berkas string
berikut int
}
func NewFileStore(berkas string) *FileStore {
return &FileStore{berkas: berkas, berikut: 1}
}
// muat membaca seluruh pelanggan dari berkas; berkas yang belum ada
// berarti penyimpanan masih kosong, bukan error.
func (s *FileStore) muat() ([]Pelanggan, error) {
data, err := os.ReadFile(s.berkas)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var daftar []Pelanggan
if err := json.Unmarshal(data, &daftar); err != nil {
return nil, err
}
return daftar, nil
}
func (s *FileStore) simpan(daftar []Pelanggan) error {
data, err := json.MarshalIndent(daftar, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.berkas, data, 0o600)
}
func (s *FileStore) Create(nama, email string) (Pelanggan, error) {
s.mu.Lock()
defer s.mu.Unlock()
daftar, err := s.muat()
if err != nil {
return Pelanggan{}, err
}
p := Pelanggan{ID: s.berikut, Nama: nama, Email: email}
daftar = append(daftar, p)
if err := s.simpan(daftar); err != nil {
return Pelanggan{}, err
}
s.berikut++
return p, nil
}
func (s *FileStore) Get(id int) (Pelanggan, error) {
s.mu.Lock()
defer s.mu.Unlock()
daftar, err := s.muat()
if err != nil {
return Pelanggan{}, err
}
for _, p := range daftar {
if p.ID == id {
return p, nil
}
}
return Pelanggan{}, ErrNotFound
}
func (s *FileStore) List() ([]Pelanggan, error) {
s.mu.Lock()
defer s.mu.Unlock()
daftar, err := s.muat()
if err != nil {
return nil, err
}
slices.SortFunc(daftar, func(a, b Pelanggan) int {
return a.ID - b.ID
})
return daftar, nil
}
func (s *FileStore) Delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
daftar, err := s.muat()
if err != nil {
return err
}
for i, p := range daftar {
if p.ID == id {
daftar = slices.Delete(daftar, i, i+1)
return s.simpan(daftar)
}
}
return ErrNotFound
}
func main() {
// Demo memakai folder sementara supaya tidak meninggalkan jejak.
// Pada test, folder ini digantikan t.TempDir().
direktori, err := os.MkdirTemp("", "filestore-demo-*")
if err != nil {
fmt.Println("galat:", err)
return
}
defer os.RemoveAll(direktori)
store := NewFileStore(direktori + "/pelanggan.json")
p, err := store.Create("Budi", "[email protected]")
if err != nil {
fmt.Println("galat:", err)
return
}
fmt.Printf("buat : ID=%d %s <%s>\n", p.ID, p.Nama, p.Email)
if _, err := store.Create("Siti", "[email protected]"); err != nil {
fmt.Println("galat:", err)
return
}
daftar, _ := store.List()
for _, pl := range daftar {
fmt.Printf("list : ID=%d %s <%s>\n", pl.ID, pl.Nama, pl.Email)
}
if err := store.Delete(p.ID); err != nil {
fmt.Println("galat:", err)
return
}
fmt.Println("hapus : OK")
if _, err := store.Get(p.ID); err != nil {
fmt.Println("get(1):", err)
}
}