London School vs Detroit School of TDD

Ringkasan singkat

Artikel ini menjelaskan dua gaya besar dalam Test-Driven Development (TDD): London School (mockist / behavior-based testing) dan Detroit School (classical / state-based testing). Disertakan penjelasan konsep, kelebihan & kekurangan, kapan harus memakai masing-masing pendekatan, serta contoh implementasi dan tes di Go.


Daftar Isi

  1. Pendahuluan singkat

  2. Perbedaan konsep: behavior vs state

  3. Kapan menggunakan masing-masing gaya

  4. Contoh kasus: UserService yang membuat user dan mengirim notifikasi

  5. Struktur proyek contoh

  6. Contoh implementasi β€” kode lengkap

    • 6.1. Contract & implementasi nyata

    • 6.2. London style (mockist) β€” menggunakan mock dan verification of interactions

    • 6.3. Detroit style (classical) β€” menggunakan fake/in-memory dan verification of state

  7. Kelebihan & kekurangan tiap gaya

  8. Best practices & tips untuk Go

  9. Kesimpulan


1. Pendahuluan singkat

TDD bukan hanya soal menulis tes sebelum kode. Ada perbedaan filosofi pada bagaimana tes ditulis:

  • London School (Mockist): Fokus pada behaviour β€” tes memverifikasi interaksi antara objek (apakah method tertentu dipanggil, dengan argumen tertentu). Sering memakai mock atau spy.

  • Detroit School (Classical / State-based): Fokus pada state β€” tes memverifikasi hasil akhir atau perubahan state (nilai return, database, file, dsb.). Minim penggunaan mock.

Keduanya valid; pilihan tergantung pada konteks, kompleksitas dependensi, dan kebutuhan maintainability.


2. Perbedaan konsep: behaviour vs state

  • Behaviour (London)

    • Tes memastikan "apa yang dilakukan" sistem terhadap dependensi.

    • Contoh: pastikan Mailer.Send(to, subject, body) dipanggil sekali dengan argumen X.

  • State (Detroit)

    • Tes memastikan "apa hasilnya" setelah kode dijalankan.

    • Contoh: pastikan entri user baru tersimpan di DB dan field activated = true.


3. Kapan menggunakan masing-masing gaya

  • London cocok ketika:

    • Anda ingin desain API/kontrak objek yang jelas.

    • Interaction correctness penting (mis. membentuk permintaan ke layanan eksternal yang sensitif).

    • Timbunan dependensi membuat integrasi nyata sulit di tests (latency, biaya, non-deterministic behavior).

  • Detroit cocok ketika:

    • Anda peduli pada hasil akhir dan side-effects yang ter-observable.

    • Sebisa mungkin hindari over-specifying implementasi internal.

    • In-memory/fake dependency mudah dibuat (mis. map-based repo).


4. Contoh kasus

Kita buat contoh domain sederhana:

  • UserService memiliki method CreateUser(email, name):

    • Menyimpan user ke repository (UserRepository).

    • Mengirim email notifikasi lewat Notifier interface.

Kita akan menulis 2 jenis test untuk CreateUser:

  • London test: verifikasi bahwa Notifier.Send dipanggil sekali dengan argumen tertentu.

  • Detroit test: verifikasi bahwa repository berisi user baru dan timestamp/flag sesuai.


5. Struktur proyek contoh

example/
β”œβ”€β”€ service.go        # implementasi UserService
β”œβ”€β”€ repo.go           # interface UserRepository + in-memory impl
β”œβ”€β”€ notifier.go       # interface Notifier + real impl (contoh)
β”œβ”€β”€ service_london_test.go   # contoh mockist tests
└── service_detroit_test.go  # contoh state-based tests

6. Contoh implementasi β€” kode lengkap

Semua contoh ditulis agar mudah dijalankan. Untuk dependency mock library, saya menggunakan github.com/stretchr/testify/mock pada contoh London-style. Jika tidak mau dependency eksternal, buat manual mock/spy (contoh disediakan).

6.1 Contract & implementasi nyata

// repo.go
package example

import "time"

// User entity
type User struct {
    ID        string
    Email     string
    Name      string
    CreatedAt time.Time
}

// UserRepository menyimpan user
type UserRepository interface {
    Save(u *User) error
    FindByEmail(email string) (*User, error)
}

// notifier.go
package example

// Notifier mengirim notifikasi (mis. email)
type Notifier interface {
    Send(to string, subject string, body string) error
}

// service.go
package example

import (
    "errors"
    "time"
)

var ErrUserExists = errors.New("user already exists")

// UserService menjalankan logika pembuatan user
type UserService struct {
    Repo     UserRepository
    Notifier Notifier
}

func (s *UserService) CreateUser(email, name string) (*User, error) {
    // cek duplicate
    if existing, _ := s.Repo.FindByEmail(email); existing != nil {
        return nil, ErrUserExists
    }

    u := &User{ID: generateID(), Email: email, Name: name, CreatedAt: time.Now().UTC()}
    if err := s.Repo.Save(u); err != nil {
        return nil, err
    }

    // kirim notifikasi β€” kita peduli bahwa fungsi ini dipanggil, bukan implementasinya
    _ = s.Notifier.Send(email, "Welcome!", "Thanks for signing up, "+name)

    return u, nil
}

// generateID sederhana (untuk contoh). Di production gunakan uuid lib
func generateID() string {
    return "id-" + time.Now().Format("20060102150405.000")
}

6.2 London style (mockist)

Tujuan: pastikan Notifier.Send dipanggil sekali dengan argumen yang tepat, dan Repo.Save dipanggil.

Contoh menggunakan testify/mock:

// service_london_test.go
package example_test

import (
    "testing"
    "example"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

// MockNotifier menggunakan testify/mock
type MockNotifier struct{ mock.Mock }

func (m *MockNotifier) Send(to, subject, body string) error {
    args := m.Called(to, subject, body)
    return args.Error(0)
}

// MockRepo manual dengan testify/mock
type MockRepo struct{ mock.Mock }

func (m *MockRepo) Save(u *example.User) error {
    args := m.Called(u)
    return args.Error(0)
}
func (m *MockRepo) FindByEmail(email string) (*example.User, error) {
    args := m.Called(email)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*example.User), args.Error(1)
}

func TestCreateUser_London(t *testing.T) {
    repo := &MockRepo{}
    notifier := &MockNotifier{}

    svc := &example.UserService{Repo: repo, Notifier: notifier}

    email := "alice@example.com"
    name := "Alice"

    // set expectations
    repo.On("FindByEmail", email).Return(nil, nil)
    repo.On("Save", mock.AnythingOfType("*example.User")).Return(nil)

    notifier.On("Send", email, "Welcome!", mock.MatchedBy(func(body string) bool {
        return len(body) > 0
    })).Return(nil).Once()

    u, err := svc.CreateUser(email, name)
    assert.NoError(t, err)
    assert.Equal(t, email, u.Email)

    // verify expectations
    repo.AssertExpectations(t)
    notifier.AssertExpectations(t)
}

Catatan:

  • Di London style kita sering memeriksa interaksi (notifier.On(...).Return(...) dan AssertExpectations).

  • Tes akan gagal jika Notifier.Send tidak dipanggil atau dipanggil dengan argumen berbeda.

Jika tidak ingin menambahkan dependency eksternal, bisa buat mock manual yang menyimpan panggilan dalam slice dan memeriksanya di akhir tes.

6.3 Detroit style (state-based)

Tujuan: verifikasi state akhir: user tersimpan di repository dan field terisi dengan benar. Kita gunakan in-memory repo (fake) dan real/simple notifier (yang hanya men-record atau noop).

// repo_inmemory.go (untuk test)
package example

import "errors"

type InMemoryRepo struct {
    data map[string]*User // key by email for simplicity
}

func NewInMemoryRepo() *InMemoryRepo {
    return &InMemoryRepo{data: make(map[string]*User)}
}

func (r *InMemoryRepo) Save(u *User) error {
    if _, ok := r.data[u.Email]; ok {
        return errors.New("already exists")
    }
    r.data[u.Email] = u
    return nil
}

func (r *InMemoryRepo) FindByEmail(email string) (*User, error) {
    if u, ok := r.data[email]; ok {
        return u, nil
    }
    return nil, nil
}

// noop_notifier.go
package example

// NoopNotifier hanya mencatat atau tidak melakukan apa-apa
type NoopNotifier struct{}

func (n *NoopNotifier) Send(to, subject, body string) error {
    // noop β€” pada Detroit kita tidak peduli pada interaksi notifikasi
    return nil
}

Tes:

// service_detroit_test.go
package example_test

import (
    "testing"
    "example"
    "github.com/stretchr/testify/assert"
)

func TestCreateUser_Detroit(t *testing.T) {
    repo := example.NewInMemoryRepo()
    notifier := &example.NoopNotifier{}
    svc := &example.UserService{Repo: repo, Notifier: notifier}

    email := "bob@example.com"
    name := "Bob"

    u, err := svc.CreateUser(email, name)
    assert.NoError(t, err)
    assert.Equal(t, email, u.Email)

    // verifikasi state: user ada di repo
    stored, _ := repo.FindByEmail(email)
    assert.NotNil(t, stored)
    assert.Equal(t, name, stored.Name)
    assert.WithinDuration(t, stored.CreatedAt, u.CreatedAt, 1e9) // dalam 1 detik
}

Catatan:

  • Di Detroit kita tidak memeriksa apakah Notifier.Send dipanggil. Kita hanya peduli efek yang dapat diobservasi (user tersimpan).

  • Keuntungan: tes tidak mengikat pada detail internal interaksi.


7. Kelebihan & Kekurangan

London (mockist)

    • Membuat kontrak interaksi jelas.

    • Berguna untuk menguji integrasi dengan dependensi eksternal yang sulit dijalankan di test.

    • Risiko over-specification: tes menjadi rapuh terhadap refactor (walau behaviour tetap sama).

    • Banyak mock bisa membuat tes sulit dipahami.

Detroit (state)

    • Tes lebih tahan terhadap refactor yang tidak mengubah hasil akhir.

    • Lebih mudah dibaca: memeriksa hasil akhir.

    • Sulit jika dependensi punya efek non-deterministik atau mahal (mis. network, time consuming tasks).

    • Membutuhkan fake implementations yang realistis agar tes bermakna.


8. Best practices & tips untuk Go

  1. Gunakan interface agar mudah mengganti implementasi nyata dengan mock/fake.

  2. Keep tests deterministic: hindari ketergantungan waktu / random tanpa kontrol (pakai injection clock jika perlu).

  3. Jangan mock everything: mock apa yang penting untuk behavior verification.

  4. Prefer small, focused tests: tiap test memverifikasi satu aspek (single responsibility).

  5. Jika memakai mock lib (testify/gomock), jangan lupa maintain expectations secara jelas; gunakan defer mock.AssertExpectations(t) saat cocok.

  6. Buat in-memory/fake implementations untuk integration-like tests (Detroit style) supaya cek state lebih mudah.

  7. Mix & match: tidak harus pilih satu gaya untuk seluruh codebase. Untuk bagian yang mengandung integrasi kompleks, mockist berguna; untuk logic murni, state-based seringkali lebih baik.


9. Kesimpulan

London dan Detroit adalah dua filosofi TDD yang masing-masing berguna tergantung konteks. Di Go, penggunaan interface memudahkan kedua pendekatan. Kunci utamanya: tulis tes yang membuat Anda percaya pada kode, tidak menahan Anda dari melakukan refactor yang sehat.


Last updated