Program sudah berjalan, tetapi “sudah pernah benar” bukan jaminan “tetap benar”. Setiap kali kita menyentuh calculator.go — menambah kasus, mengganti format pesan, memperbaiki satu cabang — seluruh perilaku lama berisiko berubah diam-diam. Test otomatis menjaga kontrak tersebut: sekali ditulis, ribuan kali bisa dipertanggungjawabkan. Pada bagian terakhir ini kita menguji kalkulator dengan tiga bentuk test yang saling melengkapi.

Tabel Kasus untuk TestTambah #

Bentuk test paling idiomatik di Go adalah table-driven test: seluruh kasus dihimpun dalam satu tabel, lalu satu loop memeriksa kasus demi kasus. TestTambah memakai pola tersebut lengkap dengan subtest:

go
func TestTambah(t *testing.T) {
	cases := []struct {
		nama       string
		a, b, want float64
	}{
		{"dua bilangan bulat", 2, 3, 5},
		{"dua bilangan negatif", -4, -6, -10},
		{"campuran positif negatif", 7, -2, 5},
		{"bilangan desimal", 1.5, 2.25, 3.75},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			if got := Tambah(c.a, c.b); got != c.want {
				t.Errorf("Tambah(%g, %g) = %g; want %g", c.a, c.b, got, c.want)
			}
		})
	}
}

Mari kita telaah baris demi baris:

  1. Baris 12-15 (cases := []struct {...}): tabel kasus dideklarasikan sebagai slice struct anonim dengan kolom nama, dua masukan a dan b, serta hasil harapan want. Struct anonim cukup karena tabel hanya dipakai fungsi test ini.
  2. Baris 16-19 (empat elemen tabel): kasus dipilih untuk mewakili kategori berbeda — bilangan bulat positif, negatif, campuran, dan desimal. Menambah kasus baru cukup menambah satu baris tabel.
  3. Baris 22-23 (for _, c := range cases dan t.Run): setiap kasus dijalankan sebagai subtest bernama. Nama subtest diambil dari kolom nama sehingga kegagalan langsung menunjuk kasusnya.
  4. Baris 24-26 (if got := Tambah(...)): hasil aktual (got) dibandingkan dengan want. Pesan kegagalan memuat masukan dan kedua nilai, format yang sudah kita kenal dari chapter Table-Driven Test .

TestKurang dan TestKali mengikuti pola yang sama tanpa t.Run — variasi yang menunjukkan bahwa subtest adalah pilihan, bukan kewajiban. Perbandingan got != c.want aman dipakai karena seluruh kasus uji memakai angka dengan representasi biner persis; untuk hasil perhitungan yang meleset seperti 0.1 + 0.2, bandingkan selisihnya dengan toleransi seperti yang dibahas pada Bagian 2.

Satu Tabel untuk Jalur Sukses dan Kegagalan #

TestBagi menggabungkan jalur sukses dan kegagalan dalam satu tabel berkat kolom wantErrSent:

go
func TestBagi(t *testing.T) {
	cases := []struct {
		nama        string
		a, b, want  float64
		wantErrSent error
	}{
		{"hasil desimal", 10, 4, 2.5, nil},
		{"hasil negatif", -9, 3, -3, nil},
		{"dibagi satu", 7.5, 1, 7.5, nil},
		{"pembagian dengan nol", 10, 0, 0, ErrPembagianNol},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			got, err := Bagi(c.a, c.b)

			if c.wantErrSent != nil {
				if !errors.Is(err, c.wantErrSent) {
					t.Fatalf("Bagi(%g, %g) error = %v; want %v", c.a, c.b, err, c.wantErrSent)
				}
				return
			}

			if err != nil {
				t.Fatalf("Bagi(%g, %g) error tak terduga: %v", c.a, c.b, err)
			}
			if got != c.want {
				t.Errorf("Bagi(%g, %g) = %g; want %g", c.a, c.b, got, c.want)
			}
		})
	}
}

Dua hal membuat test ini berbeda. Pertama, baris 68 mendeklarasikan kolom wantErrSent bertipe error dengan nil sebagai tanda kasus sukses — baris 73 mengisinya dengan sentinel ErrPembagianNol untuk kasus gagal. Kedua, baris 81 memeriksa error dengan errors.Is, bukan membandingkan teks pesan; pemeriksaan tersebut tetap benar walaupun kelak error dibungkus lagi. Cabang wantErrSent pada baris 80-85 memakai t.Fatalf karena melanjutkan pemeriksaan hasil setelah error dinyatakan benar tidak ada artinya.

Bentuk errors.Is di test juga menjangkau rantai error dari Bagian 3. Test berikut membuktikan bahwa ParseInput benar-benar membungkus error asli dari strconv:

go
func TestParseInputMembungkusErrSyntax(t *testing.T) {
	_, _, err := ParseInput("tambah", "abc", "3")
	if !errors.Is(err, strconv.ErrSyntax) {
		t.Fatalf("ParseInput(\"tambah\", \"abc\", \"3\") error = %v; want error yang membungkus strconv.ErrSyntax", err)
	}
}

Example Test sebagai Dokumentasi Hidup #

Example test adalah fungsi contoh sekaligus test: baris komentar // Output: menjadi kontrak yang diperiksa go test terhadap keluaran standar fungsi tersebut:

go
func ExampleTambah() {
	fmt.Println(Tambah(2, 3))
	// Output: 5
}

func ExampleBagi() {
	hasil, err := Bagi(10, 4)
	if err != nil {
		fmt.Println("galat:", err)
		return
	}
	fmt.Println(hasil)
	// Output: 2.5
}

func ExampleBagi_nol() {
	_, err := Bagi(10, 0)
	fmt.Println("galat:", err)
	// Output: galat: pembagian dengan nol
}

ExampleBagi menunjukkan jalur sukses: keluaran 2.5 dicocokkan dengan komentar pada baris 169. ExampleBagi_nol memakai akhiran _nol untuk menjadi contoh terpisah pada fungsi yang sama — menjalankan go test menjalankan keduanya, dan dokumentasi pkg.go.dev kelak menampilkan contoh ini lengkap dengan hasilnya. Bila keluaran berubah karena kode berubah, test ini gagal — dokumentasi tidak pernah telat mengikuti kode.

Menjalankan Seluruh Test #

Dari direktori proyek, jalankan seluruh test dengan mode verbose:

bash
go test -v ./...

Output yang Dihasilkan:

text
=== RUN   TestTambah
=== RUN   TestTambah/dua_bilangan_bulat
=== RUN   TestTambah/dua_bilangan_negatif
=== RUN   TestTambah/campuran_positif_negatif
=== RUN   TestTambah/bilangan_desimal
--- PASS: TestTambah (0.00s)
    --- PASS: TestTambah/dua_bilangan_bulat (0.00s)
    --- PASS: TestTambah/dua_bilangan_negatif (0.00s)
    --- PASS: TestTambah/campuran_positif_negatif (0.00s)
    --- PASS: TestTambah/bilangan_desimal (0.00s)
=== RUN   TestKurang
--- PASS: TestKurang (0.00s)
=== RUN   TestKali
--- PASS: TestKali (0.00s)
=== RUN   TestBagi
=== RUN   TestBagi/hasil_desimal
=== RUN   TestBagi/hasil_negatif
=== RUN   TestBagi/dibagi_satu
=== RUN   TestBagi/pembagian_dengan_nol
--- PASS: TestBagi (0.00s)
    --- PASS: TestBagi/hasil_desimal (0.00s)
    --- PASS: TestBagi/hasil_negatif (0.00s)
    --- PASS: TestBagi/dibagi_satu (0.00s)
    --- PASS: TestBagi/pembagian_dengan_nol (0.00s)
=== RUN   TestOperasiValid
--- PASS: TestOperasiValid (0.00s)
=== RUN   TestParseInput
=== RUN   TestParseInput/input_lengkap
=== RUN   TestParseInput/angka_desimal
=== RUN   TestParseInput/angka_negatif
=== RUN   TestParseInput/operasi_tidak_dikenal
=== RUN   TestParseInput/angka_pertama_salah
=== RUN   TestParseInput/angka_kedua_salah
--- PASS: TestParseInput (0.00s)
    --- PASS: TestParseInput/input_lengkap (0.00s)
    --- PASS: TestParseInput/angka_desimal (0.00s)
    --- PASS: TestParseInput/angka_negatif (0.00s)
    --- PASS: TestParseInput/operasi_tidak_dikenal (0.00s)
    --- PASS: TestParseInput/angka_pertama_salah (0.00s)
    --- PASS: TestParseInput/angka_kedua_salah (0.00s)
=== RUN   TestParseInputMembungkusErrSyntax
--- PASS: TestParseInputMembungkusErrSyntax (0.00s)
=== RUN   ExampleTambah
--- PASS: ExampleTambah (0.00s)
=== RUN   ExampleBagi
--- PASS: ExampleBagi (0.00s)
=== RUN   ExampleBagi_nol
--- PASS: ExampleBagi_nol (0.00s)
PASS
ok  	example.com/kalkulator-terminal	0.011s

Sebelum 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 — itulah hasil yang diharapkan: daftar berkas kosong dari gofmt, nol temuan dari vet, dan biner terbangun tanpa keluhan.

Seluruh berkas test beserta kode yang diujinya dapat dilihat pada bagian Berkas Sumber di bawah halaman ini.

Ringkasan dan Penutup Proyek #

Empat bagian kini lengkap: desain menetapkan kontrak, operasi murni menyediakan mesin hitung, validasi menjaga pintu masuk, dan test membekukan seluruh janji tersebut. Kalkulator terminal sederhana, tetapi polanya — kontrak exit code, sentinel error, fungsi murni, table-driven test — sama persis dengan yang dipakai program Go yang jauh lebih besar. Proyek berikutnya, Pengelola Tugas CLI , menambahkan satu dimensi yang belum ada di sini: keadaan yang tersimpan antar eksekusi.

Referensi #

Berkas Sumber

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

calculator_test.go

Unduh berkas
go
package main

import (
	"errors"
	"fmt"
	"strconv"
	"strings"
	"testing"
)

func TestTambah(t *testing.T) {
	cases := []struct {
		nama       string
		a, b, want float64
	}{
		{"dua bilangan bulat", 2, 3, 5},
		{"dua bilangan negatif", -4, -6, -10},
		{"campuran positif negatif", 7, -2, 5},
		{"bilangan desimal", 1.5, 2.25, 3.75},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			if got := Tambah(c.a, c.b); got != c.want {
				t.Errorf("Tambah(%g, %g) = %g; want %g", c.a, c.b, got, c.want)
			}
		})
	}
}

func TestKurang(t *testing.T) {
	cases := []struct {
		a, b, want float64
	}{
		{10, 4, 6},
		{2, 5, -3},
		{-1.5, -2.5, 1},
	}

	for _, c := range cases {
		if got := Kurang(c.a, c.b); got != c.want {
			t.Errorf("Kurang(%g, %g) = %g; want %g", c.a, c.b, got, c.want)
		}
	}
}

func TestKali(t *testing.T) {
	cases := []struct {
		a, b, want float64
	}{
		{6, 7, 42},
		{-3, 4, -12},
		{0.5, 8, 4},
		{0, 99, 0},
	}

	for _, c := range cases {
		if got := Kali(c.a, c.b); got != c.want {
			t.Errorf("Kali(%g, %g) = %g; want %g", c.a, c.b, got, c.want)
		}
	}
}

func TestBagi(t *testing.T) {
	cases := []struct {
		nama        string
		a, b, want  float64
		wantErrSent error
	}{
		{"hasil desimal", 10, 4, 2.5, nil},
		{"hasil negatif", -9, 3, -3, nil},
		{"dibagi satu", 7.5, 1, 7.5, nil},
		{"pembagian dengan nol", 10, 0, 0, ErrPembagianNol},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			got, err := Bagi(c.a, c.b)

			if c.wantErrSent != nil {
				if !errors.Is(err, c.wantErrSent) {
					t.Fatalf("Bagi(%g, %g) error = %v; want %v", c.a, c.b, err, c.wantErrSent)
				}
				return
			}

			if err != nil {
				t.Fatalf("Bagi(%g, %g) error tak terduga: %v", c.a, c.b, err)
			}
			if got != c.want {
				t.Errorf("Bagi(%g, %g) = %g; want %g", c.a, c.b, got, c.want)
			}
		})
	}
}

func TestOperasiValid(t *testing.T) {
	for _, operasi := range []string{"tambah", "kurang", "kali", "bagi"} {
		if !OperasiValid(operasi) {
			t.Errorf("OperasiValid(%q) = false; want true", operasi)
		}
	}

	for _, operasi := range []string{"", "pangkat", "TAMBAH", "akar"} {
		if OperasiValid(operasi) {
			t.Errorf("OperasiValid(%q) = true; want false", operasi)
		}
	}
}

func TestParseInput(t *testing.T) {
	cases := []struct {
		nama             string
		operasi, teksA   string
		teksB            string
		wantA, wantB     float64
		wantErrSepenggal string
	}{
		{"input lengkap", "tambah", "2", "3", 2, 3, ""},
		{"angka desimal", "bagi", "10", "2.5", 10, 2.5, ""},
		{"angka negatif", "kurang", "-8", "2", -8, 2, ""},
		{"operasi tidak dikenal", "pangkat", "2", "3", 0, 0, "tidak dikenal"},
		{"angka pertama salah", "tambah", "abc", "3", 0, 0, "angka pertama"},
		{"angka kedua salah", "bagi", "10", "x", 0, 0, "angka kedua"},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			a, b, err := ParseInput(c.operasi, c.teksA, c.teksB)

			if c.wantErrSepenggal == "" {
				if err != nil {
					t.Fatalf("ParseInput(%q, %q, %q) error tak terduga: %v", c.operasi, c.teksA, c.teksB, err)
				}
				if a != c.wantA || b != c.wantB {
					t.Errorf("ParseInput(%q, %q, %q) = (%g, %g); want (%g, %g)",
						c.operasi, c.teksA, c.teksB, a, b, c.wantA, c.wantB)
				}
				return
			}

			if err == nil || !strings.Contains(err.Error(), c.wantErrSepenggal) {
				t.Errorf("ParseInput(%q, %q, %q) error = %v; want pesan memuat %q",
					c.operasi, c.teksA, c.teksB, err, c.wantErrSepenggal)
			}
		})
	}
}

func TestParseInputMembungkusErrSyntax(t *testing.T) {
	_, _, err := ParseInput("tambah", "abc", "3")
	if !errors.Is(err, strconv.ErrSyntax) {
		t.Fatalf("ParseInput(\"tambah\", \"abc\", \"3\") error = %v; want error yang membungkus strconv.ErrSyntax", err)
	}
}

func ExampleTambah() {
	fmt.Println(Tambah(2, 3))
	// Output: 5
}

func ExampleBagi() {
	hasil, err := Bagi(10, 4)
	if err != nil {
		fmt.Println("galat:", err)
		return
	}
	fmt.Println(hasil)
	// Output: 2.5
}

func ExampleBagi_nol() {
	_, err := Bagi(10, 0)
	fmt.Println("galat:", err)
	// Output: galat: pembagian dengan nol
}

calculator.go

Unduh berkas
go
package main

import (
	"errors"
	"fmt"
	"strconv"
)

// ErrPembagianNol adalah sentinel error untuk percobaan pembagian dengan nol.
// Pemanggil cukup memeriksa errors.Is(err, ErrPembagianNol), bukan membandingkan teks pesan.
var ErrPembagianNol = errors.New("pembagian dengan nol")

// Tambah mengembalikan hasil penjumlahan a dan b.
func Tambah(a, b float64) float64 {
	return a + b
}

// Kurang mengembalikan hasil pengurangan a dengan b.
func Kurang(a, b float64) float64 {
	return a - b
}

// Kali mengembalikan hasil perkalian a dan b.
func Kali(a, b float64) float64 {
	return a * b
}

// Bagi mengembalikan hasil pembagian a dengan b.
// Bila b bernilai nol, fungsi mengembalikan ErrPembagianNol.
func Bagi(a, b float64) (float64, error) {
	if b == 0 {
		return 0, ErrPembagianNol
	}
	return a / b, nil
}

// OperasiValid melaporkan apakah nama operasi dikenal oleh kalkulator.
func OperasiValid(operasi string) bool {
	switch operasi {
	case "tambah", "kurang", "kali", "bagi":
		return true
	default:
		return false
	}
}

// ParseInput memvalidasi operasi dan dua teks angka dari pengguna.
// Fungsi mengembalikan kedua angka sebagai float64, atau error yang
// menjelaskan bagian masukan mana yang salah.
func ParseInput(operasi, teksA, teksB string) (float64, float64, error) {
	if !OperasiValid(operasi) {
		return 0, 0, fmt.Errorf("operasi %q tidak dikenal; gunakan tambah, kurang, kali, atau bagi", operasi)
	}

	a, err := strconv.ParseFloat(teksA, 64)
	if err != nil {
		return 0, 0, fmt.Errorf("angka pertama %q tidak valid: %w", teksA, err)
	}

	b, err := strconv.ParseFloat(teksB, 64)
	if err != nil {
		return 0, 0, fmt.Errorf("angka kedua %q tidak valid: %w", teksB, err)
	}

	return a, b, nil
}

main.go

Unduh berkas
go
package main

import (
	"fmt"
	"os"
)

// Kontrak exit code program. Nilai ini dibaca oleh skrip lain
// atau pipeline yang memanggil kalkulator.
const (
	exitSukses     = 0 // perhitungan berhasil, hasil tercetak ke stdout
	exitPenggunaan = 1 // format perintah salah
	exitSalahInput = 2 // operasi tak dikenal, angka tak valid, atau bagi dengan nol
)

func main() {
	// os.Exit dipanggil di luar jalankan agar defer tetap bekerja
	// bila kelak ditambahkan di dalam jalankan.
	os.Exit(jalankan(os.Args[1:]))
}

// jalankan memproses argumen program dan mengembalikan exit code.
func jalankan(args []string) int {
	if len(args) != 3 {
		fmt.Fprintln(os.Stderr, "Penggunaan: kalkulator <operasi> <angka1> <angka2>")
		fmt.Fprintln(os.Stderr, "Operasi yang tersedia: tambah, kurang, kali, bagi")
		return exitPenggunaan
	}

	a, b, err := ParseInput(args[0], args[1], args[2])
	if err != nil {
		fmt.Fprintln(os.Stderr, "Input tidak valid:", err)
		return exitSalahInput
	}

	hasil, err := hitung(args[0], a, b)
	if err != nil {
		fmt.Fprintln(os.Stderr, "Galat perhitungan:", err)
		return exitSalahInput
	}

	fmt.Printf("Hasil: %g\n", hasil)
	return exitSukses
}

// hitung menjalankan operasi yang diminta terhadap a dan b.
func hitung(operasi string, a, b float64) (float64, error) {
	switch operasi {
	case "tambah":
		return Tambah(a, b), nil
	case "kurang":
		return Kurang(a, b), nil
	case "kali":
		return Kali(a, b), nil
	case "bagi":
		return Bagi(a, b)
	default:
		return 0, fmt.Errorf("operasi %q tidak dikenal", operasi)
	}
}