Erhan Yakut Software Developer @Binalyze | Founder @Passwall | Golang Enthusiast | Open Sorcerer

Go ile Nasıl Unit Test Yazılır

8 min read

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.

Erhan Yakut Software Developer @Binalyze | Founder @Passwall | Golang Enthusiast | Open Sorcerer