Kembali ke Artikel

Belajar Golang dari Nol #14: Testing, Menguji Function dan Handler

Solusi IT

Di Belajar Golang dari Nol #13, API produk kita sudah punya middleware logging dan proteksi API key. Endpoint aman, setiap request tercatat. Tapi ada satu kebiasaan yang masih kita lakukan sejak bagian 11. Setiap selesai mengubah kode, kita jalankan server, buka Postman, lalu klik kirim request satu per satu untuk memastikan semuanya masih jalan.

Cara itu melelahkan. Dan yang lebih bahaya, gampang kelewat. Kamu perbaiki handler POST, lupa cek handler GET, ternyata yang GET ikut rusak. Di bagian ini kita belajar cara yang lebih waras, yaitu menyuruh Go sendiri yang mengetes kode kita. Namanya automated testing, dan Go menyediakannya bawaan tanpa install apa pun.

Kenapa Testing Bukan Kemewahan

Banyak pemula menganggap testing itu urusan nanti, sesuatu yang dikerjakan kalau sempat. Padahal ada tiga alasan kenapa testing justru menghemat waktu.

  • Refactor tanpa takut. Mau ganti struktur kode, rapikan function, atau pindah logika ke file lain. Selama test lulus, kamu tahu perilaku kode tidak berubah. Tanpa test, setiap refactor terasa seperti jalan di atas es tipis.
  • Bukti kode jalan. Satu perintah go test menggantikan puluhan klik manual di Postman. Sekali tulis, tes bisa diulang ribuan kali secara gratis.
  • Syarat kerja tim. Di hampir semua tim backend profesional, kode tanpa test tidak akan lolos code review. Kalau kamu berencana kerja sebagai backend developer Go, kebiasaan menulis test bukan nilai plus lagi. Itu standar minimum.

Kabar baiknya, Go serius soal ini. Tool testing sudah ada di dalam bahasa lewat package testing dan perintah go test. Tidak perlu install framework pihak ketiga seperti di bahasa lain.

Aturan Dasar Testing di Go

Go punya konvensi sederhana. Ikuti aturannya, dan semuanya jalan otomatis.

  • File test diberi akhiran _test.go. Contoh: kode ada di produk.go, testnya di produk_test.go. File ini tidak ikut ter-compile saat build biasa.
  • Function test harus diawali Test dengan huruf besar setelahnya, dan menerima satu parameter t *testing.T. Contoh: func TestHitungDiskon(t *testing.T).
  • Jalankan dengan go test di folder project. Tambahkan -v kalau mau lihat detail tiap test.
go test        # jalankan semua test, hanya tampilkan ringkasan
go test -v     # verbose, tampilkan tiap test dan hasilnya

Tidak ada function main yang perlu dipanggil. Tidak ada registrasi test di mana pun. Go mencari sendiri semua file _test.go dan menjalankan semua function TestXxx di dalamnya.

Test Pertama: Menguji Function Murni

Target paling enak untuk test pertama adalah function murni, yaitu function yang hasilnya hanya bergantung pada input. Tidak menyentuh database, tidak menyentuh network. Dari seri sebelumnya kita sudah punya dua kandidat, yaitu validasi produk dan hitung diskon. Ini versi ringkasnya di produk.go.

func ValidasiProduk(p Produk) error {
	if p.Nama == "" {
		return errors.New("nama produk tidak boleh kosong")
	}
	if p.Harga <= 0 {
		return errors.New("harga harus lebih dari nol")
	}
	if p.Stok < 0 {
		return errors.New("stok tidak boleh minus")
	}
	return nil
}

func HitungDiskon(harga, persen int) (int, error) {
	if persen < 0 || persen > 100 {
		return 0, errors.New("persen diskon harus 0 sampai 100")
	}
	return harga - (harga * persen / 100), nil
}

Sekarang buat file produk_test.go di folder yang sama, lalu tulis test pertama.

package main

import "testing"

func TestHitungDiskon(t *testing.T) {
	got, err := HitungDiskon(100000, 10)
	if err != nil {
		t.Fatalf("tidak mengharapkan error, dapat: %v", err)
	}
	want := 90000
	if got != want {
		t.Errorf("HitungDiskon(100000, 10) = %d, ingin %d", got, want)
	}
}

Polanya selalu sama. Panggil function dengan input tertentu, simpan hasilnya di got, bandingkan dengan nilai harapan di want. Kalau beda, laporkan lewat t.Errorf. Perhatikan pesan errornya. Pesan yang baik menyebut input, hasil yang didapat, dan hasil yang diinginkan. Saat test gagal enam bulan lagi, pesan itu yang menyelamatkanmu dari menebak-nebak.

Ada dua cara melaporkan kegagalan. t.Errorf menandai test gagal tapi lanjut ke baris berikutnya. t.Fatalf menandai gagal dan langsung berhenti. Pakai Fatalf kalau pengecekan berikutnya tidak ada artinya lagi, misalnya errornya saja sudah muncul.

Table Driven Test, Idiom Paling Penting

Satu function biasanya perlu diuji dengan banyak kombinasi input. Menyalin test di atas lima kali dengan angka berbeda jelas bukan solusi. Idiom Go untuk masalah ini bernama table driven test. Idenya, kumpulkan semua kasus dalam satu slice of struct, lalu loop.

func TestHitungDiskonTable(t *testing.T) {
	kasus := []struct {
		nama     string
		harga    int
		persen   int
		want     int
		inginErr bool
	}{
		{"diskon normal 10 persen", 100000, 10, 90000, false},
		{"tanpa diskon", 50000, 0, 50000, false},
		{"diskon penuh", 80000, 100, 0, false},
		{"persen minus harus gagal", 100000, -5, 0, true},
		{"persen di atas 100 harus gagal", 100000, 150, 0, true},
	}

	for _, k := range kasus {
		t.Run(k.nama, func(t *testing.T) {
			got, err := HitungDiskon(k.harga, k.persen)
			if k.inginErr {
				if err == nil {
					t.Errorf("mengharapkan error, tapi dapat nil")
				}
				return
			}
			if err != nil {
				t.Fatalf("tidak mengharapkan error, dapat: %v", err)
			}
			if got != k.want {
				t.Errorf("dapat %d, ingin %d", got, k.want)
			}
		})
	}
}

Setiap kasus punya nama, dan t.Run menjalankannya sebagai subtest terpisah. Saat go test -v jalan, kamu bisa lihat kasus mana yang lulus dan mana yang gagal, lengkap dengan namanya.

Kenapa pola ini enak dirawat. Menambah kasus baru cukup satu baris di tabel, bukan satu function baru. Logika pengujian ditulis sekali, tidak ada copy paste yang bisa melenceng. Dan tabelnya sendiri menjadi dokumentasi perilaku function.

Menguji Kasus yang Harus Gagal

Pemula sering hanya menguji jalur bahagia, yaitu input benar menghasilkan output benar. Padahal bug paling sering muncul di jalur sebaliknya. Input jelek harus ditolak, dan test harus memastikan penolakan itu benar-benar terjadi.

func TestValidasiProdukStokMinus(t *testing.T) {
	p := Produk{Nama: "Kopi Gayo", Harga: 45000, Stok: -3}
	err := ValidasiProduk(p)
	if err == nil {
		t.Errorf("stok minus seharusnya gagal validasi, tapi error nil")
	}
}

Perhatikan logikanya terbalik dari test biasa. Di sini kondisi gagal justru err == nil. Kalau suatu hari ada yang tidak sengaja menghapus pengecekan stok di ValidasiProduk, test ini langsung merah. Tanpa test ini, produk berstok minus bisa masuk database dan baru ketahuan saat pelanggan komplain.

Menguji Handler HTTP dengan httptest

Function murni sudah aman. Sekarang bagian yang lebih menarik, yaitu menguji handler API tanpa menjalankan server sama sekali. Go menyediakan package net/http/httptest untuk ini. Dua pemain utamanya adalah httptest.NewRequest untuk membuat request palsu dan httptest.NewRecorder untuk menangkap response.

Supaya fokus ke teknik testingnya, kita pakai handler versi in-memory yang sederhana. Di project kamu, handler dari bagian 11 dan 12 bisa diuji dengan pola yang sama persis.

var daftarProduk = []Produk{
	{ID: 1, Nama: "Kopi Gayo", Harga: 45000, Stok: 10},
	{ID: 2, Nama: "Teh Melati", Harga: 20000, Stok: 25},
}

func handlerListProduk(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(daftarProduk)
}

func handlerTambahProduk(w http.ResponseWriter, r *http.Request) {
	var p Produk
	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
		http.Error(w, "body JSON tidak valid", http.StatusBadRequest)
		return
	}
	if err := ValidasiProduk(p); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	p.ID = len(daftarProduk) + 1
	daftarProduk = append(daftarProduk, p)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(p)
}

Menguji handler GET produk

func TestHandlerListProduk(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/produk", nil)
	rec := httptest.NewRecorder()

	handlerListProduk(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, ingin %d", rec.Code, http.StatusOK)
	}

	var hasil []Produk
	if err := json.Unmarshal(rec.Body.Bytes(), &hasil); err != nil {
		t.Fatalf("body bukan JSON valid: %v", err)
	}
	if len(hasil) == 0 {
		t.Errorf("daftar produk kosong, ingin minimal 1 produk")
	}
}

Alurnya tiga langkah. Buat request palsu, siapkan recorder, panggil handler seperti function biasa. Recorder menyimpan semua yang ditulis handler, yaitu status code di rec.Code dan body di rec.Body. Kita cek status 200, lalu pastikan bodynya JSON valid. Semua terjadi di memori, tanpa port terbuka, tanpa server jalan, selesai dalam hitungan milidetik.

Menguji POST dengan body invalid

Sekarang jalur gagalnya. Kirim produk dengan nama kosong, dan handler harus menjawab 400.

func TestHandlerTambahProdukBodyInvalid(t *testing.T) {
	body := strings.NewReader(`{"nama": ""}`)
	req := httptest.NewRequest(http.MethodPost, "/produk", body)
	rec := httptest.NewRecorder()

	handlerTambahProduk(rec, req)

	if rec.Code != http.StatusBadRequest {
		t.Errorf("status = %d, ingin %d", rec.Code, http.StatusBadRequest)
	}
}

Body request dibuat dari string biasa lewat strings.NewReader. Test ini menjaga kontrak API kita. Selama test ini lulus, klien yang mengirim data kacau dijamin dapat 400, bukan 500 atau lebih parah lagi, data kacau yang tersimpan diam-diam.

Menguji Endpoint yang Dilindungi API Key

Di bagian 13 kita membungkus handler dengan middleware cekAPIKey yang memeriksa header X-API-Key. Middleware juga bisa diuji dengan httptest. Kuncinya, bungkus handler dengan middleware dulu, lalu panggil lewat ServeHTTP.

func TestMiddlewareAPIKey(t *testing.T) {
	handler := cekAPIKey(http.HandlerFunc(handlerListProduk))

	t.Run("tanpa API key ditolak", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/produk", nil)
		rec := httptest.NewRecorder()

		handler.ServeHTTP(rec, req)

		if rec.Code != http.StatusUnauthorized {
			t.Errorf("status = %d, ingin %d", rec.Code, http.StatusUnauthorized)
		}
	})

	t.Run("dengan API key benar lolos", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/produk", nil)
		req.Header.Set("X-API-Key", "rahasia123")
		rec := httptest.NewRecorder()

		handler.ServeHTTP(rec, req)

		if rec.Code != http.StatusOK {
			t.Errorf("status = %d, ingin %d", rec.Code, http.StatusOK)
		}
	})
}

Dua skenario dalam satu test. Request tanpa header harus mentok di 401, request dengan key yang benar harus tembus dan dapat 200. Test seperti ini penting karena bug di lapisan auth adalah bug keamanan. Kalau ada perubahan yang tidak sengaja mematikan pengecekan API key, test ini langsung gagal sebelum kode naik ke server.

Coverage: Melihat Bagian yang Belum Teruji

Go bisa menghitung berapa persen baris kode yang tersentuh test.

$ go test -cover
PASS
coverage: 75.9% of statements
ok      toko-api        0.018s

Satu hal yang perlu diluruskan sejak awal. Angka coverage bukan tujuan, melainkan petunjuk. Coverage 75.9% artinya masih ada sekitar seperempat kode yang belum pernah dijalankan oleh test mana pun, dan di situlah bug bisa bersembunyi. Mengejar 100% dengan test asal-asalan jauh lebih buruk daripada 75% dengan test yang benar-benar memeriksa perilaku penting. Pakai coverage untuk menemukan lubang, bukan untuk pamer angka.

Kebiasaan Sehat Menulis Test

  • Test kecil dan cepat. Satu test menguji satu perilaku. Test yang lambat akan malas dijalankan, dan test yang tidak dijalankan sama saja tidak ada.
  • Nama test menjelaskan skenario. TestHandlerTambahProdukBodyInvalid langsung terbaca maksudnya. TestProduk2 tidak menceritakan apa pun.
  • Jalankan sebelum commit. Biasakan go test ./... sebelum git commit. Perintah ini menjalankan test di semua package dalam project.
  • Kenali t.Helper. Kalau kamu membuat function bantuan untuk test, panggil t.Helper() di baris pertamanya. Dengan begitu saat gagal, Go menunjuk baris pemanggilnya, bukan baris di dalam helper. Cukup tahu dulu, kamu akan membutuhkannya saat file test mulai panjang.

Latihan: produk_test.go Utuh

Sebagai latihan penutup, gabungkan semuanya. Ini file test untuk function validasi CRUD produk dengan table driven, ditambah satu handler test.

package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestValidasiProduk(t *testing.T) {
	kasus := []struct {
		nama     string
		produk   Produk
		inginErr bool
	}{
		{"produk valid", Produk{Nama: "Kopi Gayo", Harga: 45000, Stok: 10}, false},
		{"nama kosong", Produk{Nama: "", Harga: 45000, Stok: 10}, true},
		{"harga nol", Produk{Nama: "Kopi Gayo", Harga: 0, Stok: 10}, true},
		{"harga minus", Produk{Nama: "Kopi Gayo", Harga: -100, Stok: 10}, true},
		{"stok minus", Produk{Nama: "Kopi Gayo", Harga: 45000, Stok: -3}, true},
	}

	for _, k := range kasus {
		t.Run(k.nama, func(t *testing.T) {
			err := ValidasiProduk(k.produk)
			if k.inginErr && err == nil {
				t.Errorf("mengharapkan error, tapi dapat nil")
			}
			if !k.inginErr && err != nil {
				t.Errorf("tidak mengharapkan error, dapat: %v", err)
			}
		})
	}
}

func TestHandlerListProduk(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/produk", nil)
	rec := httptest.NewRecorder()

	handlerListProduk(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, ingin %d", rec.Code, http.StatusOK)
	}

	var hasil []Produk
	if err := json.Unmarshal(rec.Body.Bytes(), &hasil); err != nil {
		t.Fatalf("body bukan JSON valid: %v", err)
	}
}

Jalankan dengan go test -v. Kalau semuanya benar, hasilnya seperti ini.

$ go test -v
=== RUN   TestValidasiProduk
=== RUN   TestValidasiProduk/produk_valid
=== RUN   TestValidasiProduk/nama_kosong
=== RUN   TestValidasiProduk/harga_nol
=== RUN   TestValidasiProduk/harga_minus
=== RUN   TestValidasiProduk/stok_minus
--- PASS: TestValidasiProduk (0.00s)
    --- PASS: TestValidasiProduk/produk_valid (0.00s)
    --- PASS: TestValidasiProduk/nama_kosong (0.00s)
    --- PASS: TestValidasiProduk/harga_nol (0.00s)
    --- PASS: TestValidasiProduk/harga_minus (0.00s)
    --- PASS: TestValidasiProduk/stok_minus (0.00s)
=== RUN   TestHandlerListProduk
--- PASS: TestHandlerListProduk (0.00s)
PASS
ok      toko-api        0.017s

Barisan PASS itu bukan sekadar hiasan. Itu bukti tertulis bahwa validasi produk dan handler API kamu berperilaku sesuai harapan, dan bukti itu bisa diperbarui kapan saja dengan satu perintah. Coba tambahkan sendiri test untuk handlerTambahProduk dengan kasus sukses, lalu ukur lagi dengan go test -cover dan lihat angkanya naik.

Selanjutnya: Naik ke Server

API produk kita sekarang punya endpoint CRUD, database, middleware, proteksi API key, dan barisan test yang menjaga semuanya. Tinggal satu langkah besar yang belum, yaitu keluar dari laptop. Di bagian 15, Deploy API Go ke Server: dari Laptop ke Production, kita akan membawa API ini ke server sungguhan supaya bisa diakses siapa pun.

Kalau kamu ingin membangun API atau sistem aplikasi yang teruji dan siap production tanpa mengerjakan semuanya sendiri, tim Arrazy bisa membantu dari perancangan sampai deployment.

BACA JUGA

Artikel Lainnya di Kategori Golang

Ingin Membaca Artikel Lainnya?

Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.

Lihat Semua Artikel