Pengelola tugas menghadirkan tantangan tingkat berikutnya: state yang tersimpan antar sesi. Kita merancang model Task, operasi CRUD, penyimpanan JSON, dan lapisan CLI yang membaca perintah pengguna, semuanya teruji dengan unit test per lapisan.

Seluruh kode sumber proyek tersedia pada bagian Berkas Sumber di bawah halaman ini — satu module Go dengan lima berkas produksi dan tiga berkas test, tanpa dependency eksternal. Setiap bagian pengerjaan menampilkan potongan kode dari berkas yang sama sehingga kondisi proyek tetap utuh di setiap tahap.

Perintah yang Didukung #

Aplikasi bernama tugas mengelola daftar tugas melalui empat subperintah:

SubperintahBentukKeterangan
tambahtugas tambah "<judul>"Menyimpan tugas baru
listtugas list [--semua]Menampilkan tugas; default hanya yang belum selesai
selesaitugas selesai <id>Menandai satu tugas selesai
hapustugas hapus <id>Menghapus satu tugas

Sesi berikut memperlihatkan alur lengkap dari menambah hingga menghapus (penyimpanan diarahkan ke berkas sementara lewat flag -data):

bash
go run . -data "$T/tugas.json" tambah "Belajar Go"
go run . -data "$T/tugas.json" tambah "Tulis dokumentasi"
go run . -data "$T/tugas.json" selesai 2
go run . -data "$T/tugas.json" list
go run . -data "$T/tugas.json" list --semua

Output yang Dihasilkan:

text
Tugas 1 "Belajar Go" tersimpan.
Tugas 2 "Tulis dokumentasi" tersimpan.
Tugas 2 "Tulis dokumentasi" ditandai selesai.
ID  STATUS  JUDUL
1   [ ]     Belajar Go
ID  STATUS  JUDUL
1   [ ]     Belajar Go
2   [x]     Tulis dokumentasi

Data tersimpan pada berkas JSON sehingga tetap ada meskipun program ditutup. Kolom ID, STATUS, dan JUDUL dirapikan oleh text/tabwriter tanpa tabel manual.

Kontrak Exit Code #

Sama seperti kalkulator, aplikasi ini melaporkan hasil lewat exit code yang terdokumentasi:

Exit codeArtiContoh pemicu
0Perintah berhasiltugas tambah "Belajar Go"
1Kesalahan penggunaansubperintah tak dikenal, argumen salah, id bukan angka
2Galat dataid tidak ditemukan, berkas JSON rusak, gagal menulis

Ringkasan Arsitektur #

Proyek memakai satu package main yang dipilah menjadi tiga lapisan tanggung jawab:

BerkasLapisanTanggung jawab
main.goAntarmukaFlag -data, titik masuk, time.Now disuntikkan
cli.goAntarmukaDispatch subperintah, tabwriter, kontrak exit code
task.goDomainStruct Task, validasi, pembandingan, aturan id
storage.goPersistensiMuat/simpan JSON dengan penulisan atomik
task_test.go, storage_test.go, cli_test.goTestTable-driven test per lapisan

Lapisan domain dan persistensi tidak menyentuh os.Args maupun os.Exit sama sekali — keduanya bekerja dengan nilai murni sehingga dapat diuji tanpa terminal. Seluruh kebutuhan terminal terkumpul di cli.go melalui satu fungsi Run yang menerima argumen, penyimpanan, jam, dan dua io.Writer. Bentuk injection inilah yang membuat alur CLI sekalipun dapat diuji dengan bytes.Buffer.

Prasyarat #

Sebelum mulai, pastikan Anda sudah membaca chapter berikut:

Menjalankan Proyek #

Dari direktori proyek yang berisi go.mod, jalankan aplikasi tanpa kompilasi terpisah. Untuk mencoba tanpa menyentuh berkas di direktori kerja, arahkan penyimpanan ke berkas sementara:

bash
T=$(mktemp -d)
go run . -data "$T/tugas.json" tambah "Belajar Go"

Output yang Dihasilkan:

text
Tugas 1 "Belajar Go" tersimpan.

Bangun biner mandiri bila ingin memakai aplikasi berulang kali, lalu jalankan tesnya:

bash
go build -o tugas .
./tugas list
go test ./...

Tahapan Pengerjaan #

Proyek dikerjakan dalam lima bagian berurutan. Setiap bagian menghasilkan aplikasi yang tetap dapat dijalankan:

  1. Bagian 1: Struktur Aplikasi — arsitektur berlapis, pembagian berkas, dan titik masuk.
  2. Bagian 2: Model Task — struct Task, validasi, dan aturan bisnisnya.
  3. Bagian 3: Operasi CRUD — subperintah tambah, list, selesai, dan hapus.
  4. Bagian 4: Penyimpanan JSON — muat dan simpan daftar tugas dengan aman.
  5. Bagian 5: Pengujian Aplikasi — test model, penyimpanan, dan alur CLI.

Mulailah dari Bagian 1 dan ikuti urutannya karena setiap bagian dibangun di atas keputusan bagian sebelumnya.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

cli_test.go

Unduh berkas
go
package 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 berkas
go
package 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 berkas
go
package 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 berkas
go
package 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 berkas
go
package 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 berkas
go
package 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 berkas
go
package 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
}