Yazılım dünyasında test yazmak eskiden bir seçenek olarak görülürdü. Günümüzde ise DevOps süreçlerinin iyileşmesi ile artık testler deployment süreçlerinin birer parçası haline gelmiş durumda. Bugün herhangi bir projeyi git repomuza gönderdiğimizde, CI/CD kapsamında önce yazdığımız testler çalıştırılıyor, başarılı olanlar derlenip sunucuya gönderiliyor, başarısız olanların bu süreci o anda durduruluyor. Konu bu kadar önemliyken artık testleri görmezden gelmek, testsiz kodu kabul etmek malesef mümkün görünmüyor. Neyse ki Go programlama dili ile test yazmak da oldukça pratik.
Unit Test ve Go Testing Kütüphanesi
Unit test ile yazılımların bağımsız kod bloklarının (fonksiyon, method vb.) testleri yapılır. Bunu yaparken de aslında temel olarak fonksiyona giren ve çıkan değerler karşılaştırılır. Go ile bu testleri yaparken Go’nun kendi kütüphanesi olan Testing‘den faydalanırız (ek bilgi). Şimdi hızlıca örnek bir testi inceleyeylim. Bu sırada dosya ve fonksiyon isimlendirmeleri ile test komutu gibi temel bilgileri de edinelim.
Not: Bu makalede kullanılan tüm kodlara, şu Github reposundan ulaşabilirsiniz.
Normal Test
Uygulamamızda bir toplama fonksiyonumuz bulunmaktadır ve aşağıda göreceğiniz şekilde bu fonksiyon, dizi halinde aldığı integer değerleri toplayıp, toplam sonucunu döndürmektedir. Konuyu sade bir şekilde anlatabilmek için olası hata senaryolarına hiç girmedim.
package tests import "testing" func Topla(sayilar []int) int { var toplam int for i := range sayilar { toplam = toplam + sayilar[i] } return toplam }
Bu topla kütüphanesinin topla.go isimli dosyada bulunduğunu varsayalım. Bunun testini yazmak için aynı dizinde topla_test.go dosyası oluşturuyor ve aşağıdaki şekilde testimizi yazıyoruz.
package tests import "testing" func TestTopla(t *testing.T) { sonuc := Topla([]int{2, 5}) if sonuc != 7 { t.Error("Beklenen sonuc 7, elde edilen ", sonuc) } }
Tam bu noktada Go ile test yazarken işin temelinin 3 temel kural olduğunu vurgulamam lazım.
Kural 1: Go’da test kodlarımızın bulunduğu dosyalar, testi yazılan dosyanın sonuna “_test” eklenerek isimlendirilir. Örneğin topla_test.go.
Kural 2: Go’da test fonksiyonunun ismi, testi yapılan fonksiyon isminin başına “Test” eklenerek oluşturulur. Örneğin TestTopla.
Kural 3: Test fonksiyonları (TestTopla gibi) her zaman sadece t *testing.T parametresini alır ve herhangi bir şey döndürmez. Test sonucu doğrudan t.Error() ile ekrana yazdırılır.
Yukarıdaki teste baktığımızda, TestTopla() içinde Topla fonksiyonuna elle parametre giriyor (2 ve 5) ve çıkan sonucun yine kendi belirttiğimiz beklentimizi (7) karşılayıp karşılamadığına bakıyoruz. Eğer karşılamıyorsa test başarısızdır diyor ve sonucu ekrana yazdırıyoruz.
Peki bu test kodları nasıl çalıştırılacak derseniz, terminalde go test -v komutunu kullanıyoruz ve aşağıdaki gibi bir test sonucu alıyoruz.
➜ go test topla_test.go -v === RUN TestTopla --- PASS: TestTopla (0.00s) PASS ok command-line-arguments 0.006s
Başarılı bir test sonucu yukarıdaki şekilde iken, başarısız test sonucu da şu şekilde görünecektir.
➜ go test topla_test.go -v === RUN TestTopla --- FAIL: TestTopla (0.00s) normalTest_test.go:19: Beklenen sonuc 6, elde edilen 7 FAIL FAIL command-line-arguments 0.007s
Test Komutları
Hazır hızlıca bir testin üzerinden geçmişken test komutlarına değinmekte fayda var. Böylece yazının devamındaki testleri bu komutlar ile çalıştırabilir ve daha detaylı çıktılar alabilirsiniz.
// Test parametreleri için yardım komutu go help testflag // Standart test go test // Detaylı test çıktısı (v = verbose) Her zaman bunu kullanın. go test -v // Belirli bir klasördeki testleri çalıştırma go test ./tests/http -v // Belirli bir dosyadaki testleri çalıştırma go test logic_test.go // Test ismini kullanarak belirli bir testi çalıştırma // -run ile Regexp kullanabilirsiniz go test -run TestIsmi // Belirli bir paketteki testleri çalıştırma go test paketismi -run TestIsmi // Bir subtesti çalıştırma go test -run=TestAna/alttest -v // Test Coverage go test -coverprofile cover.out
Table Test
Genelde testler için tek girdi ve çıktı değil, table dizayn ile birden fazla değer kullanılır. Yani girdileri ve beklentileri gösteren bir matris hazırlanır ve bir döngü içerisinde tüm matris değerlerine göre test gerçekleştirilir. Söz konusu matris, bir dizi struct‘tır. Temel olarak aşağıdaki şekilde tanımlama yapılır. Topla fonksiyonumuzu şu şekilde table dizayna uygun olarak test edebiliriz. Bu arada en sona bilerek bir hata bıraktım.
var tableDegerleri = []struct { girdix int girdiy int beklenenSonuc int }{ {2, 2, 4}, {5, 3, 8}, {8, 4, 12}, {12, 5, 19}, } func TestTopla(t *testing.T) { for _, deger := range tableDegerleri { eldeEdilenSonuc := Topla([]int{deger.girdix, deger.girdiy}) if eldeEdilenSonuc != deger.beklenenSonuc { t.Errorf("Beklenen sonuc %d, elde edilen %d", deger.beklenenSonuc, eldeEdilenSonuc) } } }
Subtest
Yazı ilerledikçe testing kütüphanesinin methodlarını öğreniyoruz. Şu ana kadar sadece t.Error() methodu ile hata bildirimini gördük. Şimdi de t.Run() ile subtest oluşturalım. Aslında unit test felsefesi bağımsız testler olarak değerlendirilse de bazen bir test içinde birden fazla subtest’ler ile testlerimiz gerçekleştirebiliriz. Özellikle de testleri belirli bir sırada çalıştırmak istiyorsak faydalı olacaktır. Genel olarak subtest yapısı şu şekildedir.
func TestSubtests(t *testing.T) { t.Run("A", func(t *testing.T) { t.Log("Test A1 tamamlandı") }) t.Run("B", func(t *testing.T) { t.Log("Test B tamamlandı") }) t.Run("C", func(t *testing.T) { t.Log("Test C tamamlandı") }) }
Şimdi de Table Test’teki ToplaTest fonksiyonunu subtest mantığıyla güncelleyelim.
var tableDegerleri = []struct { girdix int girdiy int beklenenSonuc int }{ {2, 2, 4}, {5, 3, 8}, {8, 4, 12}, {12, 5, 19}, } func TestTopla(t *testing.T) { for _, deger := range tableDegerleri { testIsmi := fmt.Sprintf("Test %d+%d", deger.girdix, deger.girdiy) // Subtest t.Run(testIsmi, func(t *testing.T) { eldeEdilenSonuc := Topla([]int{deger.girdix, deger.girdiy}) if eldeEdilenSonuc != deger.beklenenSonuc { t.Errorf("Beklenen sonuc %d, elde edilen %d", deger.beklenenSonuc, eldeEdilenSonuc) } }) } }
Paralel Test
Paralel kavramı yazılım dilinde eş zamanlı işlemlere (multi thread) karşılık gelir. Bunun da Go’daki karşılığı bildiğiniz gibi goroutine’dir. Paralel testte yukarıda görmüş olduğunuz subtest’ler sırayla değiş, eş zamanlı olarak çalıştırılır. Testing kütüphanesinin gücü sayesinde normalde oldukça baş ağrıtacak bu konu sadece t.Paralel() fonksiyonuyla çözülebilmektedir.
func TestParalel(t *testing.T) { t.Parallel() tests := []struct { name string }{ {"test 1"}, {"test 2"}, {"test 3"}, {"test 4"}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() t.Log(tt.name) }) } }
Normal bir table testte işlem çıktısının 1, 2, 3, 4 şeklinde sıralı olmasını beklersiniz. Ancak paralel testi her çalıştırdığınızda bu sıranın farklı olduğunu görürsünüz çünkü test işlemi sırayla değil, eş zamanlı olarak çalışır ve ilk hangisinin belli olmaz. Mesele şimdi çalıştırdığımda test şu şekilde (1, 3, 2, 4) sonuçlandı.
➜ go test paralelTest_test.go -v === RUN TestParalel --- PASS: TestParalel (0.00s) --- PASS: TestParalel/test_1 (0.00s) paralelTest_test.go:21: test 1 --- PASS: TestParalel/test_3 (0.00s) paralelTest_test.go:21: test 3 --- PASS: TestParalel/test_2 (0.00s) paralelTest_test.go:21: test 2 --- PASS: TestParalel/test_4 (0.00s) paralelTest_test.go:21: test 4 PASS
TestMain
Go programlama dilinde normal çalışma işlemi Main fonksiyonundan başlamaktadır. İşte bu mantığın test ortamı için olan karşılığı TestMain() fonksiyonudur. Tabi burada ilave olarak TestMain içinde çağırılacak olan m.Run() komutu ile o dosyadaki tüm test fonksiyonlarının çağırılacağını belirtmek lazım. Aşağıdaki örnekte bu kullanımı görebilirsiniz.
func TestA(t *testing.T) { t.Log("Test A çalıştı") } func TestB(t *testing.T) { t.Log("Test B çalıştı") } func TestMain(m *testing.M) { // setup() exitVal := m.Run() if exitVal == 0 { // teardown() } os.Exit(exitVal) }
TestMain için yazmış olduğumuz bu örnek uygulamada TestA ve TestB sırayla çalıştırır. Uygulama çıktısı da şu şekilde olacaktır.
➜ go test TestMain_test.go -v === RUN TestA --- PASS: TestA (0.00s) testMain_test.go:9: Test A çalıştı === RUN TestB --- PASS: TestB (0.00s) testMain_test.go:13: Test B çalıştı PASS ok command-line-arguments (cached)
Setup ve Teardown
Bu makale ile hedeflediğim hususlardan birisi de dilden bağımsız olarak, test ortamlarında kullanılan terimlerin öğrenilmesi. Tam da bu noktada Setup ve Teardown’a değinmek lazım.
Setup: Test ortamında bazen veritabanına bağlanır, bazen de sırf test için özel dosyalar oluştururuz. İşte bu hazırlık olarak değerlendirebileceğimiz işlemler setup() fonksiyonunda gerçekleştirilir. Kısaa yapılacak testler için hazırlık fonksiyonudur diyebiliriz.
Teardown: Setup ile yapılmış veritabanı bağlantıları, oluşturulan dosyalar vb. teardown ile yeniden eski haline getirilir. Temizlik fonksiyonudur da diyebiliriz. Bunun için teardown() isimli ayrı bir fonksiyon yazabilir ya da Testing paketinin t.Cleanup() fonksiyonundan faydalanabilirsiniz.
Assertion
Aslında assertion kavramına makalenin başında değinebilirdim ancak ilave paket ile kullanımını anlatacağım için buraya bıraktım. Buraya kadar yazmış olduğumuz testlerde kontrol işlemini hep if got != want { } şeklinde yaptığımızı farketmişsinizdir. Sürekli test yazmaya başlayınca bunun bir zaman kaybı olduğunu, daha pratik yolları olması gerektiğini düşünüyorsunuz. İşte tam o aşamada assertion kavramı ortaya çıkıyor. Go’da malesef native olarak bu özellik bulunmuyor. Neyse ki bu eksikliği gidermek için Testify isimli oldukça pratik bir Go paketi bulunuyor. Toplama işlemi ile ilgili yaptığımız örneği assertion ile şöyle geliştirebiliriz. Burada eşittir, eşit değildir, nil’dir, nil değildir gibi kontrolleri, if yazmadan tek satır ile rahatlıkla nasıl çözüldüğüne dikkat ediniz.
func Topla(sayilar []int) (int, error) { toplam := 0 for i := range sayilar { toplam = toplam + sayilar[i] } return toplam, nil } func Topla(t *testing.T) { sonuc, err := AssertionTopla([]int{2, 5}) // Eşittir assert.Equal(t, 7, sonuc, "sonuç eşit olmalı") // Eşit değildir assert.NotEqual(t, 6, sonuc, "Sonuçlar eşit olmamalı") // Sonuc nil olmalıdır assert.Nil(t, err) // Sonuç nil değilse err = errors.New("toplanmadi") if assert.NotNil(t, err) { // Sonucun nil olmadığını bildiğimiz için ilave testler yapabiliriz assert.Equal(t, "toplanmadi", err.Error()) } }
HTTP Test
Projelerimizde her zaman basit fonksiyonlar olmayacaktır. Bununla birlikte API uç noktalarının (endpoint) testini de yapmamız gerekebilir ve onlara normal bir fonksiyon gibi davranamayız. Onlara aynı gerçek ortamdaki gibi HTTP Request armalı ve dönen status kod (200, 404, 503 vb.) ile değeri (JSON, XML vb.) test etmeliyiz. Tabi bunu yaparken gerçek çalışan API uçlarına istek atılmaz. Projenin route’ları alınarak, sahte bir sunucu ayağa kaldırıp, oraya istek atılır ve dönen cevaplar planlanır. Bir nevi mockup’tır aslında bu yapılan. Bunu yaparken de yine Go’nun kendi paketlerinden olan net/http/httptest‘ten faydalanacağız. Aşağıdaki örnek ile httptest’in nasıl kullanıldığını görebilirsiniz.
package main import ( "io" "net/http" "net/http/httptest" "testing" ) func TestHttp(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "pong") } req := httptest.NewRequest("GET", "/ping", nil) w := httptest.NewRecorder() handler(w, req) // Status code test if w.Code != 404 { t.Error("Http test isteği başarısız") } // Return value test if w.Body.String() != "pong" { t.Error("Dönen cevap farklı, test başarısız") } }
Burada kısaca projenin route’larını aldık, her bir route için dönmesi gereken handle fonksiyonunu yazdık ve dönen status kod ile değeri test ettik.
Mock, Mockup, Mocklama
Bazı durumlarda testini yapcağımız fonksiyonları test içinde öyle gelişi güzel çağıramayız. Bu fonksiyon veritabanına kayıt etkliyor, bir şeyleri siliyor veya binlerce müşteriye eposta gönderiyor olabilir. İşte böyle durumda bu fonksiyon ile aynı girdi alan ve aynı sonucu dönen kopya (instance) obje oluşturur ve testimizi bu kopya obje üzerinde gerçekleştiririz. İşte bu kopyaya Mock veya Mockup ve bu işleme genel olarak yerli tabirle Mocklama denilir.
Mock konusunda hazır paket olarak assertion için de kullandığımız Testify paketini tavsiye ederim. Ama tabi doğrudan SQL işlemleri için hazırlanmış go-sqlmock’a da göz gezdirmekte fayda var.
Kaynaklar
- http://blog.oguzhan.info/?p=1087
- https://github.com/avelino/awesome-go#testing
- https://medium.com/goingogo/why-use-testmain-for-testing-in-go-dafb52b406bc
- https://tutorialedge.net/golang/advanced-go-testing-tutorial/
- https://dev.to/rafaacioly/mocking-tests-with-golang-45d3
- https://github.com/quii/learn-go-with-tests
- https://medium.com/@rosaniline/unit-testing-gorm-with-go-sqlmock-in-go-93cbce1f6b5b
- https://blog.golang.org/subtests
- https://pkg.go.dev/testing?tab=doc
Son Sözler
Go ile nasıl test yazılacağını anlatmış olduğum bu makale sadece okuma ile soyut kalacaktır. Bu soyut kavramlar ancak Github reposundaki kodları alıp, kendi ortamınızda test ettiğinizde somutlaşacaktır. O aşamadan sonra artık projelerinizde TDD mantığıyla önce test yazma ve sonra o testi karşılayacak fonksiyonu yazma disipliniyle ilerlerseniz, çok daha stabil çalışan uygulamalar geliştirebilirsiniz.