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
Pendahuluan singkat
Perbedaan konsep: behavior vs state
Kapan menggunakan masing-masing gaya
Contoh kasus:
UserService
yang membuat user dan mengirim notifikasiStruktur proyek contoh
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
Kelebihan & kekurangan tiap gaya
Best practices & tips untuk Go
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 methodCreateUser(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(...)
danAssertExpectations
).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
Gunakan interface agar mudah mengganti implementasi nyata dengan mock/fake.
Keep tests deterministic: hindari ketergantungan waktu / random tanpa kontrol (pakai injection clock jika perlu).
Jangan mock everything: mock apa yang penting untuk behavior verification.
Prefer small, focused tests: tiap test memverifikasi satu aspek (single responsibility).
Jika memakai mock lib (testify/gomock), jangan lupa maintain expectations secara jelas; gunakan
defer mock.AssertExpectations(t)
saat cocok.Buat in-memory/fake implementations untuk integration-like tests (Detroit style) supaya cek state lebih mudah.
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