Pengujian Table-Driven

Pola pengujian Go yang paling kanonik.

Daftar isi

Pola table-driven, yaitu pengujian bertabel yang menyimpan seluruh kasus uji pada satu struktur data, adalah bentuk pengujian yang paling kanonik di Go dan dipakai hampir di seluruh pustaka standarnya. Topik ini membahas masalah yang diselesaikan pola tersebut, bentuk penulisannya dari tabel sampai perulangan, cara menjalankannya, dan bentuk keluaran kegagalannya yang menyebut nama kasus. Contoh yang dipakai adalah fungsi penentu predikat hasil belajar dengan beberapa batas nilai yang wajib diuji berdampingan.

Keterbatasan pengujian yang disalin#

Cara paling intuitif untuk menguji fungsi dengan banyak masukan adalah menyalin blok pengecekan untuk setiap masukan. Bentuk seperti itu sah menurut compiler, tetapi kelemahannya muncul segera setelah jumlah kasus bertambah:

go Pengecekan yang disalin untuk setiap kasus
// tidak-dijalankan
if hasil := Predikat(85); hasil != "A" {
        t.Errorf("Predikat(85) = %q, seharusnya %q", hasil, "A")
}
if hasil := Predikat(84); hasil != "B" {
        t.Errorf("Predikat(84) = %q, seharusnya %q", hasil, "B")
}
// ...empat blok lagi mengikuti pola yang sama

Setiap penambahan kasus menuntut satu blok baru yang strukturnya sama, sedangkan perubahan pada pesan atau cara pengecekannya harus disalin ke setiap blok. Kesalahan salin-tempel pun mudah terjadi, misalnya masukan yang lupa disesuaikan dengan ekspektasinya, dan kegagalan itu menimbulkan laporan yang menyesatkan. Pola ini juga menyembunyikan gambaran lengkapnya, karena kasus yang diuji tersebar pada belasan blok alih-alih terbaca sekilas dari satu tempat.

Bentuk table-driven#

Bentuk table-driven memindahkan seluruh data kasus ke satu tabel, kemudian satu perulangan mengeksekusinya. Fungsi yang diuji pada topik ini menentukan predikat hasil belajar dari nilai rata-rata ujian dengan batas 85, 70, dan 55:

go nilai.go
package main

import "fmt"

// Predikat menentukan predikat hasil belajar dari nilai rata-rata
// ujian. Batas setiap predikatnya 85, 70, dan 55.
func Predikat(nilai int) string {
	switch {
	case nilai >= 85:
		return "A"
	case nilai >= 70:
		return "B"
	case nilai >= 55:
		return "C"
	default:
		return "D"
	}
}

func main() {
	fmt.Println(Predikat(85), Predikat(84), Predikat(70), Predikat(55), Predikat(54))
}
text Keluaran
A B B C D

Fungsi main memperagakan pasangan batasnya secara berurutan, yaitu 85 dijawab A sedangkan 84 dijawab B, 70 dijawab B, 55 dijawab C, dan 54 dijawab D. Deretan keluaran satu baris itu merangkum perilaku yang wajib diperiksa oleh tabel pengujian di bawahnya.

Fungsi pengujian menyusun tabel berupa slice dari struct anonim, yaitu struct yang tipenya didefinisikan langsung pada tempat pemakaiannya tanpa diberi nama. Setiap elemen tabel memuat nama kasus, masukan, dan ekspektasi, kemudian perulangan menjalankan pengecekan yang sama untuk semuanya:

go nilai_test.go
// tidak-dijalankan: dijalankan melalui go test

package main

import "testing"

func TestPredikat(t *testing.T) {
	uji := []struct {
		nama  string
		nilai int
		ingin string
	}{
		{"nilai sempurna", 100, "A"},
		{"tepat di batas A", 85, "A"},
		{"satu di bawah batas A", 84, "B"},
		{"tepat di batas B", 70, "B"},
		{"satu di bawah batas B", 69, "C"},
		{"tepat di batas C", 55, "C"},
		{"di bawah batas C", 0, "D"},
	}

	for _, u := range uji {
		if hasil := Predikat(u.nilai); hasil != u.ingin {
			t.Errorf("%s: Predikat(%d) = %q, seharusnya %q", u.nama, u.nilai, hasil, u.ingin)
		}
	}
}

Sama seperti contoh pengujian pada umumnya, baris komentar pertama di sini menandai berkas pengujian yang tidak dijalankan sendirian, karena tidak memiliki fungsi main. Perulangan for range pada badan TestPredikat menjalankan satu pengecekan untuk setiap baris tabel, dan pesan kegagalannya menempelkan nama kasus di bagian depan.

Tabel itu sekaligus menjadi spesifikasi yang dapat dibaca. Deretan kasusnya memuat nilai sempurna, setiap batas, nilai yang berada satu tingkat di bawah setiap batas, dan kasus terendah. Perhatikan bahwa verb %q dipilih alih-alih %s karena verb itu menampilkan string dengan tanda kutip, sehingga nilai kosong atau spasi tidak dapat menyamar sebagai string biasa pada pesan.

Menjalankan dan membaca kegagalan#

Pengujian table-driven dijalankan dengan perintah yang sama seperti pengujian biasa:

bash
# salin berkas contoh ke folder uji
rm -rf /tmp/gotest-tabel && mkdir -p /tmp/gotest-tabel && cp *.go /tmp/gotest-tabel/ && cd /tmp/gotest-tabel
# buat modul sementara tanpa mencatat pesannya
go mod init contoh >/dev/null 2>&1
# jalankan pengujian beserta keluaran rincinya
go test -v 2>&1 | sed -E 's/^(ok|FAIL)[[:space:]]+contoh[[:space:]].*/\1 contoh/; s/\([0-9]+\.[0-9]+s\)/(WAKTU)/'
text Keluaran
=== RUN   TestPredikat
--- PASS: TestPredikat (WAKTU)
PASS
ok contoh

Seluruh tabel dijalankan dalam satu fungsi TestPredikat, sehingga keluaran rincinya hanya memuat satu pasang baris RUN dan PASS. Keunggulan pola ini baru terlihat jelas ketika salah satu baris tabel keliru. Blok berikut sengaja mengubah ekspektasi pada baris 84 menjadi A, lalu pengujian dijalankan ulang:

bash
# sengaja kelirukan ekspektasi pada satu baris tabel
sed -i 's/84, "B"/84, "A"/' nilai_test.go
# jalankan ulang dan amati laporan kegagalannya
go test -v 2>&1 | sed -E 's/^(ok|FAIL)[[:space:]]+contoh[[:space:]].*/\1 contoh/; s/\([0-9]+\.[0-9]+s\)/(WAKTU)/'
text Keluaran
=== RUN   TestPredikat
    nilai_test.go:24: satu di bawah batas A: Predikat(84) = "B", seharusnya "A"
--- FAIL: TestPredikat (WAKTU)
FAIL
exit status 1
FAIL contoh

Pesan kegagalannya menyebut nama kasus di depan, karena nama itu sengaja ditempelkan di awal pesan t.Errorf. Tanpa nama kasus, pembaca pesan hanya melihat Predikat(84) dan harus mencari baris tabel yang bersangkutan secara manual. Sementara itu, keenam kasus lainnya tetap dijalankan sampai tuntas karena pelaporannya memakai t.Errorf. Keluaran hanya melaporkan kasus yang gagal, sehingga satu baris keliru tidak menutup nasib kasus lainnya.

Tipe tabel bernama untuk tabel yang dipakai ulang#

Struct anonim pada contoh di atas memang paling ringkas untuk tabel sekali pakai, tetapi bentuknya tidak dapat dirujuk dari tempat lain. Apabila tabel yang sama dibutuhkan oleh beberapa fungsi pengujian, atau isinya ingin dibangun bertahap dari beberapa sumber, tabel itu diekstrak menjadi tipe struct bernama beserta fungsi bantuan yang menjalankan perulangannya:

go Tipe tabel bernama beserta fungsi bantuan
// tidak-dijalankan
type kasusPredikat struct {
        nama  string
        nilai int
        ingin string
}

func ujiTabel(t *testing.T, tabel []kasusPredikat) {
        t.Helper()
        for _, u := range tabel {
                if hasil := Predikat(u.nilai); hasil != u.ingin {
                        t.Errorf("%s: Predikat(%d) = %q, seharusnya %q", u.nama, u.nilai, hasil, u.ingin)
                }
        }
}

Pemanggilnya kini cukup menyusun []kasusPredikat lalu meneruskannya ke ujiTabel, dan beberapa fungsi pengujian dapat berbagi tabel yang sama tanpa menyalin perulangannya. Pemanggilan t.Helper() di awal fungsi bantuan itu penting: metode tersebut menyatakan bahwa fungsi ini adalah perantara, sehingga nomor baris pada laporan kegagalan menunjuk baris pemanggil di dalam fungsi pengujian, bukan baris t.Errorf di dalam helpernya. Tanpanya, setiap kegagalan akan selalu menunjuk baris yang sama pada fungsi bantuan dan nomor baris itu kehilangan gunanya.

Kesalahan umum#

Dua kesalahan berikut paling sering muncul ketika pola table-driven mulai dipakai.

  1. Memakai t.Fatalf di dalam perulangan. t.Fatalf menghentikan fungsi pengujian seketika, sehingga kasus-kasus setelah baris yang gagal tidak pernah dijalankan dan laporannya tidak lengkap. Gunakan t.Errorf di dalam perulangan, dan simpan t.Fatalf untuk keadaan yang membuat kasus berikutnya tidak bermakna.
  2. Pesan kegagalan tanpa penanda kasus. Pesan yang hanya memuat nilai masukan dan hasilnya memaksa pembaca menebak baris tabel mana yang keliru, terutama ketika dua baris memuat masukan yang mirip. Sertakan nama kasus atau indeks perulangannya pada setiap pesan.

Ringkasan#

  • Pola table-driven menyimpan seluruh kasus uji pada satu tabel, yaitu slice dari struct yang memuat nama, masukan, dan ekspektasi, lalu satu perulangan mengeksekusinya.
  • Menambahkan kasus berarti menambahkan satu baris tabel, dan tabelnya sekaligus membaca seperti spesifikasi perilaku fungsi.
  • Tabel yang dipakai ulang diekstrak menjadi tipe struct bernama beserta fungsi bantuan yang memanggil t.Helper agar nomor baris kegagalan tetap menunjuk pemanggilnya.
  • t.Errorf di dalam perulangan menjaga kasus lain tetap dijalankan, sedangkan t.Fatalf menghentikan seluruh fungsi pengujian, dan pesan yang menyebut nama kasus membuat baris yang keliru mudah ditemukan.

Referensi#