Back to tutorial
Sampul Golang Dasar: Dari Nol hingga CLI Production-Ready

Contents

Table of contents

  1. 01Menyiapkan Go, Toolchain, dan Module
  2. 02Variabel, Konstanta, Tipe, String, dan Rune
  3. 03Alur Kontrol di Go
  4. 04Fungsi, Pointer, Error, dan Defer
  5. 05Array, Slice, dan Map
  6. 06Struct, Method, Interface, dan Komposisi
  7. 07Package, Struktur Proyek, dan Dependensi
  8. 08File I/O, JSON, dan Persistensi CLI
  9. 09Testing, Table-Driven Tests, Coverage, dan Race Detector
  10. 10Proyek Akhir: CLI Task Persisten Berkualitas Produksi

Chapter 1 of 10

Menyiapkan Go, Toolchain, dan Module

Tujuan Pembelajaran

Setelah bab ini, Anda mampu:

  • memasang dan memverifikasi Go;
  • mengenali peran perintah utama toolchain;
  • membuat module dan program pertama;
  • menjalankan, menguji, memformat, dan membangun program;
  • memahami hubungan module, package, dan file sumber.

1. Memasang dan Memverifikasi Go

Unduh distribusi resmi yang sesuai sistem operasi, ikuti petunjuk pemasangan, lalu buka terminal baru. Verifikasi instalasi:

Code
go version
go env GOROOT GOPATH GOMOD

GOROOT menunjuk instalasi Go. GOPATH adalah workspace global untuk cache module dan alat yang dipasang; kode aplikasi modern tidak perlu ditempatkan di dalamnya. GOMOD menunjukkan file go.mod aktif atau /dev/null ketika terminal berada di luar module.

Pastikan direktori biner Go tersedia pada PATH. Jangan mengubah GOROOT secara manual kecuali Anda memang mengelola instalasi khusus.

2. Toolchain Inti

PerintahKegunaan
go run .Mengompilasi sementara dan menjalankan package utama
go build .Memeriksa kompilasi dan menghasilkan biner bila package adalah main
go test ./...Menjalankan pengujian seluruh package di module
go fmt ./...Memformat kode dengan gaya standar Go
go vet ./...Mendeteksi pola kode yang mencurigakan
go mod tidyMenyelaraskan dependensi di go.mod dan go.sum
go doc fmt.PrintlnMembaca dokumentasi simbol

Alur kerja sederhananya:

Memuat diagram…

3. Membuat Module Pertama

Module adalah unit versi dan distribusi yang berisi satu atau lebih package. Buat proyek di direktori kosong:

Code
mkdir halo-go
cd halo-go
go mod init example.com/halo-go

Perintah terakhir membuat go.mod:

Code
module example.com/halo-go

go 1.23

Direktif go mengikuti versi bahasa yang dipilih toolchain Anda dan dapat berbeda dari contoh. Untuk proyek yang diterbitkan, path module biasanya sama dengan lokasi repositori. Untuk latihan lokal, path contoh tetap valid.

Buat main.go:

Code
package main

import "fmt"

func main() {
	fmt.Println("Halo, Go!")
}

Jalankan dan bangun:

Code
go fmt ./...
go run .
go build .

package main dan fungsi main menandai program yang dapat dieksekusi. import "fmt" memakai package pustaka standar. Nama package berlaku per direktori: file .go biasa dalam satu direktori harus memakai nama package yang sama.

4. Module, Package, dan Import

Struktur kecil yang wajar:

Code
halo-go/
├── go.mod
├── main.go
└── salam/
    └── salam.go

Isi salam/salam.go:

Code
package salam

func Pesan(nama string) string {
	return "Halo, " + nama + "!"
}

Huruf awal kapital mengekspor Pesan agar dapat dipakai package lain. Ubah main.go:

Code
package main

import (
	"fmt"

	"example.com/halo-go/salam"
)

func main() {
	fmt.Println(salam.Pesan("Ayu"))
}

Import menggunakan path module ditambah subdirektori package, bukan path filesystem absolut.

5. Dependensi dan Reproduksibilitas

Ketika kode mengimpor module eksternal, perintah Go dapat mengunduh versinya dan mencatatnya di go.mod; checksum konten tersimpan di go.sum. Jalankan:

Code
go mod tidy
go test ./...

Commit go.mod dan go.sum untuk aplikasi. Jangan mengedit go.sum manual. Cache dependensi bukan bagian proyek dan tidak perlu disalin.

Kesalahan Umum

  • Menjalankan satu file dengan go run main.go pada proyek multifile. Gunakan go run . agar seluruh package dipertimbangkan.
  • Menaruh beberapa package biasa dalam satu direktori. Pisahkan package ke direktori berbeda.
  • Menganggap GOPATH sebagai lokasi wajib proyek. Module modern dapat berada di direktori mana pun.
  • Path import tidak cocok dengan module di go.mod. Gunakan path module sebagai awalan.
  • Tidak memformat kode. Jalankan go fmt ./...; format standar adalah bagian dari budaya Go.
  • Mengabaikan hasil go test atau go vet. Keduanya menangkap masalah sebelum biner didistribusikan.

Latihan

  1. Buat module example.com/profil yang mencetak nama dan pekerjaan Anda.
  2. Tambahkan package teks dengan fungsi publik Judul(s string) string, lalu panggil dari main.
  3. Jalankan go fmt ./..., go test ./..., go vet ./..., dan go build .. Catat fungsi setiap perintah.
  4. Ubah nama package pada salah satu file menjadi nama berbeda, amati galat kompilasi, lalu perbaiki.
  5. Gunakan go doc strings.ToUpper untuk membaca kontrak fungsi tanpa peramban.

Rangkuman

Go menyediakan toolchain terpadu untuk format, kompilasi, pengujian, dokumentasi, dan dependensi. go.mod menetapkan identitas module; direktori membentuk package; dan package main menghasilkan program. Siklus aman untuk proyek pemula adalah format, test, vet, lalu build.

Chapter 2 of 10

Variabel, Konstanta, Tipe, String, dan Rune

Tujuan Pembelajaran

Setelah bab ini, Anda mampu:

  • mendeklarasikan variabel dan konstanta secara idiomatis;
  • memahami tipe statis, nilai nol, konversi, dan inferensi;
  • memilih tipe numerik yang tepat;
  • mengolah teks UTF-8 dengan string, byte, dan rune.

1. Variabel dan Nilai Nol

Go bertipe statis: tipe setiap nilai diketahui saat kompilasi. Deklarasi var dapat menyertakan tipe, nilai awal, atau keduanya.

Code
package main

import "fmt"

func main() {
	var jumlah int
	var aktif bool
	var nama = "Nadia"
	usia := 21

	fmt.Println(jumlah, aktif, nama, usia)
}

Variabel tanpa initializer memperoleh zero value: 0 untuk angka, false untuk boolean, "" untuk string, dan nil untuk pointer, slice, map, channel, fungsi, serta interface.

:= adalah deklarasi singkat dan hanya berlaku di dalam fungsi. Gunakan var untuk variabel tingkat package atau ketika tipe/nilai nol perlu ditonjolkan.

Deklarasi singkat multivariabel boleh mendeklarasikan sebagian nama baru:

Code
x, y := 10, 20
x, z := 30, 40
fmt.Println(x, y, z)

Baris kedua sah karena z baru; x hanya diberi nilai baru.

2. Konstanta dan Tipe Dasar

Konstanta ditentukan saat kompilasi dan tidak dapat diubah. Konstanta dapat tidak bertipe sampai konteks menuntut tipe tertentu.

Code
package main

import "fmt"

const Pi = 3.141592653589793
const MaksPercobaan int = 3

func main() {
	var radius float64 = 2
	luas := Pi * radius * radius
	fmt.Println(luas, MaksPercobaan)
}

Tipe dasar penting:

  • bool;
  • string;
  • integer bertanda int, int8, int16, int32, int64;
  • integer tanpa tanda uint, uint8, uint16, uint32, uint64;
  • pecahan float32, float64;
  • kompleks complex64, complex128;
  • alias byte untuk uint8 dan rune untuk int32.

Gunakan int untuk hitungan umum dan indeks. Gunakan ukuran eksplisit untuk format data/protokol. Pilih float64 sebagai default pecahan, tetapi jangan memakainya untuk uang jika pembulatan desimal harus eksak; simpan satuan terkecil sebagai integer.

3. Konversi Eksplisit

Go tidak melakukan konversi numerik implisit.

Code
package main

import "fmt"

func main() {
	jumlah := 7
	rata := float64(jumlah) / 2
	fmt.Println(rata)
}

Konversi dapat kehilangan informasi: int(3.9) menjadi 3, dan konversi ke integer sempit dapat meluap sesuai representasi. Validasi rentang di batas input sebelum mengonversi.

4. String, Byte, Rune, dan UTF-8

string adalah urutan byte yang immutable. Teks Go lazimnya UTF-8, tetapi sebuah string secara teknis dapat memuat byte apa pun. len(s) menghitung byte, bukan karakter pengguna.

Memuat diagram…

Contoh yang memperlihatkan perbedaannya:

Code
package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
	s := "Go語"
	fmt.Println(len(s))
	fmt.Println(utf8.RuneCountInString(s))

	for indeksByte, r := range s {
		fmt.Printf("indeks=%d rune=%q kode=%U\n", indeksByte, r, r)
	}
}

memerlukan tiga byte UTF-8. range mendekode rune dan memberikan indeks byte awal setiap rune. Rune mewakili code point Unicode, bukan selalu satu karakter yang terlihat; grafem seperti emoji keluarga dapat terdiri dari beberapa rune.

Indeks langsung menghasilkan byte:

Code
package main

import "fmt"

func main() {
	s := "é"
	fmt.Printf("%x\n", s[0])
	r := []rune(s)
	fmt.Printf("%c\n", r[0])
}

Konversi []byte(s) cocok untuk data mentah/UTF-8; []rune(s) cocok ketika operasi memang berbasis code point. Keduanya mengalokasikan salinan yang dapat diubah.

String literal berinterpretasi memakai tanda petik dan escape seperti \n. Raw string memakai backtick dan mempertahankan baris serta backslash:

Code
package main

import "fmt"

func main() {
	path := `C:\data\laporan.txt`
	baris := "pertama\nkedua"
	fmt.Println(path)
	fmt.Println(baris)
}

Kesalahan Umum

  • Memakai := di tingkat package. Gunakan var atau const.
  • Mencampur tipe angka tanpa konversi. Konversikan secara eksplisit dan periksa potensi kehilangan data.
  • Menganggap len(string) menghitung huruf. len menghitung byte.
  • Mengambil s[i] dan berharap rune. Hasilnya byte; gunakan range atau []rune bila perlu.
  • Menganggap rune sama dengan grafem visual. Rune adalah code point, bukan seluruh karakter gabungan.
  • Membuat variabel yang tidak digunakan. Go menolak variabel lokal dan import yang tidak dipakai.
  • Melakukan shadowing tanpa sadar. x := dalam blok dalam dapat membuat variabel baru, bukan memperbarui variabel luar.

Latihan

  1. Deklarasikan profil pengguna memakai string, int, dan bool; cetak zero value sebelum pemberian nilai.
  2. Hitung luas lingkaran dengan konstanta tak bertipe dan float64.
  3. Untuk string "Indonesia 🇮🇩", tampilkan jumlah byte, jumlah rune, indeks byte, dan setiap rune.
  4. Buat program yang menerima jumlah detik sebagai int dan menghitung jam, menit, serta detik.
  5. Tunjukkan satu konversi numerik yang kehilangan pecahan, lalu jelaskan hasilnya.

Rangkuman

Deklarasi Go menyeimbangkan kejelasan var dengan ringkasnya :=. Konstanta dapat tetap tidak bertipe hingga digunakan, sedangkan konversi antarangka selalu eksplisit. String menyimpan byte; rune merepresentasikan code point Unicode. Pilih operasi byte, rune, atau grafem sesuai kebutuhan, jangan berdasarkan asumsi bahwa semuanya setara dengan “karakter”.

Chapter 3 of 10

Alur Kontrol di Go

Tujuan Pembelajaran

Setelah bab ini, Anda mampu:

  • membuat percabangan dengan if dan switch;
  • menulis berbagai bentuk perulangan for;
  • mengiterasi koleksi dengan range;
  • memakai break dan continue secara terukur;
  • menghindari jebakan scope dan kondisi batas.

1. if, else if, dan Scope

Kondisi if harus bernilai bool; Go tidak menganggap 0, string kosong, atau nil sebagai false secara implisit. Tanda kurung tidak diperlukan, sedangkan kurung kurawal wajib.

Code
package main

import "fmt"

func kategori(nilai int) string {
	if nilai >= 85 {
		return "A"
	} else if nilai >= 70 {
		return "B"
	}
	return "C"
}

func main() {
	fmt.Println(kategori(78))
}

Karena cabang pertama sudah return, else sebenarnya dapat dihilangkan untuk mengurangi indentasi.

if dapat memiliki pernyataan pendek yang scopenya terbatas pada seluruh rantai if:

Code
package main

import (
	"fmt"
	"strconv"
)

func main() {
	if n, err := strconv.Atoi("42"); err != nil {
		fmt.Println("input tidak valid")
	} else {
		fmt.Println(n * 2)
	}
}

2. switch

switch Go berhenti otomatis setelah satu kasus cocok; tidak memerlukan break. Beberapa nilai dapat berada dalam satu case.

Code
package main

import "fmt"

func jenisHari(hari string) string {
	switch hari {
	case "Sabtu", "Minggu":
		return "akhir pekan"
	case "Senin", "Selasa", "Rabu", "Kamis", "Jumat":
		return "hari kerja"
	default:
		return "tidak dikenal"
	}
}

func main() {
	fmt.Println(jenisHari("Sabtu"))
}

Switch tanpa ekspresi setara dengan switch true dan berguna untuk kondisi berurutan:

Code
func suhuLabel(c float64) string {
	switch {
	case c < 0:
		return "beku"
	case c < 30:
		return "sedang"
	default:
		return "panas"
	}
}

Urutan kasus penting: kasus pertama yang benar dipilih. fallthrough ada, tetapi jarang dibutuhkan karena mengeksekusi kasus berikutnya tanpa memeriksa kondisinya.

3. Satu Kata Kunci Perulangan: for

Go memakai for untuk seluruh bentuk perulangan.

Code
package main

import "fmt"

func main() {
	for i := 0; i < 3; i++ {
		fmt.Println(i)
	}

	n := 3
	for n > 0 {
		fmt.Println(n)
		n--
	}
}

Perulangan tanpa kondisi berjalan sampai dihentikan:

Code
for {
	// hentikan dengan break, return, atau kondisi eksternal
	break
}

Aliran keputusan loop:

Memuat diagram…

4. Iterasi dengan range

range menghasilkan pasangan indeks/nilai untuk slice dan array, serta key/value untuk map.

Code
package main

import "fmt"

func main() {
	nilai := []int{80, 90, 75}
	total := 0
	for i, n := range nilai {
		fmt.Printf("nilai[%d]=%d\n", i, n)
		total += n
	}
	fmt.Println("total:", total)
}

Gunakan blank identifier untuk hasil yang tidak dibutuhkan:

Code
for _, n := range nilai {
	fmt.Println(n)
}

Urutan iterasi map sengaja tidak ditentukan. Jangan mengandalkannya untuk output stabil; kumpulkan dan urutkan key terlebih dahulu jika urutan diperlukan.

Pada string, range menghasilkan indeks byte dan rune yang didekode. Pada slice nilai struct, variabel nilai adalah salinan; ubah elemen melalui indeks bila mutasi diperlukan:

Code
for i := range nilai {
	nilai[i] *= 2
}

5. break, continue, dan Label

continue melanjutkan iterasi berikutnya; break keluar dari loop atau switch terdekat.

Code
package main

import "fmt"

func main() {
	for n := 1; n <= 10; n++ {
		if n%2 != 0 {
			continue
		}
		if n > 6 {
			break
		}
		fmt.Println(n)
	}
}

Label berguna untuk keluar dari loop bersarang, tetapi sering kali fungsi kecil dan return lebih mudah dibaca:

Code
package main

import "fmt"

func main() {
Luar:
	for baris := 0; baris < 3; baris++ {
		for kolom := 0; kolom < 3; kolom++ {
			if baris == 1 && kolom == 1 {
				break Luar
			}
			fmt.Println(baris, kolom)
		}
	}
}

Kesalahan Umum

  • Menulis kondisi non-boolean. Gunakan perbandingan eksplisit, misalnya n != 0.
  • Menambah break rutin pada setiap case. Switch Go tidak jatuh ke kasus berikutnya secara default.
  • Loop tak berujung karena lupa memperbarui kondisi. Pastikan setiap jalur memajukan state atau berhenti.
  • Mengandalkan urutan map. Urutkan key jika hasil harus deterministik.
  • Memodifikasi salinan dari range. Gunakan indeks untuk mengubah elemen slice.
  • Off-by-one. Untuk panjang len(data), indeks valid adalah 0 sampai len(data)-1; kondisi lazim adalah i < len(data).
  • Scope variabel terlalu sempit. Nilai dari short statement if tidak tersedia setelah rantai tersebut.

Latihan

  1. Cetak angka 1–100; ganti kelipatan 3 dengan Fizz, 5 dengan Buzz, dan keduanya dengan FizzBuzz.
  2. Buat fungsi yang mengklasifikasikan usia menggunakan switch tanpa ekspresi.
  3. Hitung nilai minimum, maksimum, dan rata-rata sebuah slice tanpa package tambahan.
  4. Iterasi string beraksara multibyte dan tampilkan indeks byte serta rune.
  5. Buat pencarian pada matriks yang berhenti ketika target ditemukan; implementasikan sekali dengan label dan sekali dengan fungsi yang mengembalikan hasil.

Rangkuman

if menangani keputusan boolean, sedangkan switch menyederhanakan banyak cabang dan berhenti otomatis. Semua perulangan memakai for; range memberi iterasi idiomatis atas koleksi dan string. Gunakan break, continue, dan label hanya ketika membuat alur lebih jelas, serta perhatikan scope, batas indeks, salinan nilai, dan urutan map yang tidak dijamin.

Chapter 4 of 10

Fungsi, Pointer, Error, dan Defer

Tujuan Pembelajaran

Setelah bab ini, Anda mampu:

  • mendefinisikan fungsi dengan parameter dan beberapa hasil;
  • memahami kapan pointer diperlukan;
  • membuat, membungkus, dan memeriksa error;
  • memakai defer untuk cleanup yang dapat diandalkan.

1. Fungsi dan Nilai Kembalian

Fungsi Go menyatakan tipe setiap parameter dan hasil. Parameter bertipe sama dapat diringkas.

Code
package main

import "fmt"

func tambah(a, b int) int {
	return a + b
}

func bagi(a, b float64) (float64, bool) {
	if b == 0 {
		return 0, false
	}
	return a / b, true
}

func main() {
	fmt.Println(tambah(2, 3))
	hasil, ok := bagi(10, 2)
	fmt.Println(hasil, ok)
}

Beberapa hasil umum dipakai untuk nilai beserta status atau error. Named result tersedia, tetapi hasil eksplisit biasanya lebih jelas pada fungsi pendek. Fungsi variadik menerima nol atau lebih argumen:

Code
func jumlah(angka ...int) int {
	total := 0
	for _, n := range angka {
		total += n
	}
	return total
}

Fungsi adalah nilai dan dapat disimpan atau dikirim sebagai argumen:

Code
func terapkan(a, b int, operasi func(int, int) int) int {
	return operasi(a, b)
}

2. Pointer dan Semantik Nilai

Argumen selalu diteruskan by value. Jika argumennya pointer, nilai alamat itulah yang disalin, sehingga fungsi dapat mengubah objek tujuan.

Code
package main

import "fmt"

func naikkan(n *int) {
	*n++
}

func main() {
	nilai := 10
	naikkan(&nilai)
	fmt.Println(nilai)
}

&nilai mengambil alamat; *n membaca atau menulis nilai yang ditunjuk. Go tidak mendukung aritmetika pointer. Pointer memiliki zero value nil; dereference pointer nil menyebabkan panic.

Gunakan pointer ketika fungsi perlu memutasi nilai pemanggil, ketika penyalinan struct besar memang mahal, atau ketika nil bermakna. Jangan memakai pointer hanya agar kode terlihat efisien. Slice dan map sudah memiliki descriptor/header yang merujuk data internal; fungsi biasanya tidak memerlukan pointer ke slice atau map untuk mengubah elemennya.

3. Error sebagai Nilai

error adalah interface standar:

Code
type error interface {
	Error() string
}

Fungsi yang dapat gagal lazimnya mengembalikan error terakhir. Pemeriksaan dilakukan segera.

Code
package main

import (
	"errors"
	"fmt"
)

func akarSederhana(n float64) (float64, error) {
	if n < 0 {
		return 0, errors.New("nilai tidak boleh negatif")
	}
	if n == 0 {
		return 0, nil
	}
	x := n
	for i := 0; i < 10; i++ {
		x = (x + n/x) / 2
	}
	return x, nil
}

func main() {
	hasil, err := akarSederhana(9)
	if err != nil {
		fmt.Println("gagal:", err)
		return
	}
	fmt.Println(hasil)
}

Tambahkan konteks tanpa menghilangkan identitas error memakai %w:

Code
return fmt.Errorf("membaca konfigurasi %q: %w", nama, err)

Periksa rantai error dengan errors.Is, bukan membandingkan teks. Gunakan errors.As untuk mengambil tipe error tertentu.

Code
package main

import (
	"errors"
	"fmt"
)

var ErrTidakDitemukan = errors.New("data tidak ditemukan")

func cari(id int) (string, error) {
	if id != 1 {
		return "", fmt.Errorf("id %d: %w", id, ErrTidakDitemukan)
	}
	return "data", nil
}

func main() {
	_, err := cari(2)
	if errors.Is(err, ErrTidakDitemukan) {
		fmt.Println("gunakan hasil kosong")
	}
}

Alur penanganannya:

Memuat diagram…

4. Cleanup dengan defer

defer menjadwalkan pemanggilan sampai fungsi di sekitarnya selesai, baik melalui return normal maupun panic. Argumen deferred call dievaluasi saat defer dijalankan. Banyak defer dieksekusi LIFO.

Code
package main

import "fmt"

func urutan() {
	defer fmt.Println("pertama dijadwalkan, terakhir berjalan")
	defer fmt.Println("kedua dijadwalkan, pertama berjalan")
	fmt.Println("body")
}

func main() {
	urutan()
}

Pola cleanup file:

Code
func bacaAwal(nama string) ([]byte, error) {
	f, err := os.Open(nama)
	if err != nil {
		return nil, fmt.Errorf("membuka %q: %w", nama, err)
	}
	defer f.Close()

	data := make([]byte, 64)
	n, err := f.Read(data)
	if err != nil && !errors.Is(err, io.EOF) {
		return nil, fmt.Errorf("membaca %q: %w", nama, err)
	}
	return data[:n], nil
}

Import yang diperlukan adalah errors, fmt, io, dan os. Untuk operasi tulis, error dari Close dapat penting; jangan selalu mengabaikannya. Hindari defer di loop panjang jika resource harus dilepas pada setiap iterasi; pindahkan satu iterasi ke fungsi kecil.

Kesalahan Umum

  • Mengabaikan error dengan _. Abaikan hanya bila kegagalan benar-benar tidak relevan dan kontraknya dipahami.
  • Membandingkan err.Error() sebagai teks. Gunakan errors.Is atau errors.As.
  • Membungkus tanpa %w. %v memutus kemampuan pemeriksaan rantai.
  • Dereference pointer nil. Validasi nil bila termasuk input yang mungkin.
  • Menggunakan pointer tanpa kebutuhan. Nilai kecil lebih sederhana dan aman disalin.
  • Menaruh defer sebelum memastikan resource berhasil dibuka. Jadwalkan cleanup setelah pemeriksaan error.
  • Salah memahami argumen defer. Nilai argumen ditangkap saat pernyataan defer, bukan saat eksekusi akhir.

Latihan

  1. Buat fungsi variadik yang mengembalikan minimum dan error jika tidak ada argumen.
  2. Buat fungsi tukar(a, b *int) dan uji perubahan pada pemanggil.
  3. Definisikan sentinel error untuk saldo tidak cukup; bungkus dengan konteks dan periksa memakai errors.Is.
  4. Prediksi keluaran tiga defer, lalu jalankan untuk memverifikasi urutan LIFO.
  5. Buat fungsi pembaca file yang selalu menutup file dan menambahkan nama file pada error.

Rangkuman

Fungsi Go mendukung beberapa hasil, nilai fungsi, dan parameter variadik. Semua argumen disalin; pointer memungkinkan mutasi terhadap nilai tujuan, tetapi harus digunakan dengan alasan jelas. Error adalah nilai yang diperiksa, diberi konteks dengan %w, dan dikenali melalui errors.Is/errors.As. defer menempatkan cleanup dekat akuisisi resource dan berjalan dalam urutan LIFO.

Chapter 5 of 10

Array, Slice, dan Map

Tujuan Pembelajaran

Setelah bab ini, Anda mampu:

  • membedakan array, slice, dan map;
  • mengelola panjang dan kapasitas slice;
  • memahami backing array, append, dan copy;
  • membedakan koleksi nil dan kosong;
  • memakai map dengan pola lookup comma-ok.

1. Array: Ukuran Bagian dari Tipe

Array menyimpan elemen berjumlah tetap. Panjangnya menjadi bagian dari tipe; [3]int berbeda dari [4]int.

Code
package main

import "fmt"

func main() {
	var nol [3]int
	prima := [4]int{2, 3, 5, 7}
	otomatis := [...]string{"merah", "hijau", "biru"}

	fmt.Println(nol, prima, otomatis)
	fmt.Println(len(prima), prima[0])
}

Assignment dan parameter array menyalin seluruh array. Array berguna untuk ukuran intrinsik dan tetap, misalnya hash 32 byte. Untuk koleksi umum, slice lebih fleksibel.

2. Slice: Tampilan ke Backing Array

Slice bukan array. Slice adalah descriptor yang mereferensikan segmen backing array, dengan panjang (len) dan kapasitas (cap).

Code
package main

import "fmt"

func main() {
	angka := []int{10, 20, 30, 40, 50}
	bagian := angka[1:4]
	fmt.Println(bagian, len(bagian), cap(bagian))

	bagian[0] = 99
	fmt.Println(angka)
}

Batas a[low:high] mencakup low dan tidak mencakup high. Kapasitas dihitung dari awal slice sampai akhir backing array yang tersedia.

Memuat diagram…

Buat slice dengan make ketika panjang atau kapasitas diketahui:

Code
package main

import "fmt"

func main() {
	a := make([]int, 3)
	b := make([]int, 0, 8)
	fmt.Println(a, len(a), cap(a))
	fmt.Println(b, len(b), cap(b))
}

make([]int, 3) menghasilkan tiga elemen yang sudah dapat diindeks. make([]int, 0, 8) belum memiliki elemen, sehingga b[0] panic; kapasitas bukan panjang.

3. append, Kapasitas, dan Alias

append mengembalikan slice baru dan hasilnya harus disimpan. Jika kapasitas cukup, backing array lama dapat dipakai; jika tidak, runtime mengalokasikan array baru.

Code
package main

import "fmt"

func main() {
	nilai := make([]int, 0, 2)
	nilai = append(nilai, 10, 20)
	nilai = append(nilai, 30)
	fmt.Println(nilai, len(nilai), cap(nilai))
}

Karena dua slice dapat berbagi backing array, perubahan atau append kadang terlihat melalui slice lain dan kadang tidak setelah realokasi. Jangan bergantung pada detail pertumbuhan kapasitas.

Gunakan full slice expression untuk membatasi kapasitas saat perlu mencegah append menimpa bagian lain:

Code
semua := []int{1, 2, 3, 4}
kiri := semua[:2:2]
kiri = append(kiri, 9)
fmt.Println(semua, kiri)

Kapasitas kiri dibatasi dua, sehingga append memerlukan backing array baru.

4. Menyalin Slice

Assignment slice menyalin header, bukan elemen. Untuk salinan independen, gunakan copy atau append ke slice baru.

Code
package main

import "fmt"

func main() {
	asal := []int{1, 2, 3}
	salinan := make([]int, len(asal))
	copy(salinan, asal)

	salinan[0] = 99
	fmt.Println(asal, salinan)
}

copy(dst, src) mengembalikan jumlah elemen yang disalin, yaitu minimum panjang keduanya.

5. Slice Nil dan Kosong

Code
var nilSlice []int
emptySlice := []int{}
madeEmpty := make([]int, 0)

Ketiganya memiliki panjang nol dan aman untuk range serta append. Hanya nilSlice == nil yang benar. Perbedaan dapat tampak pada serialisasi atau API eksternal (null versus []), sehingga ikuti kontrak boundary. Slice tidak dapat dibandingkan dengan slice lain; hanya dapat dibandingkan dengan nil.

6. Map dan comma-ok

Map menyimpan pasangan key-value. Key harus comparable; slice, map, dan function tidak dapat menjadi key.

Code
package main

import "fmt"

func main() {
	skor := map[string]int{
		"Ayu": 90,
		"Bima": 82,
	}

	skor["Citra"] = 88
	nilai, ada := skor["Ayu"]
	fmt.Println(nilai, ada)

	hapusNilai, ada := skor["Doni"]
	fmt.Println(hapusNilai, ada)

	delete(skor, "Bima")
	fmt.Println(len(skor))
}

Lookup key yang tidak ada menghasilkan zero value. Karena zero value mungkin nilai valid, gunakan bentuk nilai, ada := m[key] untuk membedakannya.

Map nil dapat dibaca dan menghasilkan zero value, tetapi penulisan menyebabkan panic:

Code
var m map[string]int
fmt.Println(m["x"])
m = make(map[string]int)
m["x"] = 1

Map adalah reference-like descriptor. Assignment atau pengiriman ke fungsi membuat descriptor baru yang mengacu data map sama. Map tidak aman untuk read/write bersamaan tanpa sinkronisasi. Urutan iterasinya tidak ditentukan.

Pola penghitung:

Code
package main

import "fmt"

func main() {
	frekuensi := make(map[rune]int)
	for _, r := range "banana" {
		frekuensi[r]++
	}
	fmt.Println(frekuensi['a'])
}

Zero value int membuat increment langsung bekerja tanpa pemeriksaan keberadaan.

Kesalahan Umum

  • Menganggap kapasitas sebagai elemen yang dapat diindeks. Indeks harus selalu kurang dari len.
  • Mengabaikan hasil append. Tulis s = append(s, x).
  • Mengira assignment slice menyalin data. Gunakan copy untuk independensi.
  • Tidak menyadari alias backing array. Salin atau batasi kapasitas ketika isolasi diperlukan.
  • Menulis ke map nil. Inisialisasi dengan literal atau make.
  • Mengandalkan zero value lookup untuk membuktikan key ada. Gunakan comma-ok.
  • Mengandalkan urutan iterasi map. Urutkan key untuk keluaran stabil.
  • Membandingkan dua slice atau map. Keduanya hanya dapat dibandingkan dengan nil.

Latihan

  1. Buat array tujuh suhu harian, lalu hitung rata-ratanya melalui slice.
  2. Buat slice dengan kapasitas awal lima, tambahkan sepuluh elemen, dan cetak len/cap setelah setiap append.
  3. Demonstrasikan dua slice yang berbagi backing array, lalu buat versi independen dengan copy.
  4. Hitung frekuensi setiap kata dalam kalimat memakai map[string]int.
  5. Buat fungsi lookup kontak yang mengembalikan nomor dan bool, termasuk kasus nomor kosong sebagai nilai valid.
  6. Bandingkan perilaku slice nil dan kosong saat len, range, append, dan pemeriksaan == nil.

Rangkuman

Array memiliki ukuran tetap yang menjadi bagian tipe. Slice menyediakan tampilan fleksibel ke backing array melalui pointer, panjang, dan kapasitas; append dapat mempertahankan atau mengganti storage, sedangkan copy membuat elemen independen. Map memberi lookup efisien tetapi tidak menjamin urutan. Pahami nilai nil, aliasing, batas indeks, dan pola comma-ok agar pengelolaan koleksi tetap benar.

Chapter 6 of 10

Struct, Method, Interface, dan Komposisi

Tujuan Pembelajaran

Setelah menyelesaikan bab ini, Anda mampu:

  • memodelkan data dengan struct;
  • menulis method dengan receiver nilai dan pointer;
  • mendefinisikan interface kecil berdasarkan perilaku;
  • memakai komposisi dan embedding tanpa pewarisan;
  • memilih tipe konkret atau interface secara tepat.

1. Struct: Mengelompokkan Data yang Berkaitan

struct adalah tipe bentukan yang menyatukan beberapa field. Gunakan nama field berawalan huruf besar jika field perlu diakses dari package lain.

Code
package main

import "fmt"

type Task struct {
	ID    int
	Title string
	Done  bool
}

func main() {
	t := Task{ID: 1, Title: "Belajar struct"}
	t.Done = true
	fmt.Printf("%d: %s, selesai=%t\n", t.ID, t.Title, t.Done)
}

Utamakan literal bernama seperti Task{ID: 1, Title: "..."}. Bentuk ini tetap jelas ketika urutan field berubah.

2. Method dan Receiver

Method adalah fungsi yang terikat pada tipe. Receiver nilai menerima salinan; receiver pointer dapat mengubah nilai asal.

Code
package main

import (
	"errors"
	"fmt"
	"strings"
)

type Task struct {
	ID    int
	Title string
	Done  bool
}

func NewTask(id int, title string) (Task, error) {
	title = strings.TrimSpace(title)
	if id < 1 {
		return Task{}, errors.New("ID harus positif")
	}
	if title == "" {
		return Task{}, errors.New("judul tidak boleh kosong")
	}
	return Task{ID: id, Title: title}, nil
}

func (t *Task) Complete() {
	t.Done = true
}

func (t Task) String() string {
	status := "[ ]"
	if t.Done {
		status = "[x]"
	}
	return fmt.Sprintf("%s %d %s", status, t.ID, t.Title)
}

func main() {
	t, err := NewTask(1, "Belajar method")
	if err != nil {
		panic(err)
	}
	t.Complete()
	fmt.Println(t)
}

Gunakan receiver pointer ketika method memodifikasi objek atau struct cukup besar. Gunakan receiver nilai untuk operasi baca pada nilai kecil. Jangan mencampur keduanya tanpa alasan yang jelas.

3. Interface: Kontrak Perilaku

Implementasi interface di Go bersifat implisit. Sebuah tipe memenuhi interface jika memiliki seluruh method yang diminta.

Code
package main

import "fmt"

type Describer interface {
	Describe() string
}

type Task struct{ Title string }

func (t Task) Describe() string { return "Tugas: " + t.Title }

type Note struct{ Text string }

func (n Note) Describe() string { return "Catatan: " + n.Text }

func PrintDescription(d Describer) {
	fmt.Println(d.Describe())
}

func main() {
	PrintDescription(Task{Title: "Belajar interface"})
	PrintDescription(Note{Text: "Interface sebaiknya kecil"})
}

Definisikan interface di sisi pemakai, bukan otomatis di sisi implementasi. Terima interface jika fungsi benar-benar membutuhkan variasi perilaku; kembalikan tipe konkret agar API tetap mudah digunakan.

Method Set dan Pointer

Jika method didefinisikan pada *Task, biasanya hanya *Task yang memenuhi interface terkait. Method dengan receiver nilai tersedia bagi Task dan *Task.

Code
var _ fmt.Stringer = Task{}

Pernyataan di atas merupakan pemeriksaan saat kompilasi bahwa Task memenuhi fmt.Stringer.

4. Komposisi dan Embedding

Go tidak memiliki pewarisan kelas. Bangun tipe kompleks dengan menyusun tipe yang lebih kecil.

Memuat diagram…

Embedding mempromosikan field dan method, tetapi nilai embedded tetap merupakan bagian objek, bukan hubungan “adalah”.

Code
package main

import "fmt"

type Metadata struct {
	CreatedBy string
}

type Task struct {
	Metadata
	Title string
}

func main() {
	t := Task{
		Metadata: Metadata{CreatedBy: "Ayu"},
		Title:    "Belajar komposisi",
	}
	fmt.Println(t.CreatedBy, t.Title)
}

Gunakan field bernama ketika embedding membuat API ambigu. Komposisi eksplisit sering lebih mudah dibaca:

Code
type Service struct {
	store TaskStore
}

5. Contoh: Service dengan Store

Code
package main

import (
	"errors"
	"fmt"
	"strings"
)

type Task struct {
	ID    int
	Title string
}

type TaskStore interface {
	Save(Task) error
	All() []Task
}

type MemoryStore struct {
	tasks []Task
}

func (s *MemoryStore) Save(t Task) error {
	s.tasks = append(s.tasks, t)
	return nil
}

func (s *MemoryStore) All() []Task {
	return append([]Task(nil), s.tasks...)
}

type Service struct {
	store TaskStore
}

func NewService(store TaskStore) Service {
	return Service{store: store}
}

func (s Service) Add(id int, title string) error {
	title = strings.TrimSpace(title)
	if id < 1 || title == "" {
		return errors.New("ID dan judul tidak valid")
	}
	return s.store.Save(Task{ID: id, Title: title})
}

func main() {
	store := &MemoryStore{}
	service := NewService(store)
	if err := service.Add(1, "Susun model domain"); err != nil {
		panic(err)
	}
	fmt.Println(store.All())
}

All mengembalikan salinan slice agar pemanggil tidak dapat mengubah penyimpanan secara tidak sengaja.

Kesalahan Umum

  1. Semua hal dijadikan interface. Mulailah dengan tipe konkret; ekstrak interface saat ada kebutuhan substitusi atau pengujian.
  2. Receiver nilai untuk mutasi. Perubahan hanya terjadi pada salinan.
  3. Interface terlalu besar. Pecah berdasarkan kebutuhan pemakai; satu sampai tiga method sering cukup.
  4. Menganggap embedding sebagai inheritance. Embedded type tidak membentuk hierarki subtype.
  5. Mengekspor seluruh field. Ekspor hanya bagian API yang memang diperlukan.
  6. Menyalin struct yang mengandung mutex. Gunakan pointer dan jangan menyalin nilai sinkronisasi.
  7. Nil pointer di dalam interface. Interface dapat tidak nil meskipun nilai konkret di dalamnya berupa pointer nil.

Latihan

  1. Tambahkan Priority int pada Task dan method Validate() error dengan rentang 1–5.
  2. Buat interface TaskReader yang hanya memiliki All() []Task.
  3. Implementasikan FindByID(int) (Task, bool) pada MemoryStore.
  4. Buat Logger kecil dengan method Printf(string, ...any) dan injeksikan ke Service.
  5. Jelaskan kapan field bernama lebih baik daripada embedding pada contoh Metadata.

Ringkasan

  • struct memodelkan data; method menempatkan perilaku dekat dengan data.
  • Receiver pointer diperlukan untuk mutasi, sedangkan receiver nilai cocok untuk operasi baca pada nilai kecil.
  • Interface Go dipenuhi secara implisit dan sebaiknya kecil serta berpusat pada kebutuhan pemakai.
  • Komposisi menggantikan pewarisan dan menjaga hubungan antarkomponen tetap eksplisit.

Chapter 7 of 10

Package, Struktur Proyek, dan Dependensi

Tujuan Pembelajaran

Setelah menyelesaikan bab ini, Anda mampu:

  • membagi program menjadi package yang kohesif;
  • membuat dan merawat module Go;
  • memilih struktur proyek yang sederhana dan idiomatis;
  • mengelola dependensi dengan go mod;
  • memahami aturan ekspor, import, dan package internal.

1. Package dan Module

Package adalah unit organisasi, kompilasi, dan akses kode. Module adalah kumpulan package berversi yang memiliki berkas go.mod.

Buat proyek baru:

Code
mkdir taskcli
cd taskcli
go mod init example.com/taskcli

Nama module biasanya URL repositori tempat kode akan diterbitkan. Untuk eksperimen lokal, nama seperti example.com/taskcli sah selama import di proyek konsisten.

Setiap direktori normal berisi satu package. Nama package singkat, huruf kecil, dan tidak perlu mengulang konteks: gunakan task, bukan taskutils atau task_package.

2. Ekspor dan Import

Identifier berawalan huruf besar diekspor dari package; identifier berawalan huruf kecil hanya dapat digunakan di package yang sama.

Code
// task/task.go
package task

import (
	"errors"
	"strings"
)

type Task struct {
	ID    int
	Title string
}

func New(id int, title string) (Task, error) {
	title = strings.TrimSpace(title)
	if id < 1 {
		return Task{}, errors.New("ID harus positif")
	}
	if title == "" {
		return Task{}, errors.New("judul tidak boleh kosong")
	}
	return Task{ID: id, Title: title}, nil
}
Code
// cmd/taskcli/main.go
package main

import (
	"fmt"
	"log"

	"example.com/taskcli/task"
)

func main() {
	t, err := task.New(1, "Belajar package")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(t.Title)
}

Import dikelompokkan oleh gofmt: pustaka standar, baris kosong, lalu package module atau pihak ketiga. Hindari dot import karena asal identifier menjadi tidak jelas.

3. Struktur Proyek yang Proporsional

Untuk aplikasi kecil, satu main.go dapat cukup. Pecah direktori setelah ada tanggung jawab nyata yang berbeda.

Code
taskcli/
├── go.mod
├── cmd/
│   └── taskcli/
│       └── main.go
├── internal/
│   └── task/
│       ├── task.go
│       └── store.go
└── README.md

Direktori cmd/taskcli berisi entry point. internal/task menyimpan kode aplikasi yang tidak boleh diimpor module lain. Go menegakkan batas internal saat kompilasi.

Memuat diagram…

Jangan menyalin struktur proyek besar tanpa kebutuhan. Folder generik seperti utils, common, atau helpers sering menyembunyikan tanggung jawab yang tidak jelas.

4. Siklus Import

Go melarang import cycle. Jika package task mengimpor storage, maka storage tidak boleh kembali mengimpor task.

Solusi yang lazim:

  • pindahkan tipe domain ke package yang paling memilikinya;
  • definisikan interface kecil di package pemakai;
  • gabungkan package jika pemisahannya tidak memberi manfaat;
  • pindahkan kode bersama ke package ketiga hanya jika konsepnya benar-benar mandiri.

5. Mengelola Dependensi

go.mod mencatat path module, versi Go, dan kebutuhan module lain. go.sum mencatat checksum untuk verifikasi unduhan.

Code
go mod tidy
go list -m all
go mod graph
go mod verify

go mod tidy menambah dependensi yang diperlukan dan menghapus yang tidak dipakai. Jalankan setelah perubahan import.

Jika memang memerlukan package eksternal:

Code
go get example.com/some/module@v1.2.3
go mod tidy

Gunakan versi eksplisit bila proyek menuntut reproduksibilitas. Evaluasi lisensi, pemeliharaan, keamanan, dan ukuran transitive dependency sebelum menambah library. Pustaka standar sering sudah cukup.

Untuk memperbarui dependensi langsung dan tidak langsung sesuai batas kompatibilitas:

Code
go get -u ./...
go mod tidy
go test ./...

Tinjau perubahan go.mod dan go.sum; jangan memperbarui tanpa menjalankan pengujian.

6. Package Documentation dan API

Dokumentasi identifier yang diekspor dimulai dengan namanya.

Code
// Package task menyediakan model dan operasi tugas.
package task

// Task merepresentasikan satu pekerjaan yang dapat diselesaikan.
type Task struct {
	ID int
}

API package yang baik memiliki permukaan kecil, nama jelas, dan aturan error yang konsisten. Hindari mengekspos representasi internal jika pengguna hanya memerlukan operasi tingkat tinggi.

7. Contoh Proyek Minimal yang Dapat Dikompilasi

Code
hello/
├── go.mod
├── main.go
└── greeting/
    └── greeting.go
Code
// greeting/greeting.go
package greeting

import "strings"

func Hello(name string) string {
	name = strings.TrimSpace(name)
	if name == "" {
		name = "dunia"
	}
	return "Halo, " + name + "!"
}
Code
// main.go
package main

import (
	"fmt"

	"example.com/hello/greeting"
)

func main() {
	fmt.Println(greeting.Hello("Gopher"))
}
Code
go mod init example.com/hello
gofmt -w .
go run .
go test ./...

Kesalahan Umum

  1. Terlalu dini membuat banyak package. Mulai sederhana dan pisahkan saat tanggung jawab jelas.
  2. Nama package mengulang nama tipe. task.Task lebih baik daripada taskmodel.TaskModel.
  3. Mengedit go.sum secara manual. Biarkan tool Go mengelolanya.
  4. Mengabaikan go mod tidy. Module dapat menyimpan kebutuhan usang atau kehilangan dependensi.
  5. Memakai replace lokal lalu lupa menghapusnya. Build pengguna lain akan gagal.
  6. Menciptakan import cycle. Evaluasi kembali arah dependensi atau batas package.
  7. Menambah library untuk fungsi kecil. Periksa pustaka standar terlebih dahulu.

Latihan

  1. Buat module example.com/library dengan package book dan program di cmd/catalog.
  2. Pindahkan package book ke internal/book, lalu coba impor dari module lain dan amati error.
  3. Jalankan go list ./..., go mod tidy, dan go mod verify; jelaskan fungsi tiap perintah.
  4. Temukan package bernama utils pada proyek latihan dan ubah menjadi package dengan tanggung jawab spesifik.
  5. Tambahkan satu dependency kecil hanya untuk eksperimen, periksa go mod graph, lalu hapus import dan jalankan go mod tidy.

Ringkasan

  • Package mengelompokkan kode berdasarkan tanggung jawab; module memberi identitas dan versi pada kumpulan package.
  • Struktur proyek harus tumbuh mengikuti kebutuhan, bukan template yang berlebihan.
  • internal menyediakan batas akses yang ditegakkan toolchain.
  • go mod tidy, verify, dan pengujian menjaga dependensi tetap konsisten dan dapat direproduksi.

Chapter 8 of 10

File I/O, JSON, dan Persistensi CLI

Tujuan Pembelajaran

Setelah menyelesaikan bab ini, Anda mampu:

  • membaca dan menulis file dengan pustaka standar;
  • mengubah struct menjadi JSON dan sebaliknya;
  • membedakan file tidak ada dari kegagalan I/O lain;
  • menyimpan data CLI secara atomik;
  • menangani path, permission, dan error dengan aman.

1. Membaca dan Menulis File

Untuk file kecil yang harus dimuat seluruhnya, gunakan os.ReadFile dan os.WriteFile.

Code
package main

import (
	"fmt"
	"log"
	"os"
)

func main() {
	const path = "message.txt"
	if err := os.WriteFile(path, []byte("Halo, Go!\n"), 0o600); err != nil {
		log.Fatal(err)
	}

	data, err := os.ReadFile(path)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Print(string(data))
}

Mode 0o600 memberi akses baca-tulis kepada pemilik. Permission aktual dapat dipersempit oleh umask. Untuk data pengguna yang mungkin sensitif, jangan memakai mode yang dapat dibaca semua pengguna.

Untuk data besar atau streaming, gunakan os.Open, bufio.Scanner, bufio.Reader, atau io.Copy. File yang dibuka harus ditutup.

Code
f, err := os.Open("input.txt")
if err != nil {
	return err
}
defer f.Close()

Pada file output, error Close dapat penting karena flush terakhir bisa gagal. Tangani secara eksplisit jika kehilangan data berisiko.

2. JSON dan Struct Tags

Package encoding/json memetakan field struct yang diekspor. Tag mengatur nama key.

Code
package main

import (
	"encoding/json"
	"fmt"
	"log"
)

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

func main() {
	tasks := []Task{{ID: 1, Title: "Belajar JSON", Done: false}}
	data, err := json.MarshalIndent(tasks, "", "  ")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(data))
}

Untuk membaca JSON yang tidak dipercaya, decoder dapat menolak field asing:

Code
decoder := json.NewDecoder(r)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
	return fmt.Errorf("decode JSON: %w", err)
}

Marshal hanya gagal untuk nilai yang tidak didukung, seperti channel, function, atau siklus pointer. Tetap tangani error.

3. Memuat Data dengan Semantik yang Jelas

File belum ada dapat berarti aplikasi baru, bukan kegagalan.

Code
func load(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return []Task{}, nil
	}
	if err != nil {
		return nil, fmt.Errorf("baca %q: %w", path, err)
	}
	if len(data) == 0 {
		return []Task{}, nil
	}

	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, fmt.Errorf("decode %q: %w", path, err)
	}
	return tasks, nil
}

Jangan memperlakukan permission denied atau kerusakan disk sebagai “data kosong”; tindakan tersebut menyembunyikan masalah dan berpotensi menimpa data.

4. Penulisan Atomik

os.WriteFile langsung ke file tujuan dapat meninggalkan file terpotong bila proses berhenti. Pola yang lebih aman adalah menulis file sementara pada direktori yang sama, menyinkronkan, menutup, lalu mengganti nama.

Memuat diagram…

Program lengkap berikut menyediakan penyimpanan JSON sederhana:

Code
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
)

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

func Load(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return []Task{}, nil
	}
	if err != nil {
		return nil, fmt.Errorf("baca %q: %w", path, err)
	}
	if len(data) == 0 {
		return []Task{}, nil
	}

	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, fmt.Errorf("decode %q: %w", path, err)
	}
	return tasks, nil
}

func Save(path string, tasks []Task) (err error) {
	data, err := json.MarshalIndent(tasks, "", "  ")
	if err != nil {
		return fmt.Errorf("encode tugas: %w", err)
	}
	data = append(data, '\n')

	dir := filepath.Dir(path)
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return fmt.Errorf("buat direktori %q: %w", dir, err)
	}

	tmp, err := os.CreateTemp(dir, ".tasks-*.tmp")
	if err != nil {
		return fmt.Errorf("buat file sementara: %w", err)
	}
	tmpName := tmp.Name()
	defer func() {
		tmp.Close()
		os.Remove(tmpName)
	}()

	if err := tmp.Chmod(0o600); err != nil {
		return fmt.Errorf("atur permission: %w", err)
	}
	if _, err := tmp.Write(data); err != nil {
		return fmt.Errorf("tulis file sementara: %w", err)
	}
	if err := tmp.Sync(); err != nil {
		return fmt.Errorf("sinkronkan file sementara: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("tutup file sementara: %w", err)
	}
	if err := os.Rename(tmpName, path); err != nil {
		return fmt.Errorf("ganti %q: %w", path, err)
	}
	return nil
}

func main() {
	path := filepath.Join(os.TempDir(), "taskcli-example", "tasks.json")
	tasks := []Task{{ID: 1, Title: "Simpan atomik"}}
	if err := Save(path, tasks); err != nil {
		panic(err)
	}
	loaded, err := Load(path)
	if err != nil {
		panic(err)
	}
	fmt.Println(loaded[0].Title)
}

Rename atomik secara umum jika sumber dan tujuan berada pada filesystem yang sama; karena itu file sementara dibuat di direktori tujuan. Jaminan detail tetap bergantung pada sistem operasi dan filesystem. Untuk ketahanan terhadap kehilangan daya yang lebih ketat, sinkronisasi direktori setelah rename dapat diperlukan.

5. Menentukan Lokasi Data CLI

Hindari menulis data tetap di current working directory karena direktori tersebut berubah sesuai cara program dijalankan. Gunakan direktori konfigurasi pengguna:

Code
func defaultPath() (string, error) {
	dir, err := os.UserConfigDir()
	if err != nil {
		return "", fmt.Errorf("tentukan direktori konfigurasi: %w", err)
	}
	return filepath.Join(dir, "taskcli", "tasks.json"), nil
}

Izinkan flag seperti -file agar pengujian dan otomasi dapat memilih lokasi lain. Jangan membangun path dengan penggabungan string; gunakan filepath.Join.

6. Batas Persistensi JSON

JSON sesuai untuk CLI pengguna tunggal dengan data kecil. Ia bukan solusi ideal untuk banyak proses yang menulis bersamaan, query kompleks, atau dataset besar. Penulisan atomik mencegah file setengah jadi, tetapi tidak mencegah lost update antara dua proses. Pada kebutuhan tersebut, tambahkan locking yang sesuai platform atau beralih ke database transaksional.

Kesalahan Umum

  1. Mengabaikan error I/O atau JSON. Selalu beri konteks dengan %w.
  2. Menimpa file ketika decode gagal. Hentikan operasi dan laporkan kerusakan data.
  3. Membuat temp file di filesystem lain. Rename dapat gagal dan kehilangan sifat atomik.
  4. Permission terlalu longgar. Gunakan 0o600 untuk file data pribadi dan 0o700 untuk direktori.
  5. Lupa menutup file. Gunakan defer untuk input; tangani error close pada output penting.
  6. Mengandalkan current working directory. Gunakan direktori pengguna atau flag eksplisit.
  7. Menganggap JSON sebagai database multiwriter. Tambahkan koordinasi atau gunakan penyimpanan transaksional.

Latihan

  1. Tambahkan field CreatedAt time.Time dan amati representasi JSON-nya.
  2. Gunakan json.Decoder dengan DisallowUnknownFields untuk memuat file.
  3. Tulis fungsi Backup(path string) yang menyalin data lama sebelum perubahan.
  4. Uji Load untuk file tidak ada, kosong, JSON rusak, dan JSON valid.
  5. Tambahkan flag -file ke CLI kecil dan gunakan t.TempDir() dalam pengujian.

Ringkasan

  • Pilih API file sesuai ukuran dan pola akses data.
  • Struct tag mengontrol format JSON; semua kegagalan encode/decode harus ditangani.
  • Bedakan file tidak ada dari error I/O lain.
  • Temp-file, Sync, Close, dan Rename mengurangi risiko korupsi saat menyimpan.
  • JSON cocok untuk persistensi sederhana, bukan konkurensi penulis tanpa koordinasi.

Chapter 9 of 10

Testing, Table-Driven Tests, Coverage, dan Race Detector

Tujuan Pembelajaran

Setelah menyelesaikan bab ini, Anda mampu:

  • menulis unit test dengan package testing;
  • menyusun table-driven test dan subtest;
  • menguji file tanpa mencemari lingkungan pengguna;
  • membaca coverage secara kritis;
  • mendeteksi data race dengan race detector.

1. Dasar Pengujian Go

File pengujian berakhiran _test.go. Fungsi test bernama TestXxx dan menerima *testing.T.

Code
// task.go
package task

import (
	"errors"
	"strings"
)

type Task struct {
	ID    int
	Title string
}

func New(id int, title string) (Task, error) {
	title = strings.TrimSpace(title)
	if id < 1 {
		return Task{}, errors.New("ID harus positif")
	}
	if title == "" {
		return Task{}, errors.New("judul tidak boleh kosong")
	}
	return Task{ID: id, Title: title}, nil
}
Code
// task_test.go
package task

import "testing"

func TestNew(t *testing.T) {
	got, err := New(1, " Belajar test ")
	if err != nil {
		t.Fatalf("New() error = %v", err)
	}
	if got.Title != "Belajar test" {
		t.Errorf("Title = %q, ingin %q", got.Title, "Belajar test")
	}
}

Gunakan Fatalf jika test tidak dapat dilanjutkan; gunakan Errorf jika pemeriksaan lain masih bermakna. Pesan kegagalan sebaiknya memuat nilai aktual dan nilai yang diharapkan.

2. Table-Driven Tests

Table-driven test mengumpulkan beberapa skenario dalam satu struktur data. Setiap kasus dijalankan sebagai subtest dengan t.Run.

Code
package task

import "testing"

func TestNewTable(t *testing.T) {
	tests := []struct {
		name      string
		id        int
		title     string
		wantTitle string
		wantErr   bool
	}{
		{name: "valid", id: 1, title: " Belajar Go ", wantTitle: "Belajar Go"},
		{name: "ID nol", id: 0, title: "Belajar", wantErr: true},
		{name: "judul kosong", id: 1, title: "   ", wantErr: true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := New(tt.id, tt.title)
			if (err != nil) != tt.wantErr {
				t.Fatalf("New(%d, %q) error = %v, wantErr %t", tt.id, tt.title, err, tt.wantErr)
			}
			if !tt.wantErr && got.Title != tt.wantTitle {
				t.Errorf("Title = %q, ingin %q", got.Title, tt.wantTitle)
			}
		})
	}
}

Nama kasus harus menjelaskan perilaku, bukan nomor urut. Jangan memakai table-driven test jika hanya ada satu skenario sederhana dan tabel justru mengaburkan maksud.

3. Helper dan Cleanup

t.Helper() membuat lokasi kegagalan menunjuk ke pemanggil helper. t.Cleanup() memastikan pembersihan dijalankan setelah test.

Code
func writeFixture(t *testing.T, dir, content string) string {
	t.Helper()
	path := filepath.Join(dir, "tasks.json")
	if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
		t.Fatalf("tulis fixture: %v", err)
	}
	return path
}

Untuk file, gunakan t.TempDir(). Direktori unik tersebut dibersihkan otomatis dan tidak menyentuh data pengguna.

Code
func TestLoadMissingFile(t *testing.T) {
	path := filepath.Join(t.TempDir(), "missing.json")
	got, err := Load(path)
	if err != nil {
		t.Fatalf("Load() error = %v", err)
	}
	if len(got) != 0 {
		t.Fatalf("len(Load()) = %d, ingin 0", len(got))
	}
}

4. Determinisme dan Isolasi

Test yang baik memberikan hasil sama pada setiap eksekusi. Hindari ketergantungan pada:

  • waktu nyata tanpa cara mengendalikannya;
  • urutan iterasi map;
  • jaringan eksternal;
  • direktori kerja atau file pengguna;
  • state global yang dibagi antartest.

Jika logika membutuhkan waktu, injeksikan fungsi atau nilai waktu. Jika keluaran berasal dari map, urutkan key sebelum membandingkan. Jalankan t.Parallel() hanya setelah memastikan tidak ada state bersama yang berubah.

Memuat diagram…

5. Membandingkan Error

Jangan membandingkan teks error jika kode menyediakan sentinel error atau tipe khusus.

Code
var ErrNotFound = errors.New("tugas tidak ditemukan")

if !errors.Is(err, ErrNotFound) {
	t.Fatalf("error = %v, ingin ErrNotFound", err)
}

Gunakan errors.As untuk memeriksa tipe error. Pemeriksaan string rapuh karena perubahan redaksi dapat merusak test tanpa mengubah perilaku.

6. Coverage

Coverage menunjukkan bagian statement yang dieksekusi, bukan kualitas pengujian.

Code
go test ./...
go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
go tool cover -html=coverage.out

Coverage tinggi tetap dapat melewatkan assertion penting, error path, race, atau kebutuhan bisnis. Gunakan laporan untuk menemukan area yang belum diuji, bukan sebagai satu-satunya target kualitas.

7. Race Detector

Data race terjadi ketika goroutine mengakses lokasi memori yang sama secara bersamaan, setidaknya satu akses adalah penulisan, dan tidak ada sinkronisasi yang benar.

Code
package counter

import "sync"

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Add() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}
Code
package counter

import (
	"sync"
	"testing"
)

func TestCounterConcurrent(t *testing.T) {
	var c Counter
	var wg sync.WaitGroup
	for range 100 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.Add()
		}()
	}
	wg.Wait()
	if got := c.Value(); got != 100 {
		t.Fatalf("Value() = %d, ingin 100", got)
	}
}
Code
go test -race ./...

Race detector hanya menemukan race pada jalur yang benar-benar dijalankan. Ia menambah waktu dan memori, tetapi sangat bernilai pada CI atau sebelum rilis. Race detector bukan pengganti desain sinkronisasi dan tidak mendeteksi semua logical race.

8. Perintah Verifikasi Praktis

Code
gofmt -w .
go vet ./...
go test ./...
go test -shuffle=on -count=10 ./...
go test -race ./...
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out

-shuffle=on membantu menemukan ketergantungan urutan. -count=10 membantu mengungkap test yang sesekali gagal, tetapi bukan bukti mutlak bahwa test bebas flakiness.

Kesalahan Umum

  1. Hanya menguji happy path. Sertakan input batas, error, dan data rusak.
  2. Assertion tanpa pesan diagnostik. Tampilkan aktual, harapan, dan input relevan.
  3. Test memakai file pengguna. Gunakan t.TempDir().
  4. Mengejar 100% coverage tanpa nilai. Prioritaskan risiko dan perilaku penting.
  5. Memakai t.Parallel() pada state bersama. Isolasi atau sinkronkan terlebih dahulu.
  6. Mengabaikan hasil -race. Race adalah bug; perbaiki, jangan sekadar menonaktifkan pemeriksaan.
  7. Sleep untuk sinkronisasi. Gunakan channel, WaitGroup, atau primitive sinkronisasi lain.

Latihan

  1. Tambahkan kasus batas pada TestNewTable, termasuk whitespace Unicode.
  2. Tulis test Load untuk file valid, tidak ada, kosong, dan JSON rusak menggunakan t.TempDir().
  3. Buat helper fixture dengan t.Helper().
  4. Sengaja hapus mutex dari Counter, jalankan go test -race, lalu perbaiki kembali.
  5. Buat laporan coverage dan identifikasi satu error path yang belum diuji.

Ringkasan

  • Package testing menyediakan unit test tanpa framework tambahan.
  • Table-driven tests efektif untuk banyak variasi perilaku yang sama.
  • Helper, t.TempDir, dan input terkendali menjaga test diagnostik serta deterministik.
  • Coverage mengukur eksekusi statement, bukan kebenaran.
  • Race detector menemukan akses memori tidak tersinkronisasi pada jalur yang diuji.

Chapter 10 of 10

Proyek Akhir: CLI Task Persisten Berkualitas Produksi

Tujuan Pembelajaran

Setelah menyelesaikan proyek ini, Anda mampu:

  • menyusun CLI menjadi domain, penyimpanan, dan entry point;
  • memvalidasi input serta mempertahankan invariant data;
  • menyimpan JSON dengan penggantian file atomik;
  • menghasilkan pesan error dan exit code yang berguna;
  • menguji perilaku penting dengan table-driven tests;
  • memverifikasi format, analisis statis, coverage, dan data race.

1. Spesifikasi

Aplikasi bernama task mendukung perintah:

Code
task add <judul>
task list
task done <id>
task delete <id>

Data disimpan dalam JSON. Flag global -file dapat menentukan lokasi file; jika tidak diberikan, aplikasi menggunakan direktori konfigurasi pengguna.

Aturan domain:

  • judul dipangkas dan tidak boleh kosong;
  • judul maksimum 200 byte pada versi ini;
  • ID berupa bilangan bulat positif, unik, dan tidak dipakai ulang;
  • done dan delete gagal jika ID tidak ditemukan;
  • kegagalan membaca atau decode tidak boleh ditafsirkan sebagai data kosong;
  • perubahan baru dianggap berhasil setelah penyimpanan berhasil.

2. Arsitektur

Memuat diagram…

main menangani argumen, output, dan exit code. Package task memiliki aturan aplikasi. JSONStore menangani file. Interface didefinisikan di sisi pemakai agar kontraknya kecil.

3. Struktur File

Code
taskcli/
├── go.mod
├── cmd/
│   └── task/
│       └── main.go
└── internal/
    └── task/
        ├── task.go
        ├── app.go
        ├── app_test.go
        ├── jsonstore.go
        └── jsonstore_test.go

Buat module:

Code
mkdir taskcli
cd taskcli
go mod init example.com/taskcli
mkdir -p cmd/task internal/task

4. Model Domain

Code
// internal/task/task.go
package task

import "errors"

const MaxTitleBytes = 200

var (
	ErrEmptyTitle = errors.New("judul tidak boleh kosong")
	ErrLongTitle  = errors.New("judul terlalu panjang")
	ErrNotFound   = errors.New("tugas tidak ditemukan")
)

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

Sentinel error memungkinkan pemanggil menggunakan errors.Is. Batas byte sederhana dan deterministik. Jika kebutuhan berubah menjadi batas karakter pengguna, gunakan perhitungan rune dan tetapkan aturan Unicode dengan jelas.

5. Logika Aplikasi

Code
// internal/task/app.go
package task

import (
	"fmt"
	"strings"
)

type Store interface {
	Load() ([]Task, error)
	Save([]Task) error
}

type App struct {
	store Store
}

func NewApp(store Store) App {
	return App{store: store}
}

func (a App) Add(title string) (Task, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return Task{}, ErrEmptyTitle
	}
	if len(title) > MaxTitleBytes {
		return Task{}, ErrLongTitle
	}

	tasks, err := a.store.Load()
	if err != nil {
		return Task{}, fmt.Errorf("muat tugas: %w", err)
	}
	maxID := 0
	for _, t := range tasks {
		if t.ID > maxID {
			maxID = t.ID
		}
	}
	created := Task{ID: maxID + 1, Title: title}
	tasks = append(tasks, created)
	if err := a.store.Save(tasks); err != nil {
		return Task{}, fmt.Errorf("simpan tugas: %w", err)
	}
	return created, nil
}

func (a App) List() ([]Task, error) {
	tasks, err := a.store.Load()
	if err != nil {
		return nil, fmt.Errorf("muat tugas: %w", err)
	}
	return tasks, nil
}

func (a App) Done(id int) error {
	if id < 1 {
		return ErrNotFound
	}
	tasks, err := a.store.Load()
	if err != nil {
		return fmt.Errorf("muat tugas: %w", err)
	}
	for i := range tasks {
		if tasks[i].ID == id {
			tasks[i].Done = true
			if err := a.store.Save(tasks); err != nil {
				return fmt.Errorf("simpan tugas: %w", err)
			}
			return nil
		}
	}
	return ErrNotFound
}

func (a App) Delete(id int) error {
	if id < 1 {
		return ErrNotFound
	}
	tasks, err := a.store.Load()
	if err != nil {
		return fmt.Errorf("muat tugas: %w", err)
	}
	for i := range tasks {
		if tasks[i].ID == id {
			tasks = append(tasks[:i], tasks[i+1:]...)
			if err := a.store.Save(tasks); err != nil {
				return fmt.Errorf("simpan tugas: %w", err)
			}
			return nil
		}
	}
	return ErrNotFound
}

Aplikasi melakukan load-modify-save. Desain ini cukup untuk satu proses dan data kecil. Dua proses yang menulis bersamaan dapat mengalami lost update; tambahkan file locking atau database ketika multiwriter menjadi kebutuhan nyata.

6. JSON Store dengan Penulisan Atomik

Code
// internal/task/jsonstore.go
package task

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
)

type JSONStore struct {
	Path string
}

func (s JSONStore) Load() ([]Task, error) {
	data, err := os.ReadFile(s.Path)
	if errors.Is(err, os.ErrNotExist) {
		return []Task{}, nil
	}
	if err != nil {
		return nil, fmt.Errorf("baca %q: %w", s.Path, err)
	}
	if len(data) == 0 {
		return []Task{}, nil
	}
	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, fmt.Errorf("decode %q: %w", s.Path, err)
	}
	if tasks == nil {
		tasks = []Task{}
	}
	return tasks, nil
}

func (s JSONStore) Save(tasks []Task) error {
	data, err := json.MarshalIndent(tasks, "", "  ")
	if err != nil {
		return fmt.Errorf("encode tugas: %w", err)
	}
	data = append(data, '\n')

	dir := filepath.Dir(s.Path)
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return fmt.Errorf("buat direktori %q: %w", dir, err)
	}
	tmp, err := os.CreateTemp(dir, ".tasks-*.tmp")
	if err != nil {
		return fmt.Errorf("buat file sementara: %w", err)
	}
	name := tmp.Name()
	defer func() {
		tmp.Close()
		os.Remove(name)
	}()

	if err := tmp.Chmod(0o600); err != nil {
		return fmt.Errorf("atur permission file sementara: %w", err)
	}
	if _, err := tmp.Write(data); err != nil {
		return fmt.Errorf("tulis file sementara: %w", err)
	}
	if err := tmp.Sync(); err != nil {
		return fmt.Errorf("sync file sementara: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("tutup file sementara: %w", err)
	}
	if err := os.Rename(name, s.Path); err != nil {
		return fmt.Errorf("ganti file data: %w", err)
	}
	return nil
}

File sementara berada pada direktori yang sama agar Rename tidak melintasi filesystem. Deferred cleanup menghapus temp file pada jalur error.

7. Entry Point dan Parsing CLI

Code
// cmd/task/main.go
package main

import (
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"example.com/taskcli/internal/task"
)

func defaultPath() (string, error) {
	dir, err := os.UserConfigDir()
	if err != nil {
		return "", fmt.Errorf("tentukan direktori konfigurasi: %w", err)
	}
	return filepath.Join(dir, "taskcli", "tasks.json"), nil
}

func usage(w io.Writer) {
	fmt.Fprintln(w, "penggunaan:")
	fmt.Fprintln(w, "  task [-file path] add <judul>")
	fmt.Fprintln(w, "  task [-file path] list")
	fmt.Fprintln(w, "  task [-file path] done <id>")
	fmt.Fprintln(w, "  task [-file path] delete <id>")
}

func parseID(value string) (int, error) {
	id, err := strconv.Atoi(value)
	if err != nil || id < 1 {
		return 0, errors.New("ID harus berupa bilangan bulat positif")
	}
	return id, nil
}

func run(args []string, stdout, stderr io.Writer) error {
	path, err := defaultPath()
	if err != nil {
		return err
	}
	flags := flag.NewFlagSet("task", flag.ContinueOnError)
	flags.SetOutput(stderr)
	file := flags.String("file", path, "lokasi file data JSON")
	if err := flags.Parse(args); err != nil {
		return err
	}
	args = flags.Args()
	if len(args) == 0 {
		usage(stderr)
		return errors.New("perintah wajib diberikan")
	}

	app := task.NewApp(task.JSONStore{Path: *file})
	switch args[0] {
	case "add":
		if len(args) < 2 {
			return errors.New("judul wajib diberikan")
		}
		created, err := app.Add(strings.Join(args[1:], " "))
		if err != nil {
			return err
		}
		fmt.Fprintf(stdout, "tugas %d ditambahkan\n", created.ID)
		return nil
	case "list":
		if len(args) != 1 {
			return errors.New("list tidak menerima argumen")
		}
		tasks, err := app.List()
		if err != nil {
			return err
		}
		for _, t := range tasks {
			mark := " "
			if t.Done {
				mark = "x"
			}
			fmt.Fprintf(stdout, "[%s] %d %s\n", mark, t.ID, t.Title)
		}
		return nil
	case "done", "delete":
		if len(args) != 2 {
			return fmt.Errorf("%s memerlukan tepat satu ID", args[0])
		}
		id, err := parseID(args[1])
		if err != nil {
			return err
		}
		if args[0] == "done" {
			err = app.Done(id)
		} else {
			err = app.Delete(id)
		}
		if err != nil {
			return err
		}
		fmt.Fprintf(stdout, "tugas %d diperbarui\n", id)
		return nil
	default:
		usage(stderr)
		return fmt.Errorf("perintah tidak dikenal: %s", args[0])
	}
}

func main() {
	if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}

Logika run dapat diuji tanpa menjalankan subprocess karena menerima argumen dan writer. main tetap kecil dan menjadi satu-satunya tempat yang memanggil os.Exit.

8. Pengujian Table-Driven

Code
// internal/task/app_test.go
package task

import (
	"errors"
	"testing"
)

type memoryStore struct {
	tasks   []Task
	loadErr error
	saveErr error
}

func (s *memoryStore) Load() ([]Task, error) {
	return append([]Task(nil), s.tasks...), s.loadErr
}

func (s *memoryStore) Save(tasks []Task) error {
	if s.saveErr != nil {
		return s.saveErr
	}
	s.tasks = append([]Task(nil), tasks...)
	return nil
}

func TestAddValidation(t *testing.T) {
	tests := []struct {
		name    string
		title   string
		wantErr error
	}{
		{name: "kosong", title: "   ", wantErr: ErrEmptyTitle},
		{name: "valid", title: " Belajar Go "},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			store := &memoryStore{}
			got, err := NewApp(store).Add(tt.title)
			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("Add() error = %v, ingin %v", err, tt.wantErr)
			}
			if tt.wantErr == nil && got.Title != "Belajar Go" {
				t.Errorf("Title = %q, ingin %q", got.Title, "Belajar Go")
			}
		})
	}
}

func TestDoneNotFound(t *testing.T) {
	err := NewApp(&memoryStore{}).Done(99)
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("Done() error = %v, ingin ErrNotFound", err)
	}
}
Code
// internal/task/jsonstore_test.go
package task

import (
	"os"
	"path/filepath"
	"testing"
)

func TestJSONStoreRoundTrip(t *testing.T) {
	path := filepath.Join(t.TempDir(), "data", "tasks.json")
	store := JSONStore{Path: path}
	want := []Task{{ID: 1, Title: "Uji persistensi", Done: true}}

	if err := store.Save(want); err != nil {
		t.Fatalf("Save() error = %v", err)
	}
	got, err := store.Load()
	if err != nil {
		t.Fatalf("Load() error = %v", err)
	}
	if len(got) != 1 || got[0] != want[0] {
		t.Fatalf("Load() = %#v, ingin %#v", got, want)
	}
}

func TestJSONStoreRejectsBrokenJSON(t *testing.T) {
	path := filepath.Join(t.TempDir(), "tasks.json")
	if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
		t.Fatal(err)
	}
	if _, err := (JSONStore{Path: path}).Load(); err == nil {
		t.Fatal("Load() error = nil, ingin error decode")
	}
}

Tambahkan test untuk save error, load error, ID maksimum, delete, file tidak ada, dan argumen CLI. Test run dapat memakai bytes.Buffer sebagai stdout dan stderr.

9. Validasi dan Penanganan Error

Validasi dilakukan pada dua batas:

  1. CLI: jumlah argumen, nama perintah, dan format ID.
  2. Domain: judul kosong/panjang dan keberadaan tugas.

Error I/O dibungkus memakai %w agar konteks terbaca tanpa kehilangan penyebab. Jangan mencetak error pada setiap lapisan; kembalikan error sampai main, lalu cetak sekali ke stderr. Output normal masuk stdout agar dapat dipipe.

Exit code versi ini:

  • 0: operasi berhasil;
  • 1: input, domain, atau operasi gagal.

Jika otomasi nantinya perlu membedakan kesalahan penggunaan dari kegagalan penyimpanan, petakan error ke exit code khusus di main. Jangan menambahkan klasifikasi sebelum ada konsumen nyata.

10. Build dan Penggunaan

Code
gofmt -w .
go build -o task ./cmd/task
./task -file ./tmp/tasks.json add "Belajar Go"
./task -file ./tmp/tasks.json list
./task -file ./tmp/tasks.json done 1
./task -file ./tmp/tasks.json delete 1

Flag global harus muncul sebelum subcommand karena flag.FlagSet berhenti memproses flag pada argumen non-flag pertama.

11. Perintah Verifikasi

Jalankan seluruh pemeriksaan dari root module:

Code
gofmt -w .
go vet ./...
go test ./...
go test -shuffle=on -count=10 ./...
go test -race ./...
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
go build ./cmd/task
go mod tidy
go mod verify

Verifikasi perilaku kegagalan:

Code
./task -file ./tmp/tasks.json add ""
./task -file ./tmp/tasks.json done abc
./task -file ./tmp/tasks.json done 999
printf '{' > ./tmp/broken.json
./task -file ./tmp/broken.json list

Perintah di atas harus gagal tanpa mengganti file JSON rusak. Periksa stdout, stderr, dan exit status:

Code
./task -file ./tmp/tasks.json list >/tmp/task.out 2>/tmp/task.err
printf 'exit=%s\n' "$?"

12. Kriteria Selesai

  • semua contoh dikompilasi dengan versi Go pada go.mod;
  • data bertahan setelah proses berhenti;
  • file tidak ada dianggap daftar kosong, tetapi JSON rusak dilaporkan;
  • write memakai temp file pada direktori tujuan dan rename;
  • input tidak valid menghasilkan pesan jelas di stderr dan status nonzero;
  • test tidak membaca atau menulis data pengguna;
  • go vet, go test, dan go test -race berhasil;
  • binary dapat dibangun tanpa dependency eksternal.

Kesalahan Umum

  1. Menaruh seluruh logika di main. Pisahkan parsing dari domain dan penyimpanan.
  2. Mengabaikan save error setelah mutasi. Laporkan kegagalan; jangan mengumumkan keberhasilan.
  3. Menimpa JSON rusak dengan daftar kosong. Bedakan os.ErrNotExist dari decode error.
  4. Menghasilkan ID dari panjang slice. Penghapusan dapat menyebabkan ID dipakai ulang; cari ID maksimum.
  5. Mencetak error di banyak lapisan. Error menjadi duplikat; cetak sekali di batas proses.
  6. Menggunakan file produksi dalam test. Selalu gunakan t.TempDir() atau fake store.
  7. Menganggap atomic write menyelesaikan multiwriter. Ia mencegah file parsial, bukan lost update.
  8. Menambahkan framework CLI terlalu dini. Pustaka standar cukup untuk empat subcommand sederhana.

Latihan dan Pengembangan

  1. Tambahkan perintah edit <id> <judul> beserta table-driven tests.
  2. Tambahkan filter list --done dan list --pending; tentukan posisi flag secara konsisten.
  3. Uji fungsi run menggunakan bytes.Buffer untuk stdout dan stderr.
  4. Tambahkan field waktu dengan clock yang dapat diinjeksi agar test deterministik.
  5. Tambahkan validasi duplikasi ID ketika membaca data.
  6. Jika benar-benar membutuhkan dua proses penulis, evaluasi locking lintas platform atau database transaksional dan dokumentasikan trade-off.
  7. Tambahkan sinkronisasi direktori setelah rename untuk kebutuhan durability yang lebih ketat pada platform yang mendukungnya.

Ringkasan

  • Proyek memisahkan antarmuka CLI, aturan aplikasi, dan persistensi tanpa abstraksi berlebihan.
  • Validasi ditempatkan pada batas input dan domain; error dibungkus lalu dicetak sekali.
  • JSON store memakai permission terbatas dan pola temp-write-sync-close-rename.
  • Table-driven tests, fake store, dan t.TempDir() menjaga pengujian cepat serta terisolasi.
  • Format, vet, test, race detector, coverage, dan build membentuk verifikasi sebelum rilis.

Sampai jumpa di pelajaran berikutnya

Terima kasih telah belajar.

Anda telah menyelesaikan 10 bab dalam Golang Dasar: Dari Nol hingga CLI Production-Ready. Gunakan fondasi ini untuk berlatih, bereksperimen, dan membagikan kembali apa yang Anda pelajari.

Tirta Afandi (Kikuk Afandi)

© 2026 Tirta Afandi (Kikuk Afandi), licensed CC BY-SA 4.0.

https://creativecommons.org/licenses/by-sa/4.0/