Test Doubles
Saat membangun perangkat lunak, unit testing adalah pilar utama untuk memastikan kualitas dan keandalan kode. Namun, sering kali kita menghadapi tantangan: bagaimana cara menguji sebuah unit (misalnya, sebuah fungsi atau method) secara terisolasi jika ia memiliki dependensi ke komponen lain seperti database, layanan eksternal (API), atau sistem file?
Di sinilah Test Doubles berperan. Mereka adalah "aktor pengganti" dalam dunia pengujian yang memungkinkan kita mengisolasi System Under Test (SUT) dari dependensinya, sehingga pengujian menjadi lebih cepat, dapat diandalkan, dan fokus pada logika yang sedang diuji.
Artikel ini akan membahas secara mendalam apa itu Test Doubles, mengapa kita membutuhkannya, dan akan mengupas tuntas lima jenis utamanya—Dummy, Fake, Stub, Spy, dan Mock—lengkap dengan contoh implementasi praktis menggunakan Go.
Apa itu Test Doubles?
Istilah "Test Double" dipopulerkan oleh Gerard Meszaros dalam bukunya xUnit Test Patterns. Analogi sederhananya adalah seperti stunt double (pemeran pengganti) dalam produksi film. Ketika sebuah adegan terlalu berbahaya atau memerlukan keahlian khusus, mereka menggunakan stunt double untuk menggantikan aktor utama.
Dalam pengujian perangkat lunak, Test Double adalah objek apa pun yang berpura-pura menjadi objek dependensi asli untuk tujuan pengujian. Tujuannya adalah untuk menggantikan dependensi yang nyata dengan versi yang bisa kita kontrol sepenuhnya.
Mengapa kita membutuhkan Test Doubles?
Isolasi: Memastikan bahwa kegagalan tes benar-benar disebabkan oleh kesalahan pada unit yang diuji (SUT), bukan karena masalah pada dependensinya (misalnya, koneksi database terputus).
Kecepatan: Menghindari panggilan yang lambat ke jaringan, database, atau sistem file. Tes unit harus berjalan dalam hitungan milidetik.
Determinisme: Menghilangkan ketidakpastian. API eksternal bisa saja down atau mengembalikan data yang berbeda-beda. Dengan Test Double, kita bisa memastikan dependensi selalu mengembalikan respons yang kita inginkan.
Simulasi Skenario Sulit: Memudahkan simulasi kondisi yang sulit diciptakan di dunia nyata, seperti kesalahan jaringan, respons error dari API, atau kondisi race condition.
Tipe-Tipe Test Doubles
Ada lima jenis utama Test Doubles, masing-masing dengan tujuan dan kasus penggunaan yang spesifik. Mari kita bahas satu per satu.
Untuk semua contoh, kita akan menggunakan skenario sederhana: sebuah UserService
yang bertugas mendaftarkan pengguna baru. Layanan ini memiliki dua dependensi:
UserRepository
: Untuk menyimpan data pengguna ke database.Notifier
: Untuk mengirim notifikasi email selamat datang.
Berikut adalah interface dan struct yang akan kita gunakan:
Go
package testdoubles
// User adalah model data kita
type User struct {
ID int
Email string
Name string
}
// UserRepository adalah interface untuk dependensi database
type UserRepository interface {
Save(user User) error
FindByID(id int) (*User, error)
}
// Notifier adalah interface untuk dependensi layanan notifikasi
type Notifier interface {
SendWelcomeEmail(email string) error
}
// UserService adalah System Under Test (SUT) kita
type UserService struct {
repo UserRepository
notifier Notifier
}
// NewUserService adalah constructor untuk UserService
func NewUserService(repo UserRepository, notifier Notifier) *UserService {
return &UserService{repo: repo, notifier: notifier}
}
// RegisterUser adalah method yang akan kita uji
func (s *UserService) RegisterUser(name, email string) (*User, error) {
// Logika bisnis: simpan pengguna
user := User{ID: 1, Name: name, Email: email}
if err := s.repo.Save(user); err != nil {
// Jika penyimpanan gagal, jangan kirim email
return nil, err
}
// Setelah berhasil, kirim email notifikasi
if err := s.notifier.SendWelcomeEmail(email); err != nil {
// Di sini kita bisa memutuskan, apakah error notifikasi menggagalkan registrasi?
// Untuk saat ini, kita abaikan error notifikasi agar fokus pada pengujian.
// log.Printf("failed to send welcome email: %v", err)
}
return &user, nil
}
Sekarang, mari kita lihat bagaimana setiap jenis Test Double digunakan untuk menguji UserService
.
1. Dummy Objects
Dummy adalah jenis Test Double yang paling sederhana. Objek ini dilewatkan sebagai argumen ke dalam fungsi atau method, tetapi tidak pernah benar-benar digunakan. Tujuannya hanya untuk mengisi daftar parameter agar kode bisa berjalan tanpa error.
Tujuan: Memenuhi "kontrak" parameter fungsi.
Analogi: Manekin di kursi penumpang mobil saat uji tabrak. Ia ada di sana untuk mengisi ruang, tetapi tidak ada yang peduli pada perilakunya.
Contoh di Go
Misalkan kita ingin menguji bahwa RegisterUser
berhasil membuat pengguna, dan dalam skenario ini kita tidak peduli apakah notifikasi terkirim atau tidak. Kita hanya butuh objek Notifier
agar NewUserService
tidak error.
package testdoubles
import (
"testing"
"github.com/stretchr/testify/assert"
)
// DummyNotifier hanya ada untuk memenuhi parameter, method-nya tidak melakukan apa-apa.
type DummyNotifier struct{}
func (d *DummyNotifier) SendWelcomeEmail(email string) error {
// Tidak melakukan apa-apa. Kosong.
return nil
}
// StubUserRepository akan kita gunakan untuk simulasi database
// (Kita akan membahas Stub lebih detail nanti)
type StubUserRepositoryForDummy struct {}
func (r *StubUserRepositoryForDummy) Save(user User) error {
return nil // Selalu berhasil
}
func (r *StubUserRepositoryForDummy) FindByID(id int) (*User, error) {
return nil, nil // Tidak relevan untuk tes ini
}
func TestUserService_RegisterUser_WithDummy(t *testing.T) {
// Arrange
dummyNotifier := &DummyNotifier{}
stubRepo := &StubUserRepositoryForDummy{} // Menggunakan stub sederhana untuk repo
userService := NewUserService(stubRepo, dummyNotifier)
// Act
user, err := userService.RegisterUser("John Doe", "john.doe@example.com")
// Assert
assert.NoError(t, err)
assert.NotNil(t, user)
assert.Equal(t, "John Doe", user.Name)
// Kita tidak melakukan assert apapun terkait dummyNotifier, karena kita tidak peduli.
}
2. Fake Objects
Fake adalah objek yang memiliki implementasi fungsional, tetapi jauh lebih sederhana daripada versi produksi. Fake biasanya menggantikan dependensi kompleks seperti database dengan versi in-memory.
Tujuan: Menyediakan implementasi ringan yang berfungsi untuk pengujian.
Analogi: Simulator penerbangan. Ia meniru perilaku pesawat sungguhan tetapi dalam lingkungan yang terkendali dan aman.
Contoh di Go
Kita akan membuat FakeUserRepository
yang menggunakan map
di memori untuk menyimpan pengguna, meniru perilaku database.
// fake_test.go
package testdoubles
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
// FakeUserRepository menggunakan map untuk meniru database.
type FakeUserRepository struct {
users map[int]User
nextID int
}
func NewFakeUserRepository() *FakeUserRepository {
return &FakeUserRepository{
users: make(map[int]User),
nextID: 1,
}
}
func (f *FakeUserRepository) Save(user User) error {
if user.Email == "exists@example.com" {
return errors.New("email already exists")
}
user.ID = f.nextID
f.users[user.ID] = user
f.nextID++
return nil
}
func (f *FakeUserRepository) FindByID(id int) (*User, error) {
user, ok := f.users[id]
if !ok {
return nil, errors.New("user not found")
}
return &user, nil
}
func TestUserService_RegisterUser_WithFakeRepository(t *testing.T) {
// Arrange
fakeRepo := NewFakeUserRepository()
dummyNotifier := &DummyNotifier{} // Notifier tidak relevan di sini
userService := NewUserService(fakeRepo, dummyNotifier)
// Act
registeredUser, err := userService.RegisterUser("Jane Doe", "jane.doe@example.com")
// Assert
assert.NoError(t, err)
assert.NotNil(t, registeredUser)
// Verifikasi state: periksa apakah pengguna benar-benar tersimpan di fake repo
foundUser, err := fakeRepo.FindByID(registeredUser.ID)
assert.NoError(t, err)
assert.Equal(t, "Jane Doe", foundUser.Name)
assert.Equal(t, "jane.doe@example.com", foundUser.Email)
}
3. Stubs
Stub menyediakan jawaban yang sudah ditentukan (kalengan) untuk panggilan selama pengujian. Stub digunakan ketika kita ingin menguji bagaimana SUT bereaksi terhadap data atau kondisi tertentu dari dependensinya. Ini berfokus pada verifikasi state.
Tujuan: Memberikan input terkontrol ke SUT.
Analogi: Seorang aktor yang hanya diberi skrip untuk satu kalimat dan akan selalu mengucapkan kalimat itu setiap kali ditanya.
Contoh di Go
Kita ingin menguji skenario di mana pendaftaran pengguna gagal karena database error. Kita akan membuat StubUserRepository
yang selalu mengembalikan error saat Save
dipanggil.
Go
package testdoubles
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
// StubUserRepository dirancang untuk mengembalikan hasil yang telah ditentukan.
type StubUserRepository struct {
SaveFunc func(user User) error
}
func (s *StubUserRepository) Save(user User) error {
// Jika fungsi spesifik disediakan, panggil itu. Jika tidak, default.
if s.SaveFunc != nil {
return s.SaveFunc(user)
}
return nil
}
func (s *StubUserRepository) FindByID(id int) (*User, error) {
// Tidak relevan untuk tes ini
return nil, nil
}
func TestUserService_RegisterUser_FailsWhenRepoSaveFails(t *testing.T) {
// Arrange
// Konfigurasi stub untuk selalu mengembalikan error
stubRepo := &StubUserRepository{
SaveFunc: func(user User) error {
return errors.New("database connection failed")
},
}
dummyNotifier := &DummyNotifier{}
userService := NewUserService(stubRepo, dummyNotifier)
// Act
user, err := userService.RegisterUser("Test User", "test@example.com")
// Assert
assert.Error(t, err)
assert.Nil(t, user)
assert.Equal(t, "database connection failed", err.Error())
}
4. Spies
Spy adalah Stub yang juga merekam informasi tentang bagaimana ia dipanggil. Ini memungkinkan kita untuk memverifikasi interaksi antara SUT dan dependensinya. Spy berfokus pada verifikasi perilaku.
Tujuan: Memata-matai SUT untuk memastikan ia memanggil dependensi dengan benar.
Analogi: Seorang mata-mata yang tidak hanya memberikan informasi (seperti Stub), tetapi juga mencatat siapa yang bertanya, kapan, dan dengan pertanyaan apa.
Contoh di Go
Kita ingin memastikan bahwa setelah pengguna berhasil disimpan, method SendWelcomeEmail
pada Notifier
benar-benar dipanggil dengan email yang benar.
package testdoubles
import (
"testing"
"github.com/stretchr/testify/assert"
)
// SpyNotifier merekam interaksi yang terjadi.
type SpyNotifier struct {
WelcomeEmailSent bool
EmailRecipient string
TimesCalled int
}
func (s *SpyNotifier) SendWelcomeEmail(email string) error {
s.WelcomeEmailSent = true
s.EmailRecipient = email
s.TimesCalled++
return nil
}
func TestUserService_RegisterUser_SendsWelcomeEmail(t *testing.T) {
// Arrange
stubRepo := &StubUserRepository{} // Repo selalu berhasil
spyNotifier := &SpyNotifier{}
userService := NewUserService(stubRepo, spyNotifier)
testEmail := "spy.test@example.com"
// Act
_, err := userService.RegisterUser("Spy User", testEmail)
// Assert
assert.NoError(t, err)
// Verifikasi perilaku: periksa apakah notifier dipanggil dengan benar
assert.True(t, spyNotifier.WelcomeEmailSent, "SendWelcomeEmail should have been called")
assert.Equal(t, 1, spyNotifier.TimesCalled, "SendWelcomeEmail should be called exactly once")
assert.Equal(t, testEmail, spyNotifier.EmailRecipient, "Email should be sent to the correct recipient")
}
5. Mocks
Mock adalah objek yang paling "cerdas". Mocks diprogram dengan ekspektasi-yaitu, spesifikasi panggilan metode yang diharapkan akan diterima. Jika SUT tidak berinteraksi dengan mock persis seperti yang diharapkan, tes akan gagal. Mock juga berfokus pada verifikasi perilaku, tetapi dengan cara yang lebih ketat daripada Spy.
Tujuan: Mendefinisikan ekspektasi interaksi yang ketat dan secara otomatis memverifikasinya.
Analogi: Seorang instruktur militer yang memberikan perintah spesifik kepada seorang kadet. Jika kadet tidak melakukan persis seperti yang diperintahkan, ia akan langsung gagal.
Membuat mock secara manual bisa sangat merepotkan. Di Go, sangat umum menggunakan library seperti testify/mock
atau gomock
untuk ini.
Contoh di Go (menggunakan testify/mock
)
Pertama, install library-nya:
go get github.com/stretchr/testify/mock
Kemudian, kita buat mock-nya.
package testdoubles
import "github.com/stretchr/testify/mock"
type MockNotifier struct {
mock.Mock
}
func (m *MockNotifier) SendWelcomeEmail(email string) error {
args := m.Called(email)
return args.Error(0)
}
Sekarang, kita gunakan mock ini dalam tes untuk memverifikasi interaksi secara ketat.
package testdoubles
import (
"testing"
"github.com/stretchr/testify/mock"
)
// MockUserRepository menggunakan testify/mock
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) Save(user User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *MockUserRepository) FindByID(id int) (*User, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func TestUserService_RegisterUser_WithMocks(t *testing.T) {
// Arrange
mockRepo := new(MockUserRepository)
mockNotifier := new(MockNotifier)
userService := NewUserService(mockRepo, mockNotifier)
userName := "Mock User"
userEmail := "mock.user@example.com"
// User object yang kita harapkan akan di-pass ke Save
expectedUser := User{ID: 1, Name: userName, Email: userEmail}
// 1. Set ekspektasi pada mockRepo:
// - Method "Save" akan dipanggil TEPAT SATU KALI.
// - Argumen yang diberikan harus cocok dengan `expectedUser`.
// - Method ini akan mengembalikan `nil` (tidak ada error).
mockRepo.On("Save", expectedUser).Return(nil).Once()
// 2. Set ekspektasi pada mockNotifier:
// - Method "SendWelcomeEmail" akan dipanggil TEPAT SATU KALI.
// - Argumen yang diberikan harus `userEmail`.
// - Method ini akan mengembalikan `nil`.
mockNotifier.On("SendWelcomeEmail", userEmail).Return(nil).Once()
// Act
userService.RegisterUser(userName, userEmail)
// Assert
// Verifikasi bahwa semua ekspektasi yang kita atur di atas telah terpenuhi.
// Jika "Save" atau "SendWelcomeEmail" tidak dipanggil, atau dipanggil dengan argumen yang salah,
// tes ini akan gagal.
mockRepo.AssertExpectations(t)
mockNotifier.AssertExpectations(t)
}
Kesimpulan: Kapan Menggunakan yang Mana?
Dummy
Mengisi parameter
-
Ketika sebuah argumen diperlukan tapi tidak akan digunakan dalam jalur eksekusi tes.
Fake
Simulasi fungsionalitas
State
Ketika butuh dependensi yang berfungsi tapi versi ringan (misal: database in-memory).
Stub
Menyediakan data "kalengan"
State
Ketika ingin menguji bagaimana SUT bereaksi terhadap input atau kondisi tertentu.
Spy
Merekam interaksi
Behavior
Ketika ingin memeriksa apakah sebuah metode dipanggil dan dengan apa tanpa memaksakan urutan yang ketat.
Mock
Mendefinisikan ekspektasi
Behavior
Ketika ingin memverifikasi protokol interaksi yang spesifik dan ketat antara SUT dan dependensinya.
Menggunakan Test Doubles adalah keterampilan fundamental dalam unit testing modern. Dengan memahami perbedaan di antara kelima jenis ini, Anda dapat menulis tes yang lebih bersih, lebih cepat, dan lebih terfokus, yang pada akhirnya akan menghasilkan kode yang lebih kuat dan mudah dipelihara. Selamat menguji!
Last updated