Menguji Aplikasi Go Anda: Memulai dengan Cara yang Benar
Diterbitkan: 2022-03-11Saat mempelajari sesuatu yang baru, penting untuk memiliki kondisi pikiran yang segar.
Jika Anda cukup baru di Go dan berasal dari bahasa seperti JavaScript atau Ruby, Anda mungkin terbiasa menggunakan kerangka kerja yang ada yang membantu Anda mengejek, menegaskan, dan melakukan uji coba lainnya.
Sekarang hilangkan gagasan ketergantungan pada dependensi atau kerangka kerja eksternal! Pengujian adalah hambatan pertama yang saya temukan ketika mempelajari bahasa pemrograman yang luar biasa ini beberapa tahun yang lalu, saat sumber daya yang tersedia jauh lebih sedikit.
Sekarang saya tahu bahwa menguji keberhasilan di Go berarti tidak terlalu bergantung pada dependensi (seperti semua hal di Go), sangat bergantung pada perpustakaan eksternal, dan menulis kode yang dapat digunakan kembali dengan baik. Presentasi pengalaman Blake Mizerany menjelajah dengan perpustakaan pengujian pihak ketiga adalah awal yang baik untuk menyesuaikan pola pikir Anda. Anda akan melihat beberapa argumen bagus tentang penggunaan pustaka dan kerangka kerja eksternal versus melakukannya dengan "jalan".
Ini mungkin tampak kontra-intuitif untuk membangun kerangka pengujian Anda sendiri dan konsep mengejek, tetapi lebih mudah daripada yang dipikirkan orang, dan titik awal yang baik untuk mempelajari bahasa. Plus, tidak seperti ketika saya belajar, Anda memiliki artikel ini untuk memandu Anda melalui skenario pengujian umum serta memperkenalkan teknik yang saya anggap sebagai praktik terbaik untuk menguji dan menjaga kode tetap bersih secara efisien.
Pengujian Tabel di Go
Unit pengujian dasar - ketenaran 'pengujian unit' - dapat berupa komponen program apa pun dalam bentuk paling sederhana yang mengambil input dan mengembalikan output. Mari kita lihat fungsi sederhana yang ingin kita tulis tesnya. Ini sama sekali tidak sempurna atau lengkap, tetapi cukup baik untuk tujuan demonstrasi:
avg.go
func Avg(nos ...int) int { sum := 0 for _, n := range nos { sum += n } if sum == 0 { return 0 } return sum / len(nos) }
Fungsi di atas, func Avg(nos ...int)
, mengembalikan nol atau rata-rata bilangan bulat dari serangkaian angka yang diberikan padanya. Sekarang mari kita menulis tes untuk itu.
Di Go, dianggap sebagai praktik terbaik untuk memberi nama file pengujian dengan nama yang sama dengan file yang berisi kode yang sedang diuji, dengan tambahan akhiran _test
. Misalnya, kode di atas ada dalam file bernama avg.go
, jadi file pengujian kita akan diberi nama avg_test.go
.
Perhatikan bahwa contoh-contoh ini hanya kutipan dari file yang sebenarnya, karena definisi paket dan impor telah dihilangkan untuk kesederhanaan.
Berikut adalah pengujian untuk fungsi Avg
:
avg__test.go
func TestAvg(t *testing.T) { for _, tt := range []struct { Nos []int Result int }{ {Nos: []int{2, 4}, Result: 3}, {Nos: []int{1, 2, 5}, Result: 2}, {Nos: []int{1}, Result: 1}, {Nos: []int{}, Result: 0}, {Nos: []int{2, -2}, Result: 0}, } { if avg := Average(tt.Nos...); avg != tt.Result { t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg) } } }
Ada beberapa hal yang perlu diperhatikan tentang definisi fungsi:
- Pertama, awalan 'Test' pada nama fungsi tes. Ini diperlukan agar alat akan mengambilnya sebagai tes yang valid.
- Bagian terakhir dari nama fungsi umumnya adalah nama fungsi atau metode yang diuji, dalam hal ini
Avg
. - Kita juga harus melewati struktur pengujian yang disebut
testing.T
, yang memungkinkan kontrol aliran pengujian. Untuk detail lebih lanjut tentang API ini, silakan kunjungi halaman dokumentasi.
Sekarang mari kita bicara tentang bentuk di mana contoh ditulis. Rangkaian pengujian (serangkaian pengujian) sedang dijalankan melalui fungsi Avg()
, dan setiap pengujian berisi masukan spesifik dan keluaran yang diharapkan. Dalam kasus kami, setiap pengujian mengirimkan sepotong bilangan bulat ( Nos
) dan mengharapkan nilai pengembalian tertentu ( Result
).
Mengejek Antarmuka Golang
Salah satu fitur terbesar dan paling kuat yang ditawarkan bahasa Go disebut antarmuka. Selain kekuatan dan fleksibilitas yang kami dapatkan dari antarmuka saat merancang program kami, antarmuka juga memberi kami peluang luar biasa untuk memisahkan komponen kami dan mengujinya secara menyeluruh di titik pertemuannya.
Mari kita ambil skenario imajiner di mana kita perlu membaca N byte pertama dari io.Reader dan mengembalikannya sebagai string. Ini akan terlihat seperti ini:
readn.go
// readN reads at most n bytes from r and returns them as a string. func readN(r io.Reader, n int) (string, error) { buf := make([]byte, n) m, err := r.Read(buf) if err != nil { return "", err } return string(buf[:m]), nil }
Jelas, hal utama yang harus diuji adalah fungsi readN
, ketika diberikan berbagai input, mengembalikan output yang benar. Ini dapat dilakukan dengan pengujian tabel. Tetapi ada dua aspek non-sepele lainnya yang harus kita bahas, yaitu memeriksa bahwa:
-
r.Read
dipanggil dengan buffer berukuran n. -
r.Read
mengembalikan kesalahan jika ada yang dilempar.
Untuk mengetahui ukuran buffer yang diteruskan ke r.Read
, serta mengendalikan kesalahan yang dikembalikan, kita perlu mengejek r
yang diteruskan ke readN
. Jika kita melihat dokumentasi Go pada tipe Reader, kita melihat seperti apa io.Reader
:
type Reader interface { Read(p []byte) (n int, err error) }
Itu tampaknya agak mudah. Yang harus kita lakukan untuk memenuhi io.Reader
adalah memiliki metode Read
tiruan kita sendiri. Jadi ReaderMock
kami dapat menjadi sebagai berikut:
type ReaderMock struct { ReadMock func([]byte) (int, error) } func (m ReaderMock) Read(p []byte) (int, error) { return m.ReadMock(p) }
Mari kita analisis kode di atas sebentar. Setiap instance ReaderMock
jelas memenuhi antarmuka io.Reader
karena mengimplementasikan metode Read
yang diperlukan. Mock kami juga berisi bidang ReadMock
, memungkinkan kami untuk mengatur perilaku yang tepat dari metode yang diejek, yang membuatnya sangat mudah bagi kami untuk membuat instance apa pun yang kami butuhkan secara dinamis.
Trik bebas memori yang bagus untuk memastikan bahwa antarmuka terpenuhi saat dijalankan adalah dengan memasukkan kode berikut ke dalam kode kita:
var _ io.Reader = (*MockReader)(nil)
Ini memeriksa pernyataan tetapi tidak mengalokasikan apa pun, yang memungkinkan kami memastikan bahwa antarmuka diimplementasikan dengan benar pada waktu kompilasi, sebelum program benar-benar menjalankan fungsi apa pun yang menggunakannya. Trik opsional, tetapi bermanfaat.
Selanjutnya, mari kita tulis pengujian pertama kita, di mana r.Read
dipanggil dengan buffer berukuran n
. Untuk melakukan ini, kami menggunakan ReaderMock
kami sebagai berikut:
func TestReadN_bufSize(t *testing.T) { total := 0 mr := &MockReader{func(b []byte) (int, error) { total = len(b) return 0, nil }} readN(mr, 5) if total != 5 { t.Fatalf("expected 5, got %d", total) } }
Seperti yang Anda lihat di atas, kami telah mendefinisikan perilaku untuk fungsi Read
dari io.Reader
"palsu" kami dengan variabel cakupan, yang nantinya dapat digunakan untuk menegaskan validitas pengujian kami. Cukup mudah.
Mari kita lihat skenario kedua yang perlu kita uji, yang mengharuskan kita untuk mengejek Read
untuk mengembalikan kesalahan:
func TestReadN_error(t *testing.T) { expect := errors.New("some non-nil error") mr := &MockReader{func(b []byte) (int, error) { return 0, expect }} _, err := readN(mr, 5) if err != expect { t.Fatal("expected error") } }
Dalam pengujian di atas, panggilan apa pun ke mr.Read
(Pembaca tiruan kami) akan mengembalikan kesalahan yang ditentukan, sehingga aman untuk mengasumsikan bahwa fungsi readN
yang benar akan melakukan hal yang sama.
Fungsi Mengejek dengan Go
Tidak sering kita perlu mengejek suatu fungsi, karena kita cenderung menggunakan struktur dan antarmuka sebagai gantinya. Ini lebih mudah untuk dikendalikan, tetapi kadang-kadang kita dapat bertemu dengan kebutuhan ini, dan saya sering melihat kebingungan di sekitar topik. Beberapa orang bahkan bertanya bagaimana cara mengejek hal-hal seperti log.Println
. Meskipun jarang terjadi kita perlu menguji input yang diberikan ke log.Println
, kita akan menggunakan kesempatan ini untuk mendemonstrasikan.

Pertimbangkan pernyataan if
sederhana di bawah ini yang mencatat keluaran tergantung pada nilai n
:
func printSize(n int) { if n < 10 { log.Println("SMALL") } else { log.Println("LARGE") } }
Dalam contoh di atas, kami mengasumsikan skenario konyol di mana kami secara khusus menguji log.Println
dipanggil dengan nilai yang benar. Agar kita bisa mengejek fungsi ini, kita harus membungkusnya di dalam kita sendiri terlebih dahulu:
var show = func(v ...interface{}) { log.Println(v...) }
Mendeklarasikan fungsi dengan cara ini - sebagai variabel - memungkinkan kita untuk menimpanya dalam pengujian dan menetapkan perilaku apa pun yang kita inginkan. Secara implisit, baris yang mengacu pada log.Println
diganti dengan show
, sehingga program kita menjadi:
func printSize(n int) { if n < 10 { show("SMALL") } else { show("LARGE") } }
Sekarang kita dapat menguji:
func TestPrintSize(t *testing.T) { var got string oldShow := show show = func(v ...interface{}) { if len(v) != 1 { t.Fatalf("expected show to be called with 1 param, got %d", len(v)) } var ok bool got, ok = v[0].(string) if !ok { t.Fatal("expected show to be called with a string") } } for _, tt := range []struct{ N int Out string }{ {2, "SMALL"}, {3, "SMALL"}, {9, "SMALL"}, {10, "LARGE"}, {11, "LARGE"}, {100, "LARGE"}, } { got = "" printSize(tt.N) if got != tt.Out { t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got) } } // careful though, we must not forget to restore it to its original value // before finishing the test, or it might interfere with other tests in our // suite, giving us unexpected and hard to trace behavior. show = oldShow }
Takeaway kami seharusnya tidak 'mock log.Println
', tetapi dalam skenario yang sangat sesekali ketika kami perlu mengejek fungsi tingkat paket untuk alasan yang sah, satu-satunya cara untuk melakukannya (sejauh yang saya ketahui) adalah dengan mendeklarasikannya sebagai variabel tingkat paket sehingga kita dapat mengontrol nilainya.
Namun, jika kita perlu untuk mengejek hal-hal seperti log.Println
, solusi yang jauh lebih elegan dapat ditulis jika kita menggunakan logger kustom.
Pergi Tes Rendering Template
Skenario lain yang cukup umum adalah menguji apakah output dari template yang diberikan sesuai dengan harapan. Mari kita pertimbangkan permintaan GET ke http://localhost:3999/welcome?name=Frank
, yang mengembalikan isi berikut:
<html> <head><title>Welcome page</title></head> <body> <h1 class="header-name"> Welcome <span class="name">Frank</span>! </h1> </body> </html>
Jika itu tidak cukup jelas sekarang, bukan kebetulan bahwa name
parameter kueri cocok dengan konten span
yang digolongkan sebagai "nama". Dalam hal ini, tes yang jelas adalah memverifikasi bahwa ini terjadi dengan benar setiap kali di beberapa keluaran. Saya menemukan perpustakaan GoQuery sangat membantu di sini.
Sekarang kita dapat menulis pengujian kita dengan cara ini:
welcome__test.go
func TestWelcome_name(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { t.Fatal(err) } if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" { t.Fatalf("expected markup to contain 'Frank', got '%s'", v) } }
Pertama, kami memeriksa bahwa kode respons adalah 200/OK sebelum melanjutkan.
Saya percaya bahwa tidak terlalu mengada-ada untuk berasumsi bahwa sisa potongan kode di atas sudah cukup jelas: kami mengambil URL menggunakan paket http
dan membuat dokumen baru yang kompatibel dengan goquery dari respons, yang kemudian kami gunakan untuk kueri DOM yang dikembalikan. Kami memeriksa bahwa span.name
di dalam h1.header-name
merangkum teks 'Frank'.
Menguji API JSON
Go sering digunakan untuk menulis semacam API, jadi yang terakhir, mari kita lihat beberapa cara tingkat tinggi untuk menguji API JSON.
Pertimbangkan jika titik akhir sebelumnya mengembalikan JSON alih-alih HTML, jadi dari http://localhost:3999/welcome.json?name=Frank
kami mengharapkan badan respons terlihat seperti:
{"Salutation": "Hello Frank!"}
Menegaskan respons JSON, seperti yang mungkin sudah diduga, tidak jauh berbeda dengan menegaskan respons templat, dengan pengecualian bahwa kita tidak memerlukan pustaka atau dependensi eksternal apa pun. Pustaka standar Go sudah cukup. Berikut adalah pengujian kami yang mengonfirmasi bahwa JSON yang benar dikembalikan untuk parameter yang diberikan:
welcome__test.go
func TestWelcome_name_JSON(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var dst struct{ Salutation string } if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil { t.Fatal(err) } if dst.Salutation != "Hello Frank!" { t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation) } }
Jika apa pun selain struktur yang kami dekode akan dikembalikan, json.NewDecoder
akan mengembalikan kesalahan dan pengujian akan gagal. Mempertimbangkan bahwa respons berhasil memecahkan kode terhadap struktur, kami memeriksa apakah konten bidang seperti yang diharapkan - dalam kasus kami "Halo Frank!".
Pengaturan & Pembongkaran
Pengujian dengan Go itu mudah, tetapi ada satu masalah dengan pengujian JSON di atas dan pengujian rendering template sebelumnya. Mereka berdua berasumsi bahwa server sedang berjalan, dan ini menciptakan ketergantungan yang tidak dapat diandalkan. Juga, bukan ide bagus untuk melawan server "langsung".
Untungnya, Go menawarkan paket httptest untuk membuat server uji. Pengujian memicu server terpisah mereka sendiri, independen dari server utama kami, sehingga pengujian tidak akan mengganggu produksi.
Dalam kasus ini, sangat ideal untuk membuat setup
umum dan fungsi teardown
untuk dipanggil oleh semua pengujian yang memerlukan server yang sedang berjalan. Mengikuti pola baru yang lebih aman ini, pengujian kami akan berakhir seperti ini:
func setup() *httptest.Server { return httptest.NewServer(app.Handler()) } func teardown(s *httptest.Server) { s.Close() } func TestWelcome_name(t *testing.T) { srv := setup() url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL) resp, err := http.Get(url) // verify errors & run assertions as usual teardown(srv) }
Perhatikan referensi app.Handler()
. Ini adalah fungsi praktik terbaik yang mengembalikan http.Handler aplikasi, yang dapat membuat instance server produksi atau server pengujian Anda.
Kesimpulan
Pengujian di Go adalah peluang bagus untuk mengambil perspektif luar program Anda dan mengambil posisi pengunjung Anda, atau dalam banyak kasus, pengguna API Anda. Ini memberikan peluang besar untuk memastikan Anda berdua memberikan kode yang baik dan pengalaman yang berkualitas.
Setiap kali Anda tidak yakin dengan fungsionalitas yang lebih kompleks dalam kode Anda, pengujian berguna sebagai jaminan, dan juga menjamin bahwa bagian-bagian tersebut akan terus bekerja sama dengan baik ketika memodifikasi bagian-bagian dari sistem yang lebih besar.
Saya harap artikel ini bermanfaat bagi Anda, dan Anda dipersilakan untuk berkomentar jika Anda mengetahui trik pengujian lainnya.