Kode yang perlu diuji sering kali bergantung pada bagian luar, seperti penyimpanan data atau jaringan, yang lambat, berubah-ubah, atau tidak boleh disentuh selama pengujian. Test double, yaitu objek pengganti yang ditempatkan pada posisi dependensi aslinya selama pengujian, menyelesaikan persoalan itu. Topik ini membahas dua bentuknya yang paling sering dipakai di Go. Bentuk pertama adalah mock, yaitu implementasi kecil yang jawabannya ditetapkan sendiri oleh pengujian dengan bantuan interface. Bentuk kedua adalah httptest dari pustaka standar, yang menyediakan perekam respons serta server uji untuk menguji handler HTTP tanpa menyentuh jaringan luar.
Interface sebagai titik sambung#
Interface adalah satu-satunya titik sambung yang dibutuhkan untuk memasang objek pengganti. Fungsi yang bergantung pada interface dapat menerima implementasi aslinya di produksi dan implementasi palsunya di pengujian, tanpa perubahan apa pun pada badan fungsinya. Contoh berikut memakai interface kamus kata dengan satu metode, satu implementasi nyata di memori, dan satu fungsi yang logikanya akan diuji:
package main
import "fmt"
// Kamus adalah sumber kata yang dikenal. Implementasinya dapat berupa
// kamus di memori, berkas lokal, atau layanan daring.
type Kamus interface {
Ada(kata string) bool
}
// KamusMemori menyimpan daftar kata pada peta di memori.
type KamusMemori struct {
kata map[string]bool
}
// BaruKamusMemori membuat kamus memori dari daftar kata yang diberikan.
func BaruKamusMemori(daftar []string) KamusMemori {
k := KamusMemori{kata: make(map[string]bool)}
for _, w := range daftar {
k.kata[w] = true
}
return k
}
// Ada melaporkan apakah kata tersebut tercantum pada kamus.
func (k KamusMemori) Ada(kata string) bool {
return k.kata[kata]
}
// KataTidakDikenal mengembalikan kata-kata pada daftar yang tidak
// tercantum pada kamus yang diberikan.
func KataTidakDikenal(daftar []string, k Kamus) []string {
var tidakDikenal []string
for _, kata := range daftar {
if !k.Ada(kata) {
tidakDikenal = append(tidakDikenal, kata)
}
}
return tidakDikenal
}
func main() {
kamus := BaruKamusMemori([]string{"kopi", "susu"})
fmt.Println(KataTidakDikenal([]string{"kopi", "gula", "susu"}, kamus))
}[gula]Fungsi main menyusun kamus memori yang hanya mengenal kata kopi dan susu, lalu menyaring daftar kopi, gula, dan susu melaluinya. Hasilnya satu-satunya kata gula yang dilaporkan tidak dikenal, dan penjelasan itu sekaligus menjadi ekspektasi yang diperiksa oleh pengujian pada bagian berikutnya.
Fungsi KataTidakDikenal tidak tahu dan tidak peduli dari mana kamusnya berasal, karena yang dilihatnya hanya interface Kamus. Dalam pemakaian nyata, implementasi Kamus bisa berupa kamus di memori seperti di atas, atau layanan daring yang memerlukan jaringan dan keadaan yang sulit dipastikan selama pengujian. Karena parameter fungsinya bertipe interface, kedua kemungkinan itu dapat ditukar tanpa menyentuh logika penyaringannya. Perhatikan pula bahwa interface Kamus didefinisikan di sisi pemakainya, yaitu pada berkas yang memuat KataTidakDikenal, dan hanya memuat metode yang benar-benar dibutuhkan. Konvensi Go menganjurkan interface kecil milik pemakai alih-alih interface besar milik penyedianya, karena makin kecil sebuah interface, makin ringkas pula mock-nya ditulis.
Mock dengan kamus palsu#
Mock adalah implementasi palsu yang jawabannya ditetapkan oleh pengujian, biasanya sekaligus mencatat interaksi yang diterimanya. Berikut ini KamusPalsu menjawab sesuai peta jawaban dan mencatat setiap kata yang ditanyakan kepadanya:
// tidak-dijalankan: dijalankan melalui go test
package main
import "testing"
// KamusPalsu adalah mock dari Kamus. Jawabannya ditetapkan lewat peta
// jawaban, sedangkan setiap pertanyaan dicatat agar interaksinya dapat
// diperiksa.
type KamusPalsu struct {
jawaban map[string]bool
diperiksa []string
}
// Ada mencatat pertanyaan lalu menjawab sesuai peta jawaban. Kata yang
// tidak tercantum dianggap tidak dikenal.
func (p *KamusPalsu) Ada(kata string) bool {
p.diperiksa = append(p.diperiksa, kata)
return p.jawaban[kata]
}
func TestKataTidakDikenal(t *testing.T) {
palsu := &KamusPalsu{jawaban: map[string]bool{"kopi": true, "susu": true}}
hasil := KataTidakDikenal([]string{"kopi", "gula", "susu"}, palsu)
if len(hasil) != 1 || hasil[0] != "gula" {
t.Errorf("KataTidakDikenal = %v, seharusnya [gula]", hasil)
}
if len(palsu.diperiksa) != 3 {
t.Errorf("kamus diperiksa %d kali, seharusnya 3", len(palsu.diperiksa))
}
}Baris komentar pertama pada berkas itu menandai bahwa berkas pengujian dijalankan melalui go test dan tidak berfungsi main. Pengujian di atas memeriksa dua hal sekaligus, yaitu hasil akhir fungsi dan interaksinya. Pengecekan palsu.diperiksa memastikan fungsi benar-benar bertanya untuk setiap kata pada daftar, sesuatu yang tidak terlihat dari hasil akhirnya saja.
Implementasi KamusMemori tidak dipakai di sini justru karena implementasi itu memang nyata, sedangkan yang ingin diuji adalah logika KataTidakDikenal semata, yang harus benar terhadap kamus mana pun. Dengan mock, keadaan yang sulit dibuat pada kamus nyata pun dapat disimulasikan dengan mudah, misalnya kamus yang menjawab false untuk semua kata karena layanannya sedang tidak tersedia. Pengujian pada folder uji berikut menjalankan pemeriksaan itu:
# salin berkas contoh ke folder uji
rm -rf /tmp/gotest-double && mkdir -p /tmp/gotest-double && cp *.go /tmp/gotest-double/ && cd /tmp/gotest-double
# buat modul sementara tanpa mencatat pesannya
go mod init contoh >/dev/null 2>&1
# uji logika penyaringan kata dengan kamus palsu
go test -v -run TestKataTidakDikenal 2>&1 | sed -E 's/^(ok|FAIL)[[:space:]]+contoh[[:space:]].*/\1 contoh/; s/\([0-9]+\.[0-9]+s\)/(WAKTU)/'=== RUN TestKataTidakDikenal
--- PASS: TestKataTidakDikenal (WAKTU)
PASS
ok contohhttptest untuk handler HTTP#
Handler HTTP adalah kasus dependensi luar yang paling umum, karena handler menulis responsnya melalui interface http.ResponseWriter. Paket httptest menyediakan dua bagian yang saling melengkapi, yaitu httptest.NewRequest yang membentuk objek *http.Request tanpa jaringan, dan httptest.NewRecorder yang menghasilkan implementasi http.ResponseWriter sekaligus perekam hasilnya. Handler berikut menanggapi banyak kata pada parameter teks dalam bentuk JSON:
// tidak-dijalankan: dijalankan melalui go test
package main
import (
"encoding/json"
"net/http"
"strings"
)
// HasilPanjang adalah respons JSON dari handler penghitung kata.
type HasilPanjang struct {
Panjang int `json:"panjang"`
}
// HandlerPanjang menangani permintaan /api/panjang dengan parameter
// teks, lalu menanggapi banyak katanya dalam bentuk JSON.
func HandlerPanjang(w http.ResponseWriter, r *http.Request) {
isi := r.URL.Query().Get("teks")
hasil := HasilPanjang{Panjang: len(strings.Fields(isi))}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(hasil)
}Berkas ini tidak memuat fungsi main, karena satu paket main hanya boleh memiliki satu fungsi main dan fungsi itu sudah dipakai oleh kamus.go. Kebenaran handler di atas tidak diperiksa dengan menjalankannya sendirian, melainkan melalui go test pada berkas pengujian berikut, dan baris komentar pertamanya menandai hal yang sama untuk kedua berkas.
Pengujian handler tidak menyalakan server dan tidak membuka port, karena HandlerPanjang cukup dipanggil langsung dengan permintaan buatan dan perekam responsnya:
// tidak-dijalankan: dijalankan melalui go test
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHandlerPanjang(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/panjang?teks=kopi+susu+teh", nil)
rec := httptest.NewRecorder()
HandlerPanjang(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("kode status = %d, seharusnya %d", rec.Code, http.StatusOK)
}
var hasil HasilPanjang
if err := json.Unmarshal(rec.Body.Bytes(), &hasil); err != nil {
t.Fatalf("tubuh respons bukan JSON yang sah: %v", err)
}
if hasil.Panjang != 3 {
t.Errorf("panjang = %d, seharusnya 3", hasil.Panjang)
}
}
func TestHandlerPanjangTeksKosong(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/panjang?teks=", nil)
rec := httptest.NewRecorder()
HandlerPanjang(rec, req)
var hasil HasilPanjang
if err := json.Unmarshal(rec.Body.Bytes(), &hasil); err != nil {
t.Fatalf("tubuh respons bukan JSON yang sah: %v", err)
}
if hasil.Panjang != 0 {
t.Errorf("panjang = %d, seharusnya 0", hasil.Panjang)
}
}
func TestHandlerDenganServer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(HandlerPanjang))
defer server.Close()
resp, err := http.Get(server.URL + "/api/panjang?teks=kopi+susu+teh")
if err != nil {
t.Fatalf("permintaan ke server uji gagal: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("kode status = %d, seharusnya %d", resp.StatusCode, http.StatusOK)
}
var hasil HasilPanjang
if err := json.NewDecoder(resp.Body).Decode(&hasil); err != nil {
t.Fatalf("tubuh respons bukan JSON yang sah: %v", err)
}
if hasil.Panjang != 3 {
t.Errorf("panjang = %d, seharusnya 3", hasil.Panjang)
}
}Setelah handler dipanggil, seluruh responsnya dapat diperiksa dari perekam, yaitu kode status pada rec.Code, tajuk respons pada rec.Header(), dan tubuhnya pada rec.Body. Kasus kedua menguji parameter kosong yang menghasilkan angka nol, satu contoh kasus tepi yang mudah terlewat:
# uji handler HTTP dengan perekam respons dari httptest
go test -v -run TestHandlerPanjang 2>&1 | sed -E 's/^(ok|FAIL)[[:space:]]+contoh[[:space:]].*/\1 contoh/; s/\([0-9]+\.[0-9]+s\)/(WAKTU)/'=== RUN TestHandlerPanjang
--- PASS: TestHandlerPanjang (WAKTU)
=== RUN TestHandlerPanjangTeksKosong
--- PASS: TestHandlerPanjangTeksKosong (WAKTU)
PASS
ok contohServer sungguhan pada loopback#
Perekam menguji handler secara langsung, tetapi ada pengujian yang membutuhkan server sungguhan, misalnya ketika client HTTP yang dipakai memiliki perilaku sendiri seperti pengiriman tajuk dan pengurusan koneksi. httptest.NewServer menyediakannya dengan menerima sebuah handler dan mengembalikan server yang berjalan pada 127.0.0.1 dengan nomor port yang dipilih otomatis, sehingga tetap terisolasi dari jaringan luar. Fungsi TestHandlerDenganServer pada berkas pengujian di atas memakainya:
# uji jalur jaringan penuh dengan server uji pada loopback
go test -v -run TestHandlerDenganServer 2>&1 | sed -E 's/^(ok|FAIL)[[:space:]]+contoh[[:space:]].*/\1 contoh/; s/\([0-9]+\.[0-9]+s\)/(WAKTU)/'=== RUN TestHandlerDenganServer
--- PASS: TestHandlerDenganServer (WAKTU)
PASS
ok contohPengujian itu memanggil http.Get ke server.URL, sehingga permintaannya melewati seluruh jalur jaringan sungguhan, mulai dari client, koneksi TCP di loopback, sampai server yang menjalankan handler yang sama. Pemanggilan defer server.Close() menutup servernya begitu fungsi pengujian selesai dieksekusi, sehingga port yang dipakainya tidak tertinggal terbuka. Pemilihan antara perekam dan server mengikuti kebutuhan, yaitu perekam untuk menguji isi respons handler secara cepat dan langsung, sedangkan server untuk menguji jalur permintaan lengkap beserta client-nya.
Ringkasan#
- Interface yang kecil menjadi titik sambung test double, karena fungsi yang menerima interface tidak berubah ketika implementasinya ditukar.
- Mock adalah implementasi palsu dengan jawaban yang ditetapkan pengujian, dan ia sebaiknya juga mencatat interaksinya agar pola pemanggilannya dapat diperiksa.
httptest.NewRequestdanhttptest.NewRecordermenguji handler tanpa server dan tanpa jaringan, dengan kode status, tajuk, dan tubuh respons yang dapat diperiksa dari perekamnya.httptest.NewServermenyediakan server sungguhan pada127.0.0.1dengan port pilihan otomatis, sehingga jalur permintaan lengkap beserta client HTTP-nya dapat diuji tanpa menyentuh jaringan luar.
Referensi#
- Package httptest — The Go Authors — Dokumentasi resmi paket httptest beserta NewRequest, NewRecorder, dan NewServer.
- Package net/http — The Go Authors — Dokumentasi interface ResponseWriter dan tipe Request yang menjadi dasar pengujian handler.