Empat berkas produksi sudah berdiri, dan setiap janjinya — judul ditolak bila kosong, berkas tidak pernah setengah tertulis, exit code sesuai kontrak — kini harus dibuktikan. Proyek ini menguji ketiga lapisannya secara terpisah: domain diuji sebagai nilai murni, penyimpanan diuji pada direktori sementara, dan alur CLI diuji tanpa terminal sama sekali. Kunci semuanya satu: waktu yang dibekukan.
Waktu Beku untuk Test Deterministik #
Test tidak boleh menghasilkan hasil berbeda antar eksekusi, padahal Task.Dibuat berisi waktu. Solusinya ada di awal task_test.go:
// waktuBeku dipakai seluruh test agar hasil perbandingan deterministik.
var waktuBeku = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)
// tugasContoh mengembalikan salinan segar daftar tugas untuk test.
func tugasContoh() []Task {
return []Task{
{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku},
{ID: 2, Judul: "Menulis dokumentasi", Selesai: true, Dibuat: waktuBeku.Add(2 * time.Hour)},
{ID: 3, Judul: "Rilis aplikasi", Dibuat: waktuBeku.Add(4 * time.Hour)},
}
}Variabel waktuBeku pada baris 11 memegang satu instan waktu tetap, dan seluruh test memakainya. Fungsi tugasContoh mengembalikan salinan segar setiap dipanggil sehingga satu test tidak mencemari test lain — id 1, 2, 3 dengan selisih waktu dua jam. Berkat suntikan jam dari Bagian 1, waktu beku yang sama juga mengalir ke test CLI tanpa mengubah satu baris produksi pun.
Menguji Domain #
Test domain mengikuti pola table-driven dengan sentinel error:
func TestValidasiJudul(t *testing.T) {
cases := []struct {
nama string
judul string
wantErrSent error
}{
{"judul normal", "Belajar Go", nil},
{"judul dengan spasi di tepi", " Belajar Go ", nil},
{"judul kosong", "", ErrJudulKosong},
{"judul hanya spasi", " ", ErrJudulKosong},
{"judul hanya tab", "\t", ErrJudulKosong},
}
for _, c := range cases {
t.Run(c.nama, func(t *testing.T) {
err := ValidasiJudul(c.judul)
if c.wantErrSent == nil {
if err != nil {
t.Fatalf("ValidasiJudul(%q) error tak terduga: %v", c.judul, err)
}
return
}
if !errors.Is(err, c.wantErrSent) {
t.Errorf("ValidasiJudul(%q) error = %v; want %v", c.judul, err, c.wantErrSent)
}
})
}
}Tabel kasus memuat kolom wantErrSent bertipe error: baris 30-32 mengisinya dengan ErrJudulKosong untuk tiga judul yang harus ditolak, dan nil untuk judul yang sah. Cabang pada baris 39-44 memisahkan kedua jenis kasus — kasus sukses cukup memastikan tidak ada error, sedangkan kasus gagal diperiksa dengan errors.Is. Test TestEqual dan TestBandingkan memakai tabel yang sama untuk membuktikan perbandingan waktu lintas zona: jam 08:00 UTC dan 15:00 WIB dianggap setara.
Menguji Penyimpanan dengan t.TempDir #
Test penyimpanan tidak pernah menyentuh direktori kerja:
func TestSimpanMuat(t *testing.T) {
p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
asli := tugasContoh()
if err := p.Simpan(asli); err != nil {
t.Fatalf("Simpan error tak terduga: %v", err)
}
dimuat, err := p.Muat()
if err != nil {
t.Fatalf("Muat error tak terduga: %v", err)
}
if len(dimuat) != len(asli) {
t.Fatalf("Muat mengembalikan %d tugas; want %d", len(dimuat), len(asli))
}
for i := range asli {
if !dimuat[i].Equal(asli[i]) {
t.Errorf("tugas ke-%d berbeda setelah muat: got %+v; want %+v", i, dimuat[i], asli[i])
}
}
}t.TempDir() pada baris 10 membuat direktori unik per test dan menghapusnya otomatis saat test selesai — dua test bisa berjalan paralel tanpa berebut berkas. Setelah Simpan lalu Muat, setiap elemen dibandingkan memakai Equal dari model, bukan ==, sehingga perbedaan representasi waktu tetap terdeteksi dan tidak lolos begitu saja.
Test berikut memeriksa isi berkas hingga byte terakhir:
func TestSimpanIsiBerkas(t *testing.T) {
direktori := t.TempDir()
p := PenyimpananBaru(filepath.Join(direktori, "tugas.json"))
if err := p.Simpan([]Task{{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}}); err != nil {
t.Fatalf("Simpan error tak terduga: %v", err)
}
data, err := os.ReadFile(filepath.Join(direktori, "tugas.json"))
if err != nil {
t.Fatalf("membaca berkas hasil Simpan: %v", err)
}
want := `[
{
"id": 1,
"judul": "Belajar Go",
"selesai": false,
"dibuat": "2026-01-15T08:00:00Z"
}
]
`
if string(data) != want {
t.Errorf("isi berkas = %q; want %q", data, want)
}
sisa, err := filepath.Glob(filepath.Join(direktori, "tugas-*.tmp"))
if err != nil {
t.Fatalf("Glob error tak terduga: %v", err)
}
if len(sisa) != 0 {
t.Errorf("berkas sementara tertinggal setelah Simpan: %v", sisa)
}
}Dua klaim diverifikasi sekaligus: isi berkas sama persis dengan JSON berindentasi lengkap dengan waktu beku (baris 68-79), dan tidak ada berkas sementara tugas-*.tmp yang tertinggal setelah Simpan (baris 81-87) — bukti bahwa pembersihan pada Bagian 4 bekerja. Test tambahan memeriksa Muat pada berkas yang belum ada (mengembalikan daftar kosong) dan pada JSON rusak (menghasilkan error).
Menguji Alur CLI Tanpa Terminal #
Karena Run menerima io.Writer, test CLI cukup mengirim bytes.Buffer:
// penggunaUji menjalankan Run dengan penyimpanan baru berisi t.TempDir(),
// waktu beku, dan penampung keluaran. Hasilnya stdout, stderr, dan exit code.
func penggunaUji(t *testing.T, args ...string) (string, string, int) {
t.Helper()
p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
var out, errOut bytes.Buffer
exit := Run(args, p, func() time.Time { return waktuBeku }, &out, &errOut)
return out.String(), errOut.String(), exit
}Helper penggunaUji merangkai seluruh dependency: penyimpanan segar di t.TempDir(), jam beku, dan dua penampung keluaran. Test alur lengkap lalu memainkan sesi pengguna dari tambah hingga hapus:
func TestRunAlurLengkap(t *testing.T) {
p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
sekarang := func() time.Time { return waktuBeku }
jalankan := func(wantExit int, args ...string) string {
t.Helper()
var out, errOut bytes.Buffer
if got := Run(args, p, sekarang, &out, &errOut); got != wantExit {
t.Fatalf("Run(%v) exit = %d; want %d", args, got, wantExit)
}
if errOut.Len() > 0 {
t.Fatalf("Run(%v) stderr tak terduga: %q", args, errOut.String())
}
return out.String()
}
stdout := jalankan(exitSukses, "tambah", "Belajar Go")
if stdout != "Tugas 1 \"Belajar Go\" tersimpan.\n" {
t.Errorf("tambah stdout = %q", stdout)
}
jalankan(exitSukses, "tambah", "Tulis dokumentasi")
jalankan(exitSukses, "selesai", "2")
stdout = jalankan(exitSukses, "list")
want := "ID STATUS JUDUL\n1 [ ] Belajar Go\n"
if stdout != want {
t.Errorf("list stdout = %q; want %q", stdout, want)
}
stdout = jalankan(exitSukses, "list", "--semua")
want = "ID STATUS JUDUL\n1 [ ] Belajar Go\n2 [x] Tulis dokumentasi\n"
if stdout != want {
t.Errorf("list --semua stdout = %q; want %q", stdout, want)
}
jalankan(exitSukses, "hapus", "1")
stdout = jalankan(exitSukses, "list")
if stdout != "Tidak ada tugas yang belum selesai.\n" {
t.Errorf("list setelah hapus stdout = %q", stdout)
}Fungsi jalankan pada baris 40-50 memeriksa exit code dan memastikan stderr kosong pada jalur sukses, lalu mengembalikan isi stdout. Baris 59-69 membandingkan keluaran list secara utuh termasuk perataan kolom tabwriter — format tabel ikut terlindungi test. Test TestRunKesalahan mengulangi pola yang sama untuk sembilan kasus kegagalan, dari subperintah tak dikenal hingga id tidak ditemukan, masing-masing dengan exit code dan potongan pesan yang diharapkan.
Menjalankan Seluruh Test #
Dari direktori proyek:
go test -v ./...Output yang Dihasilkan:
=== RUN TestRunTanpaSubperintah
--- PASS: TestRunTanpaSubperintah (0.00s)
=== RUN TestRunAlurLengkap
--- PASS: TestRunAlurLengkap (0.00s)
=== RUN TestRunTulisJSON
--- PASS: TestRunTulisJSON (0.00s)
=== RUN TestRunKesalahan
=== RUN TestRunKesalahan/subperintah_tak_dikenal
=== RUN TestRunKesalahan/tambah_tanpa_judul
=== RUN TestRunKesalahan/tambah_dua_judul
=== RUN TestRunKesalahan/tambah_judul_kosong
=== RUN TestRunKesalahan/selesai_id_bukan_angka
=== RUN TestRunKesalahan/selesai_id_negatif
=== RUN TestRunKesalahan/selesai_id_tidak_ada
=== RUN TestRunKesalahan/hapus_id_tidak_ada
=== RUN TestRunKesalahan/list_flag_tak_dikenal
--- PASS: TestRunKesalahan (0.00s)
--- PASS: TestRunKesalahan/subperintah_tak_dikenal (0.00s)
--- PASS: TestRunKesalahan/tambah_tanpa_judul (0.00s)
--- PASS: TestRunKesalahan/tambah_dua_judul (0.00s)
--- PASS: TestRunKesalahan/tambah_judul_kosong (0.00s)
--- PASS: TestRunKesalahan/selesai_id_bukan_angka (0.00s)
--- PASS: TestRunKesalahan/selesai_id_negatif (0.00s)
--- PASS: TestRunKesalahan/selesai_id_tidak_ada (0.00s)
--- PASS: TestRunKesalahan/hapus_id_tidak_ada (0.00s)
--- PASS: TestRunKesalahan/list_flag_tak_dikenal (0.00s)
=== RUN TestRunListKosong
--- PASS: TestRunListKosong (0.00s)
=== RUN TestSimpanMuat
--- PASS: TestSimpanMuat (0.00s)
=== RUN TestMuatBerkasBelumAda
--- PASS: TestMuatBerkasBelumAda (0.00s)
=== RUN TestMuatJSONRusak
--- PASS: TestMuatJSONRusak (0.00s)
=== RUN TestSimpanIsiBerkas
--- PASS: TestSimpanIsiBerkas (0.00s)
=== RUN TestSimpanDaftarKosong
--- PASS: TestSimpanDaftarKosong (0.00s)
=== RUN TestSimpanMenggantiIsi
--- PASS: TestSimpanMenggantiIsi (0.00s)
=== RUN TestValidasiJudul
=== RUN TestValidasiJudul/judul_normal
=== RUN TestValidasiJudul/judul_dengan_spasi_di_tepi
=== RUN TestValidasiJudul/judul_kosong
=== RUN TestValidasiJudul/judul_hanya_spasi
=== RUN TestValidasiJudul/judul_hanya_tab
--- PASS: TestValidasiJudul (0.00s)
--- PASS: TestValidasiJudul/judul_normal (0.00s)
--- PASS: TestValidasiJudul/judul_dengan_spasi_di_tepi (0.00s)
--- PASS: TestValidasiJudul/judul_kosong (0.00s)
--- PASS: TestValidasiJudul/judul_hanya_spasi (0.00s)
--- PASS: TestValidasiJudul/judul_hanya_tab (0.00s)
=== RUN TestEqual
=== RUN TestEqual/isi_sama_persis
=== RUN TestEqual/id_berbeda
=== RUN TestEqual/judul_berbeda
=== RUN TestEqual/status_berbeda
=== RUN TestEqual/waktu_berbeda
=== RUN TestEqual/instan_sama_zona_beda
--- PASS: TestEqual (0.00s)
--- PASS: TestEqual/isi_sama_persis (0.00s)
--- PASS: TestEqual/id_berbeda (0.00s)
--- PASS: TestEqual/judul_berbeda (0.00s)
--- PASS: TestEqual/status_berbeda (0.00s)
--- PASS: TestEqual/waktu_berbeda (0.00s)
--- PASS: TestEqual/instan_sama_zona_beda (0.00s)
=== RUN TestBandingkan
=== RUN TestBandingkan/dibuat_lebih_dulu
=== RUN TestBandingkan/dibuat_lebih_lambat
=== RUN TestBandingkan/waktu_sama_id_lebih_kecil
=== RUN TestBandingkan/waktu_sama_id_lebih_besar
--- PASS: TestBandingkan (0.00s)
--- PASS: TestBandingkan/dibuat_lebih_dulu (0.00s)
--- PASS: TestBandingkan/dibuat_lebih_lambat (0.00s)
--- PASS: TestBandingkan/waktu_sama_id_lebih_kecil (0.00s)
--- PASS: TestBandingkan/waktu_sama_id_lebih_besar (0.00s)
=== RUN TestIDBerikutnya
=== RUN TestIDBerikutnya/daftar_kosong
=== RUN TestIDBerikutnya/berurutan
=== RUN TestIDBerikutnya/id_terbesar_di_tengah
--- PASS: TestIDBerikutnya (0.00s)
--- PASS: TestIDBerikutnya/daftar_kosong (0.00s)
--- PASS: TestIDBerikutnya/berurutan (0.00s)
--- PASS: TestIDBerikutnya/id_terbesar_di_tengah (0.00s)
=== RUN TestTugasAktif
--- PASS: TestTugasAktif (0.00s)
=== RUN TestCariIndeks
--- PASS: TestCariIndeks (0.00s)
=== RUN TestTaskMarshalJSON
--- PASS: TestTaskMarshalJSON (0.00s)
PASS
ok example.com/pengelola-tugas 0.009sSebelum menyebut proyek selesai, tiga pemeriksaan kualitas dijalankan dari direktori proyek: gofmt -l . memastikan format baku, go vet ./... mencari pola kode mencurigakan, dan go build ./... memastikan seluruh paket terkompilasi. Ketiganya selesai tanpa mencetak apa pun — hasil yang diharapkan.
Seluruh berkas test beserta kode produksi yang diujinya dapat dilihat pada bagian Berkas Sumber di bawah halaman ini.
Ringkasan dan Penutup Proyek #
Lima bagian lengkap: arsitektur tiga lapisan, model dengan aturan bisnis, empat subperintah CRUD, penyimpanan JSON atomik, dan test yang membekukan waktu untuk menjaga semuanya tetap deterministik. Aplikasi ini juga menutup satu pola penting yang belum tersentuh kalkulator: state yang hidup lebih lama dari prosesnya. Proyek berikutnya, Web Server , membawa pola serupa ke jaringan — menerima request dari klien, bukan lagi argumen dari terminal.
Referensi #
- Dokumentasi testing.T.TempDir — direktori sementara per test beserta pembersihannya.
- Dokumentasi package testing
— referensi resmi
t.Run,t.Helper, dant.Fatalf. - Tutorial: Add a test — tutorial resmi menulis test pertama.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
cli_test.go
Unduh berkaspackage main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// penggunaUji menjalankan Run dengan penyimpanan baru berisi t.TempDir(),
// waktu beku, dan penampung keluaran. Hasilnya stdout, stderr, dan exit code.
func penggunaUji(t *testing.T, args ...string) (string, string, int) {
t.Helper()
p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
var out, errOut bytes.Buffer
exit := Run(args, p, func() time.Time { return waktuBeku }, &out, &errOut)
return out.String(), errOut.String(), exit
}
func TestRunTanpaSubperintah(t *testing.T) {
stdout, stderr, exit := penggunaUji(t)
if exit != exitPenggunaan {
t.Errorf("exit = %d; want %d", exit, exitPenggunaan)
}
if stdout != "" {
t.Errorf("stdout = %q; want kosong", stdout)
}
if !strings.Contains(stderr, "Penggunaan:") {
t.Errorf("stderr = %q; want pesan penggunaan", stderr)
}
}
func TestRunAlurLengkap(t *testing.T) {
p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
sekarang := func() time.Time { return waktuBeku }
jalankan := func(wantExit int, args ...string) string {
t.Helper()
var out, errOut bytes.Buffer
if got := Run(args, p, sekarang, &out, &errOut); got != wantExit {
t.Fatalf("Run(%v) exit = %d; want %d", args, got, wantExit)
}
if errOut.Len() > 0 {
t.Fatalf("Run(%v) stderr tak terduga: %q", args, errOut.String())
}
return out.String()
}
stdout := jalankan(exitSukses, "tambah", "Belajar Go")
if stdout != "Tugas 1 \"Belajar Go\" tersimpan.\n" {
t.Errorf("tambah stdout = %q", stdout)
}
jalankan(exitSukses, "tambah", "Tulis dokumentasi")
jalankan(exitSukses, "selesai", "2")
stdout = jalankan(exitSukses, "list")
want := "ID STATUS JUDUL\n1 [ ] Belajar Go\n"
if stdout != want {
t.Errorf("list stdout = %q; want %q", stdout, want)
}
stdout = jalankan(exitSukses, "list", "--semua")
want = "ID STATUS JUDUL\n1 [ ] Belajar Go\n2 [x] Tulis dokumentasi\n"
if stdout != want {
t.Errorf("list --semua stdout = %q; want %q", stdout, want)
}
jalankan(exitSukses, "hapus", "1")
stdout = jalankan(exitSukses, "list")
if stdout != "Tidak ada tugas yang belum selesai.\n" {
t.Errorf("list setelah hapus stdout = %q", stdout)
}
}
func TestRunTulisJSON(t *testing.T) {
berkas := filepath.Join(t.TempDir(), "tugas.json")
p := PenyimpananBaru(berkas)
var out, errOut bytes.Buffer
if got := Run([]string{"tambah", "Belajar Go"}, p, func() time.Time { return waktuBeku }, &out, &errOut); got != exitSukses {
t.Fatalf("Run tambah exit = %d; want %d", got, exitSukses)
}
data, err := os.ReadFile(berkas)
if err != nil {
t.Fatalf("membaca berkas hasil tambah: %v", err)
}
want := `[
{
"id": 1,
"judul": "Belajar Go",
"selesai": false,
"dibuat": "2026-01-15T08:00:00Z"
}
]
`
if string(data) != want {
t.Errorf("isi berkas = %q; want %q", data, want)
}
}
func TestRunKesalahan(t *testing.T) {
cases := []struct {
nama string
args []string
wantExit int
wantErrSepenggal string
}{
{"subperintah tak dikenal", []string{"lihat"}, exitPenggunaan, "tidak dikenal"},
{"tambah tanpa judul", []string{"tambah"}, exitPenggunaan, "membutuhkan satu judul"},
{"tambah dua judul", []string{"tambah", "a", "b"}, exitPenggunaan, "membutuhkan satu judul"},
{"tambah judul kosong", []string{"tambah", " "}, exitPenggunaan, "judul tugas tidak boleh kosong"},
{"selesai id bukan angka", []string{"selesai", "abc"}, exitPenggunaan, "harus berupa angka"},
{"selesai id negatif", []string{"selesai", "-1"}, exitPenggunaan, "harus berupa angka"},
{"selesai id tidak ada", []string{"selesai", "9"}, exitData, "tidak ditemukan"},
{"hapus id tidak ada", []string{"hapus", "9"}, exitData, "tidak ditemukan"},
{"list flag tak dikenal", []string{"list", "--salah"}, exitPenggunaan, "flag provided but not defined"},
}
for _, c := range cases {
t.Run(c.nama, func(t *testing.T) {
stdout, stderr, exit := penggunaUji(t, c.args...)
if exit != c.wantExit {
t.Errorf("exit = %d; want %d", exit, c.wantExit)
}
if stdout != "" {
t.Errorf("stdout = %q; want kosong", stdout)
}
if !strings.Contains(stderr, c.wantErrSepenggal) {
t.Errorf("stderr = %q; want pesan memuat %q", stderr, c.wantErrSepenggal)
}
})
}
}
func TestRunListKosong(t *testing.T) {
stdout, _, exit := penggunaUji(t, "list")
if exit != exitSukses {
t.Errorf("exit = %d; want %d", exit, exitSukses)
}
if stdout != "Belum ada tugas tersimpan.\n" {
t.Errorf("list pada penyimpanan kosong stdout = %q", stdout)
}
}
cli.go
Unduh berkaspackage main
import (
"flag"
"fmt"
"io"
"slices"
"strconv"
"text/tabwriter"
"time"
)
// Kontrak exit code program. Nilai ini dibaca oleh skrip lain
// atau pipeline yang memanggil tugas.
const (
exitSukses = 0 // perintah berhasil
exitPenggunaan = 1 // subperintah tak dikenal atau argumen salah
exitData = 2 // id tak ditemukan atau kegagalan penyimpanan
)
// Run menjalankan satu subperintah dan mengembalikan exit code.
// Parameter sekarang menyediakan waktu saat ini sehingga test dapat
// membekukannya; out menampung hasil perintah dan errOut menampung
// pesan galat agar keduanya tidak tercampur.
func Run(args []string, penyimpanan *Penyimpanan, sekarang func() time.Time, out, errOut io.Writer) int {
if len(args) == 0 {
tampilkanPenggunaan(errOut)
return exitPenggunaan
}
perintah, sisa := args[0], args[1:]
switch perintah {
case "tambah":
return perintahTambah(sisa, penyimpanan, sekarang, out, errOut)
case "list":
return perintahList(sisa, penyimpanan, out, errOut)
case "selesai":
return perintahSelesai(sisa, penyimpanan, out, errOut)
case "hapus":
return perintahHapus(sisa, penyimpanan, out, errOut)
default:
fmt.Fprintf(errOut, "subperintah %q tidak dikenal\n", perintah)
tampilkanPenggunaan(errOut)
return exitPenggunaan
}
}
func tampilkanPenggunaan(errOut io.Writer) {
fmt.Fprintln(errOut, "Penggunaan:")
fmt.Fprintln(errOut, " tugas [-data <berkas>] tambah \"<judul>\"")
fmt.Fprintln(errOut, " tugas [-data <berkas>] list [--semua]")
fmt.Fprintln(errOut, " tugas [-data <berkas>] selesai <id>")
fmt.Fprintln(errOut, " tugas [-data <berkas>] hapus <id>")
}
func perintahTambah(args []string, p *Penyimpanan, sekarang func() time.Time, out, errOut io.Writer) int {
if len(args) != 1 {
fmt.Fprintln(errOut, "perintah tambah membutuhkan satu judul tugas.")
tampilkanPenggunaan(errOut)
return exitPenggunaan
}
judul := args[0]
if err := ValidasiJudul(judul); err != nil {
fmt.Fprintln(errOut, err)
return exitPenggunaan
}
tasks, err := p.Muat()
if err != nil {
fmt.Fprintln(errOut, "galat memuat tugas:", err)
return exitData
}
t := Task{
ID: IDBerikutnya(tasks),
Judul: judul,
Dibuat: sekarang(),
}
tasks = append(tasks, t)
if err := p.Simpan(tasks); err != nil {
fmt.Fprintln(errOut, "galat menyimpan tugas:", err)
return exitData
}
fmt.Fprintf(out, "Tugas %d \"%s\" tersimpan.\n", t.ID, t.Judul)
return exitSukses
}
func perintahList(args []string, p *Penyimpanan, out, errOut io.Writer) int {
semua := false
fs := flag.NewFlagSet("list", flag.ContinueOnError)
fs.SetOutput(errOut)
fs.BoolVar(&semua, "semua", false, "tampilkan juga tugas yang sudah selesai")
if err := fs.Parse(args); err != nil {
return exitPenggunaan
}
tasks, err := p.Muat()
if err != nil {
fmt.Fprintln(errOut, "galat memuat tugas:", err)
return exitData
}
terpilih := tasks
if !semua {
terpilih = TugasAktif(tasks)
}
switch {
case len(tasks) == 0:
fmt.Fprintln(out, "Belum ada tugas tersimpan.")
case len(terpilih) == 0:
fmt.Fprintln(out, "Tidak ada tugas yang belum selesai.")
default:
slices.SortStableFunc(terpilih, Bandingkan)
cetakTabel(out, terpilih)
}
return exitSukses
}
func cetakTabel(out io.Writer, tasks []Task) {
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "ID\tSTATUS\tJUDUL")
for _, t := range tasks {
status := "[ ]"
if t.Selesai {
status = "[x]"
}
fmt.Fprintf(w, "%d\t%s\t%s\n", t.ID, status, t.Judul)
}
w.Flush()
}
func perintahSelesai(args []string, p *Penyimpanan, out, errOut io.Writer) int {
id, ok := parseID(args, "selesai", errOut)
if !ok {
return exitPenggunaan
}
tasks, err := p.Muat()
if err != nil {
fmt.Fprintln(errOut, "galat memuat tugas:", err)
return exitData
}
i := CariIndeks(tasks, id)
if i < 0 {
fmt.Fprintf(errOut, "tugas dengan id %d tidak ditemukan.\n", id)
return exitData
}
tasks[i].Selesai = true
if err := p.Simpan(tasks); err != nil {
fmt.Fprintln(errOut, "galat menyimpan tugas:", err)
return exitData
}
fmt.Fprintf(out, "Tugas %d \"%s\" ditandai selesai.\n", tasks[i].ID, tasks[i].Judul)
return exitSukses
}
func perintahHapus(args []string, p *Penyimpanan, out, errOut io.Writer) int {
id, ok := parseID(args, "hapus", errOut)
if !ok {
return exitPenggunaan
}
tasks, err := p.Muat()
if err != nil {
fmt.Fprintln(errOut, "galat memuat tugas:", err)
return exitData
}
i := CariIndeks(tasks, id)
if i < 0 {
fmt.Fprintf(errOut, "tugas dengan id %d tidak ditemukan.\n", id)
return exitData
}
terhapus := tasks[i]
tasks = slices.Delete(tasks, i, i+1)
if err := p.Simpan(tasks); err != nil {
fmt.Fprintln(errOut, "galat menyimpan tugas:", err)
return exitData
}
fmt.Fprintf(out, "Tugas %d \"%s\" dihapus.\n", terhapus.ID, terhapus.Judul)
return exitSukses
}
func parseID(args []string, perintah string, errOut io.Writer) (int, bool) {
if len(args) != 1 {
fmt.Fprintf(errOut, "perintah %s membutuhkan satu id tugas.\n", perintah)
tampilkanPenggunaan(errOut)
return 0, false
}
id, err := strconv.Atoi(args[0])
if err != nil || id <= 0 {
fmt.Fprintf(errOut, "id tugas harus berupa angka bulat positif, bukan %q.\n", args[0])
return 0, false
}
return id, true
}
main.go
Unduh berkaspackage main
import (
"flag"
"os"
"time"
)
// berkasDefault adalah lokasi penyimpanan JSON ketika flag -data tidak dipakai.
const berkasDefault = "tugas.json"
func main() {
berkas := flag.String("data", berkasDefault, "berkas penyimpanan JSON")
flag.Parse()
penyimpanan := PenyimpananBaru(*berkas)
// os.Exit dipanggil di luar Run agar defer di dalam Run tetap bekerja.
os.Exit(Run(flag.Args(), penyimpanan, time.Now, os.Stdout, os.Stderr))
}
storage_test.go
Unduh berkaspackage main
import (
"os"
"path/filepath"
"testing"
)
func TestSimpanMuat(t *testing.T) {
p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
asli := tugasContoh()
if err := p.Simpan(asli); err != nil {
t.Fatalf("Simpan error tak terduga: %v", err)
}
dimuat, err := p.Muat()
if err != nil {
t.Fatalf("Muat error tak terduga: %v", err)
}
if len(dimuat) != len(asli) {
t.Fatalf("Muat mengembalikan %d tugas; want %d", len(dimuat), len(asli))
}
for i := range asli {
if !dimuat[i].Equal(asli[i]) {
t.Errorf("tugas ke-%d berbeda setelah muat: got %+v; want %+v", i, dimuat[i], asli[i])
}
}
}
func TestMuatBerkasBelumAda(t *testing.T) {
p := PenyimpananBaru(filepath.Join(t.TempDir(), "belum-ada.json"))
tasks, err := p.Muat()
if err != nil {
t.Fatalf("Muat pada berkas yang belum ada error: %v", err)
}
if len(tasks) != 0 {
t.Errorf("Muat mengembalikan %d tugas; want 0", len(tasks))
}
}
func TestMuatJSONRusak(t *testing.T) {
berkas := filepath.Join(t.TempDir(), "rusak.json")
if err := os.WriteFile(berkas, []byte("bukan json"), 0o600); err != nil {
t.Fatalf("menulis berkas uji: %v", err)
}
p := PenyimpananBaru(berkas)
if _, err := p.Muat(); err == nil {
t.Fatal("Muat pada JSON rusak harus error, tetapi berhasil")
}
}
func TestSimpanIsiBerkas(t *testing.T) {
direktori := t.TempDir()
p := PenyimpananBaru(filepath.Join(direktori, "tugas.json"))
if err := p.Simpan([]Task{{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}}); err != nil {
t.Fatalf("Simpan error tak terduga: %v", err)
}
data, err := os.ReadFile(filepath.Join(direktori, "tugas.json"))
if err != nil {
t.Fatalf("membaca berkas hasil Simpan: %v", err)
}
want := `[
{
"id": 1,
"judul": "Belajar Go",
"selesai": false,
"dibuat": "2026-01-15T08:00:00Z"
}
]
`
if string(data) != want {
t.Errorf("isi berkas = %q; want %q", data, want)
}
sisa, err := filepath.Glob(filepath.Join(direktori, "tugas-*.tmp"))
if err != nil {
t.Fatalf("Glob error tak terduga: %v", err)
}
if len(sisa) != 0 {
t.Errorf("berkas sementara tertinggal setelah Simpan: %v", sisa)
}
}
func TestSimpanDaftarKosong(t *testing.T) {
berkas := filepath.Join(t.TempDir(), "tugas.json")
p := PenyimpananBaru(berkas)
if err := p.Simpan(nil); err != nil {
t.Fatalf("Simpan(nil) error tak terduga: %v", err)
}
data, err := os.ReadFile(berkas)
if err != nil {
t.Fatalf("membaca berkas hasil Simpan: %v", err)
}
if string(data) != "[]\n" {
t.Errorf("isi berkas = %q; want %q", data, "[]\n")
}
}
func TestSimpanMenggantiIsi(t *testing.T) {
p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
if err := p.Simpan(tugasContoh()); err != nil {
t.Fatalf("Simpan pertama error: %v", err)
}
if err := p.Simpan(nil); err != nil {
t.Fatalf("Simpan kedua error: %v", err)
}
dimuat, err := p.Muat()
if err != nil {
t.Fatalf("Muat error tak terduga: %v", err)
}
if len(dimuat) != 0 {
t.Errorf("Muat setelah Simpan kedua mengembalikan %d tugas; want 0", len(dimuat))
}
}
storage.go
Unduh berkaspackage main
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
)
// Penyimpanan mengurus pembacaan dan penulisan daftar tugas
// ke satu berkas JSON.
type Penyimpanan struct {
berkas string
}
// PenyimpananBaru membuat penyimpanan yang menunjuk ke path berkas JSON.
func PenyimpananBaru(berkas string) *Penyimpanan {
return &Penyimpanan{berkas: berkas}
}
// Muat membaca seluruh tugas dari berkas JSON.
// Berkas yang belum ada dianggap daftar kosong untuk aplikasi baru.
func (p *Penyimpanan) Muat() ([]Task, error) {
isi, err := os.ReadFile(p.berkas)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("membaca berkas %s: %w", p.berkas, err)
}
var tasks []Task
if err := json.Unmarshal(isi, &tasks); err != nil {
return nil, fmt.Errorf("mengurai JSON dari %s: %w", p.berkas, err)
}
return tasks, nil
}
// Simpan menulis seluruh tugas ke berkas JSON secara atomik:
// data ditulis dulu ke berkas sementara di direktori yang sama,
// lalu berkas sementara menggantikan berkas asli lewat rename.
// Dengan urutan ini, berkas asli tidak pernah berada dalam keadaan
// setengah tertulis.
func (p *Penyimpanan) Simpan(tasks []Task) error {
if tasks == nil {
tasks = []Task{}
}
data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
return fmt.Errorf("menyusun JSON: %w", err)
}
data = append(data, '\n')
berkasTmp, err := os.CreateTemp(filepath.Dir(p.berkas), "tugas-*.tmp")
if err != nil {
return fmt.Errorf("membuat berkas sementara: %w", err)
}
namaTmp := berkasTmp.Name()
if _, err := berkasTmp.Write(data); err != nil {
berkasTmp.Close()
os.Remove(namaTmp)
return fmt.Errorf("menulis berkas sementara: %w", err)
}
if err := berkasTmp.Close(); err != nil {
os.Remove(namaTmp)
return fmt.Errorf("menutup berkas sementara: %w", err)
}
if err := os.Rename(namaTmp, p.berkas); err != nil {
os.Remove(namaTmp)
return fmt.Errorf("mengganti berkas penyimpanan: %w", err)
}
return nil
}
task_test.go
Unduh berkaspackage main
import (
"encoding/json"
"errors"
"testing"
"time"
)
// waktuBeku dipakai seluruh test agar hasil perbandingan deterministik.
var waktuBeku = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)
// tugasContoh mengembalikan salinan segar daftar tugas untuk test.
func tugasContoh() []Task {
return []Task{
{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku},
{ID: 2, Judul: "Menulis dokumentasi", Selesai: true, Dibuat: waktuBeku.Add(2 * time.Hour)},
{ID: 3, Judul: "Rilis aplikasi", Dibuat: waktuBeku.Add(4 * time.Hour)},
}
}
func TestValidasiJudul(t *testing.T) {
cases := []struct {
nama string
judul string
wantErrSent error
}{
{"judul normal", "Belajar Go", nil},
{"judul dengan spasi di tepi", " Belajar Go ", nil},
{"judul kosong", "", ErrJudulKosong},
{"judul hanya spasi", " ", ErrJudulKosong},
{"judul hanya tab", "\t", ErrJudulKosong},
}
for _, c := range cases {
t.Run(c.nama, func(t *testing.T) {
err := ValidasiJudul(c.judul)
if c.wantErrSent == nil {
if err != nil {
t.Fatalf("ValidasiJudul(%q) error tak terduga: %v", c.judul, err)
}
return
}
if !errors.Is(err, c.wantErrSent) {
t.Errorf("ValidasiJudul(%q) error = %v; want %v", c.judul, err, c.wantErrSent)
}
})
}
}
func TestEqual(t *testing.T) {
cases := []struct {
nama string
t, other Task
wantEqual bool
}{
{"isi sama persis", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, true},
{"id berbeda", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 2, Judul: "Belajar Go", Dibuat: waktuBeku}, false},
{"judul berbeda", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 1, Judul: "Belajar PHP", Dibuat: waktuBeku}, false},
{"status berbeda", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 1, Judul: "Belajar Go", Selesai: true, Dibuat: waktuBeku}, false},
{"waktu berbeda", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku.Add(time.Hour)}, false},
{"instan sama zona beda", Task{ID: 1, Dibuat: waktuBeku}, Task{ID: 1, Dibuat: time.Date(2026, 1, 15, 15, 0, 0, 0, time.FixedZone("WIB", 7*60*60))}, true},
}
for _, c := range cases {
t.Run(c.nama, func(t *testing.T) {
if got := c.t.Equal(c.other); got != c.wantEqual {
t.Errorf("Equal = %v; want %v", got, c.wantEqual)
}
})
}
}
func TestBandingkan(t *testing.T) {
cases := []struct {
nama string
t, other Task
wantSign int // -1, 0, atau 1
}{
{"dibuat lebih dulu", Task{ID: 1, Dibuat: waktuBeku}, Task{ID: 2, Dibuat: waktuBeku.Add(time.Hour)}, -1},
{"dibuat lebih lambat", Task{ID: 2, Dibuat: waktuBeku.Add(time.Hour)}, Task{ID: 1, Dibuat: waktuBeku}, 1},
{"waktu sama id lebih kecil", Task{ID: 1, Dibuat: waktuBeku}, Task{ID: 2, Dibuat: waktuBeku}, -1},
{"waktu sama id lebih besar", Task{ID: 3, Dibuat: waktuBeku}, Task{ID: 2, Dibuat: waktuBeku}, 1},
}
for _, c := range cases {
t.Run(c.nama, func(t *testing.T) {
got := Bandingkan(c.t, c.other)
if got == 0 || (got < 0) != (c.wantSign < 0) {
t.Errorf("Bandingkan = %d; want tanda %d", got, c.wantSign)
}
})
}
}
func TestIDBerikutnya(t *testing.T) {
cases := []struct {
nama string
tasks []Task
want int
}{
{"daftar kosong", nil, 1},
{"berurutan", tugasContoh(), 4},
{"id terbesar di tengah", []Task{{ID: 7}, {ID: 3}, {ID: 5}}, 8},
}
for _, c := range cases {
t.Run(c.nama, func(t *testing.T) {
if got := IDBerikutnya(c.tasks); got != c.want {
t.Errorf("IDBerikutnya = %d; want %d", got, c.want)
}
})
}
}
func TestTugasAktif(t *testing.T) {
aktif := TugasAktif(tugasContoh())
if len(aktif) != 2 {
t.Fatalf("TugasAktif mengembalikan %d tugas; want 2", len(aktif))
}
for _, tsk := range aktif {
if tsk.Selesai {
t.Errorf("tugas %d selesai tetapi ikut terpilih", tsk.ID)
}
}
if got := TugasAktif(nil); len(got) != 0 {
t.Errorf("TugasAktif(nil) mengembalikan %d tugas; want 0", len(got))
}
}
func TestCariIndeks(t *testing.T) {
tasks := tugasContoh()
if got := CariIndeks(tasks, 2); got != 1 {
t.Errorf("CariIndeks(tasks, 2) = %d; want 1", got)
}
if got := CariIndeks(tasks, 9); got != -1 {
t.Errorf("CariIndeks(tasks, 9) = %d; want -1", got)
}
if got := CariIndeks(nil, 1); got != -1 {
t.Errorf("CariIndeks(nil, 1) = %d; want -1", got)
}
}
func TestTaskMarshalJSON(t *testing.T) {
b, err := json.Marshal(Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku})
if err != nil {
t.Fatalf("Marshal error tak terduga: %v", err)
}
want := `{"id":1,"judul":"Belajar Go","selesai":false,"dibuat":"2026-01-15T08:00:00Z"}`
if string(b) != want {
t.Errorf("Marshal = %s; want %s", b, want)
}
}
task.go
Unduh berkaspackage main
import (
"errors"
"strings"
"time"
)
// Task menyimpan satu tugas pada daftar tugas.
type Task struct {
ID int `json:"id"`
Judul string `json:"judul"`
Selesai bool `json:"selesai"`
Dibuat time.Time `json:"dibuat"`
}
// ErrJudulKosong adalah sentinel error untuk judul yang kosong
// atau hanya berisi spasi.
var ErrJudulKosong = errors.New("judul tugas tidak boleh kosong")
// ValidasiJudul memeriksa bahwa judul berisi teks yang berarti,
// bukan string kosong ataupun hanya spasi.
func ValidasiJudul(judul string) error {
if strings.TrimSpace(judul) == "" {
return ErrJudulKosong
}
return nil
}
// Equal melaporkan apakah dua Task memiliki isi field yang sama persis.
// Perbandingan waktu memakai time.Time.Equal sehingga dua instan waktu
// yang sama tetap dianggap setara meski zona waktunya berbeda.
func (t Task) Equal(other Task) bool {
return t.ID == other.ID &&
t.Judul == other.Judul &&
t.Selesai == other.Selesai &&
t.Dibuat.Equal(other.Dibuat)
}
// Bandingkan membandingkan dua Task menurut waktu dibuat, dengan ID
// sebagai pemecah seri. Hasil negatif bila t dibuat lebih dulu, nol
// bila keduanya setara, dan positif bila t dibuat lebih lambat.
func Bandingkan(t, other Task) int {
if c := t.Dibuat.Compare(other.Dibuat); c != 0 {
return c
}
return t.ID - other.ID
}
// IDBerikutnya menghitung id untuk tugas baru: id terbesar saat ini + 1.
// Id tidak pernah dipakai ulang meskipun ada tugas yang dihapus.
func IDBerikutnya(tasks []Task) int {
terbesar := 0
for _, t := range tasks {
if t.ID > terbesar {
terbesar = t.ID
}
}
return terbesar + 1
}
// TugasAktif mengembalikan hanya tugas yang belum selesai.
func TugasAktif(tasks []Task) []Task {
var aktif []Task
for _, t := range tasks {
if !t.Selesai {
aktif = append(aktif, t)
}
}
return aktif
}
// CariIndeks mencari indeks tugas berdasarkan id pada slice.
// Fungsi mengembalikan -1 bila id tidak ditemukan.
func CariIndeks(tasks []Task, id int) int {
for i, t := range tasks {
if t.ID == id {
return i
}
}
return -1
}