Mocking

Bu bölümün bütün kodlarını burada bulabilirsiniz

3'ten geriye sayan bir program yazmanız istendi, her bir numarayı yeni bir satırda (1 saniye aralıklarla) yazdıracak ve sıfır olduğunda "Go!" yazacak.

3
2
1
Go!

Bunu çözmek için CountDown isminde fonksiyon yazacağız daha sonra main metodunun içine koyacağız. Sonra bunun gibi gözükecek:

package main

func main() {
    Countdown()
}

Bu oldukça önemsiz bir program olsa da, tamamen test etmek için her zaman olduğu gibi yinelemeli (iterative), test odaklı (test driven) bir yaklaşım benimsememiz gerekecek.

Yinelemeli derken neyi kastettim? Kullanışlı yazılıma sahip olmak için en küçük adımları attığımızdan emin oluruz.

Bazı hacking tekniklerinden sonra teorik olarak çalışacak bir kodla uzun zaman harcamak istemiyoruz çünkü geliştiriciler genellikle bu şekilde tavşan deliklerine düşüyorlar. Çalışan bir yazılıma sahip olabilmeniz için gereksinimleri olabildiğince küçük parçalara ayırabilmek önemli bir beceridir.

Çalışmamızı şu şekilde bölebilir ve üzerinde yineleyebiliriz:

  • 3'ü Yazdır

  • 3, 2, 1 ve Go! Yazdır

  • Her satır arasında 1 saniye bekle

İlk olarak test yaz

Yazılımımızın stdout'a yazdırması gerekiyor ve bunu DI bölümünde test etmeyi kolaylaştırmak için DI'yi nasıl kullanabileceğimizi gördük.

func TestCountdown(t *testing.T) {
    buffer := &bytes.Buffer{}

    Countdown(buffer)

    got := buffer.String()
    want := "3"

    if got != want {
        t.Errorf("got %q want %q", got, want)
    }
}

buffer gibi şeyler tanıdık değilse, bir önceki bölümü tekrar okuyun the previous section.

Countdown fonksiyonumuzun bir yere veri yazmasını istediğimizi biliyoruz ve io.Writer Go'da bu yolu fiilen interface olarak yapmakta.

  • main içerisinde os.Stdout'u göndereceğeğiz bu sayde kullanıcılarımız gerisayımın çıktılarını terminalde görebilecek.

  • Test içerisinde bytes.Buffer göndereceğiz bu sayede testimiz üretilmiş veriyi yakalayabilecek.

Dene ve testi çalıştır

./countdown_test.go:11:2: undefined: Countdown

Testin çalışması için minimum kodu yaz ve başarısız test çıktılarını kontrol et

Countdown'ı tanımla

func Countdown() {}

Tekrar dene

./countdown_test.go:11:11: too many arguments in call to Countdown
    have (*bytes.Buffer)
    want ()

The compiler is telling you what your function signature could be, so update it.

func Countdown(out *bytes.Buffer) {}

countdown_test.go:17: got '' want '3'

Harika!

Testi geçecek kadar kod yaz

func Countdown(out *bytes.Buffer) {
    fmt.Fprint(out, "3")
}

(*bytes.Buffer gibi olan) io.Writer'ı parametre alan fmt.Fprint kullanıyoruz ve ona string gönderiyoruz. Şimdi test geçmeli

Refactor

*bytes.Buffer çalışırken bunun yerine genel amaçlı bir interface kullanmanın daha iyi olacağını biliyoruz.

func Countdown(out io.Writer) {
    fmt.Fprint(out, "3")
}

Testleri tekrar çalıştırın ve şimdi geçiyor olmalılar.

Konuları tamamlamak için, fonksiyonumuzu main'e bağlayalım, böylece ilerleme kaydettiğimizden emin olmak için çalışan bazı yazılımlarımız olur.

package main

import (
    "fmt"
    "io"
    "os"
)

func Countdown(out io.Writer) {
    fmt.Fprint(out, "3")
}

func main() {
    Countdown(os.Stdout)
}

Programı deneyin ve çalıştırın ve el emeğinize hayran kalın.

Evet, bu önemsiz görünüyor ama bu yaklaşım, herhangi bir proje için tavsiye edeceğim şeydir. İnce bir işlevsellik alın ve testlerle desteklenen uçtan uca çalışmasını sağlayın.

Daha sonra 2,1 ve "Go!" yazdırabilecek.

İlk olarak test yaz

Genel tesisatın doğru çalışmasına yatırım yaparak, çözümümüzü güvenli ve kolay bir şekilde yineleyebiliriz. Tüm logic test edildiğinden, çalıştığından emin olmak için programı durdurup yeniden çalıştırmamız gerekmeyecek..

func TestCountdown(t *testing.T) {
    buffer := &bytes.Buffer{}

    Countdown(buffer)

    got := buffer.String()
    want := `3
             2
             1
             Go!`

    if got != want {
        t.Errorf("got %q want %q", got, want)
    }
}

backtick syntax string oluşturmak için başba bir yoldur ancak testimiz için mükemmel olan yeni satırlar gibi şeyleri koymanıza izin verir.

Dene ve testi çalıştır

countdown_test.go:21: got '3' want '3
        2
        1
        Go!'

Testi geçecek kadar kod yaz

func Countdown(out io.Writer) {
    for i := 3; i > 0; i-- {
        fmt.Fprintln(out, i)
    }
    fmt.Fprint(out, "Go!")
}

i-- ile geriye doğru sayan bir for döngüsü kullan ve fmt.Println kullanarak numaramızın ardından bir satır sonu karakteri ile out çıktısını alın. Sonunda "Go!" göndermek için fmt.Fprint'i kullan.

Refactor

Bazı sihirli değerleri adlandırılmış sabitlere yeniden düzenlemekten başka yeniden düzenleme yapacak pek bir şey yok.

const finalWord = "Go!"
const countdownStart = 3

func Countdown(out io.Writer) {
    for i := countdownStart; i > 0; i-- {
        fmt.Fprintln(out, i)
    }
    fmt.Fprint(out, finalWord)
}

Eğer programı çalıştırırsanız, istediğiniz çıktıyı almalısınız ancak 1 saniyelik duraklamalı dramatik geri sayımımız yok.

Go bunu time.Sleep ile başarmamızı sağlar. Koda eklemeyi dene.

func Countdown(out io.Writer) {
    for i := countdownStart; i > 0; i-- {
        time.Sleep(1 * time.Second)
        fmt.Fprintln(out, i)
    }

    time.Sleep(1 * time.Second)
    fmt.Fprint(out, finalWord)
}

Eğer programı çalıştırırsan istediğimiz gibi çalışacaktır.

Mocklama

Testler hala geçiyor ve yazılım beklenildiği gibi çalışıyor ancak bazı problemlerimiz var:

  • Testimizin çalışması 4 saniye sürüyor.

    • Yazılım geliştirme hakkındaki her ileri görüşlü yazı, hızlı geri bildirim döngülerinin önemini vurgular.

    • Yavaş tesler geliştiricinin üretkenliğini mahveder.

    • Daha fazla test için gereksinimlerin daha karmaşık hale geldiğini hayal edin. Her yeni Countdown testi için test çalışmasına eklenen 4s'den memnun muyuz?

  • Fonksiyonumuzun önemli bir özelliğini test etmedik.

Sleep'e bağımlılığımız var ve bunu testlerimizde kontrol edebilmemiz için çıkarmamız gerekli.

Eğer time.Sleep'i mocklayabilirsek, "gerçek" time.Sleep yerine dependency injection kullanabiliriz ve onlar hakkında iddialarda bulunmak için çağrıları gözetleyebilir.

İlk olarak test yaz

Bağımlılığımız interface olarak tanımlayalım. Bu, daha sonra testlerimizde main'de gerçek Sleeper ve testlerimizde spy sleeper kullanmamızı sağlar. Interface kullanarak `Countdown fonksiyonumuz habersiz olur ve çağrıyı yapan için biraz esneklik ekler.

type Sleeper interface {
    Sleep()
}

Countdown fonksiyonu uyku süresinden sorumlu olmayacak bir tasarıma karar verdim. Bu, kodumuzun en azından şimdilik biraz basitleştirir ve fonksiyonumuzun bir kullanıcısının bu uykuyu istedikleri gibi yapılandırılabileceği anlamına gelir.

Şimdi testlerimizin kullanması için mock yapmalıyız

type SpySleeper struct {
    Calls int
}

func (s *SpySleeper) Sleep() {
    s.Calls++
}

Spylar, bağımlılıkların nasıl kullanıldığını kaydedebilen bir tür mockturlar. Gönderilen argümanı kaç kez çağırıldıkları vb. kaydedebilirler. Bizim durumumuzda, Sleep()'in kaç kez çağırıldığını kaydediyoruz bu sayede testimizde kontrol edebiliyoruz.

Spy'ımıza bağımlılık inject etmek için testleri güncelle ve sleepin 4 kez çağırıldığını doğrula.

func TestCountdown(t *testing.T) {
    buffer := &bytes.Buffer{}
    spySleeper := &SpySleeper{}

    Countdown(buffer, spySleeper)

    got := buffer.String()
    want := `3
2
1
Go!`

    if got != want {
        t.Errorf("got %q want %q", got, want)
    }

    if spySleeper.Calls != 4 {
        t.Errorf("not enough calls to sleeper, want 4 got %d", spySleeper.Calls)
    }
}

Dene ve testi çalıştır

too many arguments in call to Countdown
    have (*bytes.Buffer, *SpySleeper)
    want (io.Writer)

Testin çalışması için minimum kodu yaz ve başarısız test çıktılarını kontrol et

Sleeper'ı kabul etmesi için Countdown'u güncellemeliyiz

func Countdown(out io.Writer, sleeper Sleeper) {
    for i := countdownStart; i > 0; i-- {
        time.Sleep(1 * time.Second)
        fmt.Fprintln(out, i)
    }

    time.Sleep(1 * time.Second)
    fmt.Fprint(out, finalWord)
}

Eğer tekrar denerseniz, main fonksiyonunuz tekrar aynı sebeplerden derlenmeyecektir

./main.go:26:11: not enough arguments in call to Countdown
    have (*os.File)
    want (io.Writer, Sleeper)

Hadi ihtiyacımız olan interfacei implemente eden gerçek sleeperı oluşturalım

type DefaultSleeper struct {}

func (d *DefaultSleeper) Sleep() {
    time.Sleep(1 * time.Second)
}

Daha sonra gerçek uygulamamızda şöyle kullanabiliriz

func main() {
    sleeper := &DefaultSleeper{}
    Countdown(os.Stdout, sleeper)
}

Testi geçecek kadar kod yaz

Test derleniyor ancak geçmiyor çünkü inject edilmiş bağımlılık yerine hala time.Sleep'i çağırıyoruz. Hadi bunu düzeltelim.

func Countdown(out io.Writer, sleeper Sleeper) {
    for i := countdownStart; i > 0; i-- {
        sleeper.Sleep()
        fmt.Fprintln(out, i)
    }

    sleeper.Sleep()
    fmt.Fprint(out, finalWord)
}

Şimdi test geçmeli ve 4 saniye sürmemeli

Hala problemler var

Hala test etmediğimiz önemli bir özellik var.

Countdown her çıktıdan önce uyumalı, örn:

  • Uyu

  • N'i yazdır

  • Uyu

  • N-1'i yazdır

  • Uyu

  • Go!'yu yazdır

  • vb

Son değişikliğimiz sadece 4 kez uyuduğunu iddia ediyor ancak bu uyumalar sıra dışı gerçekleşebilir.

Testleri yazarken, testlerinizin size yeterli güven verdiğinden emin değilseniz, testlerinizi bozun! (öncelikle değişikliklerinizi kaynak denetimine adadığınızdan emin olun). Kodu aşağıdaki şekilde değiştirin

func Countdown(out io.Writer, sleeper Sleeper) {
    for i := countdownStart; i > 0; i-- {
        sleeper.Sleep()
    }

    for i := countdownStart; i > 0; i-- {
        fmt.Fprintln(out, i)
    }

    sleeper.Sleep()
    fmt.Fprint(out, finalWord)
}

Testleri çalıştırırsanız, implementasyon yanlış olsa da geçecektir.

İşlem sırasının doğru olup olmadığını kontrol etmek için yeni bir testle spyı tekrar kullanalım.

İki farklı bağımlılığımız var ve tüm işlemlerini tek bir listeye kaydetmek istiyoruz. Böylece ikisi için de bir spy yaratacağız.

type SpyCountdownOperations struct {
    Calls []string
}

func (s *SpyCountdownOperations) Sleep() {
    s.Calls = append(s.Calls, sleep)
}

func (s *SpyCountdownOperations) Write(p []byte) (n int, err error) {
    s.Calls = append(s.Calls, write)
    return
}

const write = "write"
const sleep = "sleep"

SpyCountdownOperations,her çağrıyı bir slicea kaydederek io.Writer ve Sleeper'ı implemente eder. Bu testte sadece operasyonların sırası bizi alakadar etmekte bu yüzden sadece operasyonların ismini kadetmek yeterli.

Test suitemize uyuma ve yazma operasyonlarımızın umduğumuz gibi doğur sırada gerçeklestiğini doğrulaması için alt test ekleyebiliriz

t.Run("sleep before every print", func(t *testing.T) {
    spySleepPrinter := &SpyCountdownOperations{}
    Countdown(spySleepPrinter, spySleepPrinter)

    want := []string{
        sleep,
        write,
        sleep,
        write,
        sleep,
        write,
        sleep,
        write,
    }

    if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
        t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
    }
})

Bu test başrısız olmalı. Testi düzeltmek için Countdown'ı eski haline getirelim.

Sleeper üzerinde spylık yapan iki testimiz var, testimizi yeniden düzenleyebiliriz böylece biri yazılanları test eder diğeri de yazılanlar arasında uyuduğumuzdan emin olur. Sonunda, ilk spyımuzu kullanılmadığı için silebiliriz.

func TestCountdown(t *testing.T) {

    t.Run("prints 3 to Go!", func(t *testing.T) {
        buffer := &bytes.Buffer{}
        Countdown(buffer, &SpyCountdownOperations{})

        got := buffer.String()
        want := `3
2
1
Go!`

        if got != want {
            t.Errorf("got %q want %q", got, want)
        }
    })

    t.Run("sleep before every print", func(t *testing.T) {
        spySleepPrinter := &SpyCountdownOperations{}
        Countdown(spySleepPrinter, spySleepPrinter)

        want := []string{
            sleep,
            write,
            sleep,
            write,
            sleep,
            write,
            sleep,
            write,
        }

        if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
            t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
        }
    })
}

Artık fonksiyonumuz ve iki önemli özelliği düzgünce test edildi.

Sleeper'ı düzenlenebilir hale getirme

Sleeper'ın düzenlenebilir olması harika bir özellik olurdu. Bunun anlamı ana programımız içerisinden uyuma zamanını ayarlayabiliriz.

İlk olarak test yaz

Ayarlama ve test için ne gerekiyorsa kabul eden ConfigurableSleeper tipini oluşturalım.

type ConfigurableSleeper struct {
    duration time.Duration
    sleep    func(time.Duration)
}

Uyuma süresini ayarlamak ve sleep fonksiyonuna parametre göndermek için duration'ı kullanıyoruz. sleep'in metod imzası (signature) time.Sleep ile aynı olması bize time.Sleep'i gerçek uygulamada kullanmamızı, sleep'i ise testlerimizde kullanmamızı sağlıyor:

type SpyTime struct {
    durationSlept time.Duration
}

func (s *SpyTime) Sleep(duration time.Duration) {
    s.durationSlept = duration
}

Spyımız yerindeyken, ayarlanablir sleeper için yeni test oluşturabiliriz.

func TestConfigurableSleeper(t *testing.T) {
    sleepTime := 5 * time.Second

    spyTime := &SpyTime{}
    sleeper := ConfigurableSleeper{sleepTime, spyTime.Sleep}
    sleeper.Sleep()

    if spyTime.durationSlept != sleepTime {
        t.Errorf("should have slept for %v but slept for %v", sleepTime, spyTime.durationSlept)
    }
}

Testte yeni bir şey olmamalı ve önceki mock testler ile kurulumu çok benzer olmalı.

Dene ve testi çalıştır

sleeper.Sleep undefined (type ConfigurableSleeper has no field or method Sleep, but does have sleep)

ConfigurableSleeper'ımızda Sleep metodunun olmadığını ima eden çok anlaşılabilir bir hata mesajı görmelisiniz.

Testin çalışması için minimum kodu yaz ve başarısız test çıktılarını kontrol et

func (c *ConfigurableSleeper) Sleep() {
}

Sleep fonksiyonumuzu implemente ettikten sonra başarısı olan bir testimiz var.

countdown_test.go:56: should have slept for 5s but slept for 0s

Testi geçeek kadar kod yaz

Tek yapmamız gereken ConfigurableSleeper için Sleep fonksiyonunuzu implemente etmek.

func (c *ConfigurableSleeper) Sleep() {
    c.sleep(c.duration)
}

Bu değişiklikle birlikte tüm testler tekrar geçmeli ve tüm bu rahatsızlığa rağmen main program neden hiç değişmediğini merak edebilirsiniz. Umarım sonraki bölümden sonra netleşir.

Temizleme ve refactor

Yapmamız gereken son şey main fonksyionda ConfigurableSleeper'ı kullanmak.

func main() {
    sleeper := &ConfigurableSleeper{1 * time.Second, time.Sleep}
    Countdown(os.Stdout, sleeper)
}

Eğer testi ve uygulamayu çalıştırırsak tüm davranışların aynı kaldığını görebiliriz.

ConfigurableSleeper'ı kullandığımız için DefaultSleeper'ı silmek artık güvenli. Programımızı tamamlıyoruz ve daha kapsamlı (generic) ve keyfi uzunlukta gerisayımları olan Sleeperımız oluyor.

Mocklama şeytani değilmiydi ?

Mocklamanın şeytani olduğunu duymuşsundur. Yazılım geliştirmede herhangi bir şey gibi, kötülük için kullanılabilir, DRY gibi.

İnsanlar normalde testlerini dinlemediklerinde ve refactoring aşamasına uymadıklarında kötü bir duruma girerler.

Eğer mocking kodunuz karmaşıklaşıyor veya bir şeyi test etmek için çok fazla mockunuz varsa, bu kötü duyguyu dinlemeli ve kodunuzu düşünmelisiniz. Genelde bir işarettir

  • Test ettiğiniz şey çok fazla şey yapıyorsa(çünkü mocklamak için çok fazla bağımlılığı var)

    • Modülü parçalara ayırın, böylece daha az iş yapar

  • Bağımlılıkları çok ince taneli

    • Bu bağımlılıklardan bazılarını tek bir anlamlı modülde nasıl birleştirebileceğinizi düşünün.

  • Testiniz implementasyon detayları ile fazla fazla ilgili

    • Uygulamadan ziyade beklenen davranışı test etmeyi tercih edin

Normade, çok mocklama koduzunda kötü soyutlamaya bir işarettir.

İnsanların TDD'yi zayıflık olarak gördüğü şey aslında güçtür, çoğu zaman zayıf test kodu kötü tasarımın sonucudur veya daha güzel bir şekilde ifade edilirse, iyi tasarlanmış kod kolay test edilir.

Ama mocklar ve testler hala hayatımı zorlaştırıyor!

Hiç bu durumla karşılaştın mı?

  • Refactoring yapmak istemek

  • Bunu yapmak için bir çok testi değiştirmek

  • TDD'yi sorgularsınız ve Medium'da Mocklama zararlı olarak kabul edilir diye paylaşım yaparsınız

Bu genelde çok fazla implementasyon detayını test ettiğinize dair bir işarettir. Sistemin nasıl çalıştığı implementasyon için gerçekten önemli olana kadar testlerinizin kullanışlı davranışları test etmeye çalışın.

Tam olarak hangi seviyenin test edileceğini bilmek bazen zordur, ancak burada uymaya çalıştığım bazı düşünce süreçleri ve kurallar vardır:

  • Refactoringin tanımı, kodun değişmesi ancak davranışın aynı kalmasıdır. Teoride bazı yeniden düzenleme yapmaya karar verdiyseniz, herhangi bir test değişikliği yapmadan taahhütte bulunabilmelisiniz. Bu yüzden bir test yazarken kendinize sorun

    • İstediğim davranışı mı test ediyorum yoksa implementasyon detaylarını mı?

    • Bu kodu refactor etseydim, testlerde çok fazla değişiklik yapmak gerekir miydi?

  • Go, private fonksiyonları test etmenize izin verse de, private fonksiyonlar, genel davranışı desteklemek için implentasyon ayrıntısı olduğu için bundan kaçınırdım. Açık davranışları test et. Sandi Metz, private fonksiyonları "daha az kararlı" olarak tanımlar ve testlerinizi bunlarla birleştirmek istemezsiniz.

  • Eğer bir test 3'ten fazla mock ile çalışıyorsa tasarım hakkında yeniden düşünmek için kırmızı bayraktır diye hissediyorum.

  • Spyları dikkatli kullanın. Spylar, yazdığınız algoritmanın içini görmenizi sağlar ve bu çok faydalı olabilir, ancak bu, test kodunuz ile implementasyon arasında daha sıkı bir bağlantı anlamına gelir. Onları gözetleyecekseniz, bu ayrıntıları gerçekten önemsediğinizden emin olun

Mocklama için framework kullanamaz mıyım ?

Mocklama sihir gerektirmez ve nispeten basittir; Framework kullanmak, mocklamayı olduğunda daha karmaşık yapabilir. Bu bölümde otomatik mocklama kullanmayacağız, böylece:

  • mock nasıl çalışır daha iyi anlarız

  • interface implemente etme pratiği yaparız

İşbirlikçi (Collobrative) projelerde, otomatik oluşturulan mockların değeri vardır. Bir takımda, mocklama aracı test doubleları arasında tutarlılığı kodlar. Bu, tutarsız yazılmış testlere dönüşebilecek tutarsız yazılmış test kopyalarını önleyecektir..

Yalnızca bir interface karşı test doubleları oluşturan bir mock oluşturucu kullanmalısınız. Testlerin nasıl yazıldığını aşırı derecede belirleyen veya çok fazla 'sihir' kullanan herhangi bir araç denize girebilir.

Özetlersek

TDD yaklaşımı hakkında daha fazla bilgi

  • Daha az önemsiz örneklerle karşılaştığınızda, sorunu "ince dikey dilimlere" bölün. Tavşan deliklerine girmekten ve "büyük patlama" yaklaşımı benimsemekten kaçınmak için, mümkün olan en kısa sürede testlerle desteklenen çalışan bir yazılıma sahip olduğunuz bir noktaya gelmeye çalışın.

  • Çalışan bir yazılımınız olduğunda, ihtiyacınız olan yazılıma ulaşana kadar küçük adımlarla yinelemeniz daha kolay olacaktır.

"Yinelemeli geliştirmeyi ne zaman kullanmalıyız? Yinelemeli geliştirmeyi sadece başarılı olmasını istediğiniz projelerde kullanmalısınız."

Martin Fowler.

Mocklama

  • Kodunuzun önemli bir kısmı mocklama olmadan test edilemeyecektir. Bizim durumumuzda, kodumuzun her yazdırma arasında durakladığını test edemeyiz, ancak sayısız başka örnek var. Başarısız olabilecek bir servicei çağırmak mı istiyorsunuz? Sisteminizi belirli bir durumda test etmek mi istiyorsunuz? Mocklama olmadan bu senaryoları test etmek oldukça zor.

  • Mocklar olmadan, sadece basit iş kurallarını test etmek için veritabanları ve diğer üçüncü taraf şeyleri kurmanız gerekebilir. Muhtemelen yavaş testler yapacaksınız ve bu da yavaş geri bildirim döngülerine yol açacaktır.

  • Bir şeyi test etmek için bir veritabanını veya bir web servisini döndürmek zorunda kalırsanız, bu tür servislerin güvenilmezliği nedeniyle hassas testler yapmanız olasıdır.

Bir geliştirici mocklamayı öğrendiğinde, bir sistemin her bir yönünü, ne yaptığından ziyade, nasıl çalıştığı açısından aşırı test etmek çok kolay hale gelir. Testlerinizin değeri ve gelecekteki yeniden düzenlemede ne gibi etkileri olacağı konusunda daima dikkatli olun.

Mocklama ile ilgili bu yazıda yalnızca bir tür mock olan Spyları ele aldık. Mocklar için "uygun" terim, "test double" olsa da

> Test Double, test amacıyla bir üretim nesnesini değiştirdiğiniz her durum için genel bir terimdir.

Test doubleları altında, stublar, spylar ve gerçekten de mocklar gibi çeşitli türler var! Daha fazla ayrıntı için Martin Fowler'ın gönderisine göz atın.

Last updated