Clean Code dengan Go — Part 7: Testing sebagai Dokumentasi
Test yang baik adalah dokumentasi yang tidak pernah berbohong. Pelajari table-driven tests dan cara menulis test yang bermakna di Go.
Bayangkan kamu bergabung ke sebuah tim dan diminta melakukan refactor sederhana — mengubah cara validasi email di UserService. “Gampang,” pikirmu. Dua jam kemudian, kamu push ke production. Lima menit kemudian, Slack meledak. Fitur registrasi rusak. Fitur login rusak. Notifikasi email tidak terkirim. Bahkan halaman profil ikut error.
Yang lebih menyesakkan? Tidak ada yang tahu persis kapan fitur-fitur itu mulai rusak, karena tidak ada satu pun test yang menjaganya. Tim menghabiskan tiga jam berikutnya melakukan debugging manual, menelusuri log satu per satu. Semua ini hanya karena “refactor kecil” tanpa pelindung apapun.
Inilah yang terjadi ketika test coverage-mu 0%. Test bukan sekadar kewajiban teknis — test adalah dokumentasi yang tidak pernah berbohong. Kode bisa outdated, komentar bisa salah, tapi test yang hijau tidak bisa bohong soal perilaku sistem.
Piramida Testing
Sebelum masuk ke kode, penting untuk memahami di mana kita harus menghabiskan energi saat menulis test.
graph BT
A["🧱 Unit Tests<br/>Banyak · Cepat · Murah<br/>~70% coverage"] -->|"membangun kepercayaan dasar"| B
B["🔗 Integration Tests<br/>Sedang · Menengah · Sedang<br/>~20% coverage"] -->|"memvalidasi kontrak antar modul"| C
C["🌐 E2E Tests<br/>Sedikit · Lambat · Mahal<br/>~10% coverage"]
style A fill:#4ade80,color:#000,stroke:#16a34a
style B fill:#facc15,color:#000,stroke:#ca8a04
style C fill:#f87171,color:#000,stroke:#dc2626
Unit test adalah pondasi — banyak, cepat, dan murah untuk dijalankan. Integration test memvalidasi bahwa modul-modul bekerja sama dengan benar. E2E test mensimulasikan alur pengguna nyata, tapi jumlahnya harus sedikit karena lambat dan mahal. Jangan balik piramida ini.
Konsep Inti
Konvensi Penamaan: TestNamaFungsi_Skenario_HasilYangDiharapkan
Nama test adalah dokumentasi pertama yang dibaca developer. Bandingkan dua nama ini:
Test1()→ tidak menjelaskan apa-apaTestCreateUser_DuplicateEmail_ReturnsError()→ langsung paham: fungsi apa, kondisi apa, hasil apa
Table-Driven Tests — Idiom Go yang Sesungguhnya
Go mendorong pola table-driven test: definisikan semua skenario dalam sebuah slice struct, lalu iterasi dengan t.Run(). Ini membuat test lebih ringkas, mudah ditambah skenario baru, dan mudah dibaca.
Mocking dengan Interface
Jangan bergantung pada database nyata di unit test. Gunakan interface agar implementasinya bisa diganti dengan mock — test jadi cepat, deterministik, dan bisa dijalankan di mana saja tanpa setup infrastruktur.
❌ Implementasi yang Salah
// ❌ BAD: Nama tidak informatif, tidak table-driven,
// bergantung pada DB nyata, dan menguji implementasi bukan perilaku.
var db *sql.DB // koneksi DB nyata — test tidak bisa jalan tanpa PostgreSQL!
func TestUser(t *testing.T) {
// Apa yang diuji? Input apa? Hasil yang diharapkan apa?
u := User{Email: "test@example.com", Name: "Test"}
err := CreateUser(db, u)
if err != nil {
t.Error("failed")
}
}
func Test1(t *testing.T) {
// Nama "Test1" tidak menjelaskan apapun
u := User{Email: "test@example.com", Name: "Test"}
err := CreateUser(db, u)
// Tidak mengecek apakah error yang dikembalikan adalah tipe yang benar
if err == nil {
t.Error("should fail")
}
}
func TestCreateUserImpl(t *testing.T) {
// ❌ Menguji detail implementasi internal, bukan perilaku publik
repo := &UserRepository{}
repo.mu.Lock() // mengakses mutex internal — sangat rapuh!
defer repo.mu.Unlock()
}
Kenapa ini salah?
TestUserdanTest1tidak mendeskripsikan skenario atau hasil yang diharapkan- Bergantung pada koneksi database nyata — test tidak bisa dijalankan di CI tanpa setup PostgreSQL
- Menguji detail implementasi internal (mutex) yang bisa berubah kapan saja
- Tidak ada pemisahan skenario; semua dicampur dalam satu fungsi
✅ Implementasi yang Benar
Pertama, definisikan interface untuk repository agar bisa di-mock:
// ✅ GOOD: Interface yang bersih untuk dependency injection dan mocking
package user
import "errors"
// UserRepository mendefinisikan kontrak akses data user.
type UserRepository interface {
FindByEmail(email string) (*User, error)
Save(user *User) (*User, error)
}
// User adalah domain model kita.
type User struct {
ID int
Name string
Email string
}
// UserService berisi business logic pembuatan user.
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
var (
ErrDuplicateEmail = errors.New("email already registered")
ErrInvalidInput = errors.New("invalid input")
)
func (s *UserService) CreateUser(name, email string) (*User, error) {
if name == "" || email == "" {
return nil, ErrInvalidInput
}
existing, _ := s.repo.FindByEmail(email)
if existing != nil {
return nil, ErrDuplicateEmail
}
return s.repo.Save(&User{Name: name, Email: email})
}
Sekarang, mock dan table-driven test yang sesungguhnya:
// ✅ GOOD: Table-driven test dengan nama deskriptif, mock bersih,
// dan setiap skenario terdokumentasi dengan sendirinya.
package user_test
import (
"errors"
"testing"
)
// mockUserRepo adalah implementasi mock dari UserRepository.
type mockUserRepo struct {
findByEmailFn func(email string) (*User, error)
saveFn func(user *User) (*User, error)
}
func (m *mockUserRepo) FindByEmail(email string) (*User, error) {
return m.findByEmailFn(email)
}
func (m *mockUserRepo) Save(user *User) (*User, error) {
return m.saveFn(user)
}
func TestCreateUser_Scenarios(t *testing.T) {
// Setiap test case adalah dokumentasi satu skenario bisnis.
testCases := []struct {
name string
inputName string
inputEmail string
mockRepo *mockUserRepo
expectedUser *User
expectedError error
}{
{
name: "ValidInput_ReturnsCreatedUser",
inputName: "Budi Santoso",
inputEmail: "budi@example.com",
mockRepo: &mockUserRepo{
findByEmailFn: func(email string) (*User, error) {
return nil, nil // email belum terdaftar
},
saveFn: func(u *User) (*User, error) {
u.ID = 1
return u, nil
},
},
expectedUser: &User{ID: 1, Name: "Budi Santoso", Email: "budi@example.com"},
expectedError: nil,
},
{
name: "DuplicateEmail_ReturnsErrDuplicateEmail",
inputName: "Budi Lain",
inputEmail: "budi@example.com",
mockRepo: &mockUserRepo{
findByEmailFn: func(email string) (*User, error) {
return &User{ID: 99, Email: email}, nil // email sudah ada!
},
saveFn: func(u *User) (*User, error) {
return nil, nil // tidak akan dipanggil
},
},
expectedUser: nil,
expectedError: ErrDuplicateEmail,
},
{
name: "EmptyName_ReturnsErrInvalidInput",
inputName: "",
inputEmail: "budi@example.com",
mockRepo: &mockUserRepo{},
expectedUser: nil,
expectedError: ErrInvalidInput,
},
{
name: "EmptyEmail_ReturnsErrInvalidInput",
inputName: "Budi Santoso",
inputEmail: "",
mockRepo: &mockUserRepo{},
expectedUser: nil,
expectedError: ErrInvalidInput,
},
{
name: "RepositoryError_PropagatesError",
inputName: "Budi Santoso",
inputEmail: "budi@example.com",
mockRepo: &mockUserRepo{
findByEmailFn: func(email string) (*User, error) {
return nil, nil
},
saveFn: func(u *User) (*User, error) {
return nil, errors.New("database connection lost")
},
},
expectedUser: nil,
expectedError: errors.New("database connection lost"),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Arrange
svc := NewUserService(tc.mockRepo)
// Act
gotUser, gotErr := svc.CreateUser(tc.inputName, tc.inputEmail)
// Assert
if tc.expectedError != nil {
if gotErr == nil {
t.Fatalf("expected error %q, got nil", tc.expectedError)
}
if gotErr.Error() != tc.expectedError.Error() {
t.Errorf("expected error %q, got %q", tc.expectedError, gotErr)
}
return
}
if gotErr != nil {
t.Fatalf("expected no error, got %q", gotErr)
}
if gotUser.ID != tc.expectedUser.ID || gotUser.Email != tc.expectedUser.Email {
t.Errorf("expected user %+v, got %+v", tc.expectedUser, gotUser)
}
})
}
}
Kenapa ini benar?
- Nama setiap subtest mendeskripsikan skenario dan hasil secara eksplisit
- Mock menggunakan interface — tidak butuh database nyata, jalan di mana saja
- Pola Arrange-Act-Assert membuat setiap test mudah dibaca
- Menambah skenario baru cukup tambah satu entry di slice, tidak perlu fungsi baru
Studi Kasus Nyata: Output go test sebagai Dokumentasi
Ketika kamu menjalankan test di atas dengan -v, outputnya menjadi dokumentasi yang hidup:
--- PASS: TestCreateUser_Scenarios (0.00s)
--- PASS: TestCreateUser_Scenarios/ValidInput_ReturnsCreatedUser (0.00s)
--- PASS: TestCreateUser_Scenarios/DuplicateEmail_ReturnsErrDuplicateEmail (0.00s)
--- PASS: TestCreateUser_Scenarios/EmptyName_ReturnsErrInvalidInput (0.00s)
--- PASS: TestCreateUser_Scenarios/EmptyEmail_ReturnsErrInvalidInput (0.00s)
--- PASS: TestCreateUser_Scenarios/RepositoryError_PropagatesError (0.00s)
Siapapun yang membaca output ini — bahkan non-developer — langsung paham apa yang dilakukan CreateUser dalam setiap kondisi. Ini adalah dokumentasi yang selalu up-to-date karena ia dieksekusi setiap kali CI berjalan.
Ringkasan: 5 Aturan Test sebagai Dokumentasi
📋 Lima Aturan Emas Testing
- Nama test = spesifikasi:
TestFungsi_Kondisi_HasilYangDiharapkan- Table-driven test untuk semua skenario di satu tempat
- Mock via interface, jangan pernah bergantung pada infrastruktur nyata di unit test
- Uji perilaku, bukan implementasi — jika refactor internal tidak merusak test, test-mu sudah benar
- Pola Arrange-Act-Assert agar setiap test mudah dibaca dan di-debug
🎯 Challenge
Ambil satu fungsi service paling kritis di proyekmu sekarang. Tulis table-driven test dengan minimal 5 skenario: input valid, input kosong, duplikasi, error dari dependency, dan edge case unik bisnismu. Jalankan dengan go test -v -run TestNamaFungsimu dan baca outputnya seperti membaca dokumentasi.
| 🇮🇩 Versi Indonesia | 🇬🇧 English version |
| ← Part 6: Struktur Kode | Part 8: Refactoring → |