Test pada chapter sebelumnya menguji fungsi yang berdiri sendiri; kode dunia nyata hampir selalu bergantung pada layanan eksternal: email, basis data, atau API pihak ketiga. Menjalankan dependency sungguhan di dalam test membuat test lambat, rapuh, dan berisiko mengirim email asli ke pelanggan sungguhan. Solusinya adalah soal desain, bukan soal alat: kita rancang dependency sebagai interface yang kecil, suntikkan lewat constructor, lalu ganti dengan pengganti tiruan (test double) saat test berjalan. Pada chapter terakhir section ini kita akan membangun layanan pendaftaran yang bergantung pada Notifier, dan menguji semuanya tanpa menyentuh jaringan.

Masalah Ketergantungan Langsung #

Bayangkan PendaftaranService memegang implementasi email secara langsung:

go
type PendaftaranService struct {
	notifier *EmailNotifier // kopling langsung ke implementasi konkret
}

Struktur seperti ini menyulitkan pengujian dari dua sisi: test tidak bisa memakai service tanpa membawa EmailNotifier beserta jaringannya, dan isi pesan yang “terkirim” tidak bisa diamati. Service juga kaku — mengganti email dengan SMS berarti mengubah service, bukan menukar komponen.

Interface Kecil sebagai Kontrak #

Kita balik rancangannya: service hanya mengenal kontrak Notifier berisi satu method. Simpan kode berikut sebagai notifikasi.go:

go
package main

import (
	"fmt"
	"strings"
	"sync"
)

// Pesan merekam satu notifikasi yang terkirim.
type Pesan struct {
	Ke     string
	Subjek string
	Isi    string
}

// Notifier adalah abstraksi cara mengirim pesan ke pengguna.
// Implementasi nyata bisa memakai SMTP, webhook, atau antrean pesan.
type Notifier interface {
	Kirim(ke, subjek, isi string) error
}

// EmailNotifier adalah implementasi untuk produksi. Pada contoh ini
// pengiriman disimulasikan ke log memori agar program dapat dijalankan
// tanpa jaringan; versi sungguhan memanggil server SMTP.
type EmailNotifier struct {
	mu       sync.Mutex
	terkirim []Pesan
}

// Kirim mencatat pesan ke log memori seolah terkirim lewat email.
func (e *EmailNotifier) Kirim(ke, subjek, isi string) error {
	if ke == "" {
		return fmt.Errorf("alamat tujuan kosong")
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	e.terkirim = append(e.terkirim, Pesan{Ke: ke, Subjek: subjek, Isi: isi})
	return nil
}

// Terkirim mengembalikan salinan daftar pesan yang sudah tercatat.
func (e *EmailNotifier) Terkirim() []Pesan {
	e.mu.Lock()
	defer e.mu.Unlock()
	return append([]Pesan(nil), e.terkirim...)
}

// PendaftaranService melayani pendaftaran pengguna baru lalu mengirim
// ucapan selamat datang lewat Notifier.
type PendaftaranService struct {
	notifier Notifier
}

// NewPendaftaranService menyuntikkan dependency lewat constructor,
// sehingga pemanggil yang menentukan implementasi Notifier mana yang dipakai.
func NewPendaftaranService(n Notifier) *PendaftaranService {
	return &PendaftaranService{notifier: n}
}

// Daftar mendaftarkan email yang valid, lalu memberi tahu pengguna.
func (s *PendaftaranService) Daftar(email string) error {
	if !strings.Contains(email, "@") || len(email) < 5 {
		return fmt.Errorf("email tidak valid: %q", email)
	}
	return s.notifier.Kirim(email, "Selamat datang", "Terima kasih sudah mendaftar di toko kami.")
}

func main() {
	email := &EmailNotifier{}
	layanan := NewPendaftaranService(email)
	_ = layanan.Daftar("[email protected]")
	_ = layanan.Daftar("[email protected]")
	for _, p := range email.Terkirim() {
		fmt.Printf("ke=%s subjek=%q\n", p.Ke, p.Subjek)
	}
}

Jalankan berkas tersebut dari folder chapter:

bash
go run notifikasi.go

Output yang Dihasilkan:

text
[email protected] subjek="Selamat datang"
[email protected] subjek="Selamat datang"

Mari kita telaah bagian-bagian utamanya:

  1. Baris 18-20 (type Notifier interface): kontrak satu method — sekecil mungkin. Siapa pun yang punya method Kirim dengan tanda tangan sama otomatis memenuhi interface ini, tanpa deklarasi eksplisit.
  2. Baris 25-46 (EmailNotifier): implementasi produksi. Versi chapter ini mencatat pesan ke slice berproteksi mutex agar contoh dapat berjalan offline; di proyek nyata isi Kirim diganti pemanggilan SMTP.
  3. Baris 50-58 (PendaftaranService dan constructor-nya): service menyimpan field bertipe Notifier, dan NewPendaftaranService menerima implementasi dari luar — pola dependency injection lewat constructor.
  4. Baris 61-66 (func Daftar): alur service — validasi email lalu s.notifier.Kirim. Service tidak tahu dan tidak perlu tahu media pengiriman yang dipakai.
  5. Baris 68-76 (func main): menyambungkan EmailNotifier ke service lalu mencetak pesan yang tercatat, sebagai bukti alur utuh berjalan.

Test dengan Pengganti Tiruan #

Berkas notifikasi_test.go mendefinisikan FakeNotifier — pengganti yang merekam setiap panggilan — lalu mengujinya lewat tiga skenario:

go
package main

import (
	"fmt"
	"testing"
)

// FakeNotifier merekam setiap panggilan Kirim sehingga test dapat
// memeriksa perilaku PendaftaranService tanpa jaringan sama sekali.
type FakeNotifier struct {
	panggilan []Pesan
	gagal     bool
}

// Kirim mencatat panggilan, atau mengembalikan galat bila mode gagal aktif.
func (f *FakeNotifier) Kirim(ke, subjek, isi string) error {
	if f.gagal {
		return fmt.Errorf("jaringan bermasalah")
	}
	f.panggilan = append(f.panggilan, Pesan{Ke: ke, Subjek: subjek, Isi: isi})
	return nil
}

func TestDaftarBerhasilMengirimNotifikasi(t *testing.T) {
	fake := &FakeNotifier{}
	layanan := NewPendaftaranService(fake)

	if err := layanan.Daftar("[email protected]"); err != nil {
		t.Fatalf("Daftar gagal: %v", err)
	}
	if len(fake.panggilan) != 1 {
		t.Fatalf("jumlah panggilan Kirim = %d, diharapkan 1", len(fake.panggilan))
	}
	p := fake.panggilan[0]
	if p.Ke != "[email protected]" || p.Subjek != "Selamat datang" {
		t.Errorf("isi pesan tidak sesuai: %+v", p)
	}
}

func TestDaftarEmailTidakValid(t *testing.T) {
	fake := &FakeNotifier{}
	layanan := NewPendaftaranService(fake)

	if err := layanan.Daftar("budi-tanpa-at"); err == nil {
		t.Fatal("Daftar seharusnya gagal untuk email tanpa karakter @")
	}
	if len(fake.panggilan) != 0 {
		t.Errorf("tidak boleh ada notifikasi terkirim, tercatat %d", len(fake.panggilan))
	}
}

func TestDaftarGagalKirimMeneruskanGalat(t *testing.T) {
	fake := &FakeNotifier{gagal: true}
	layanan := NewPendaftaranService(fake)

	if err := layanan.Daftar("[email protected]"); err == nil {
		t.Fatal("galat dari Notifier seharusnya diteruskan ke pemanggil")
	}
}

Jalankan dari folder chapter:

bash
go test -v ./...

Output yang Dihasilkan:

text
=== RUN   TestDaftarBerhasilMengirimNotifikasi
--- PASS: TestDaftarBerhasilMengirimNotifikasi (0.00s)
=== RUN   TestDaftarEmailTidakValid
--- PASS: TestDaftarEmailTidakValid (0.00s)
=== RUN   TestDaftarGagalKirimMeneruskanGalat
--- PASS: TestDaftarGagalKirimMeneruskanGalat (0.00s)
PASS
ok  	tutorial-website/content/go/testing/testing-package-with-dependencies	0.001s

Tiga skenario pada berkas tersebut sengaja membeda-bedakan jalur perilaku:

  1. Baris 24-38 (TestDaftarBerhasilMengirimNotifikasi): jalur bahagia. Test memeriksa jumlah panggilan Kirim tepat satu dan isi parameternya sesuai — pemeriksaan interaksi yang hanya mungkin bila dependency bisa diamati.
  2. Baris 40-50 (TestDaftarEmailTidakValid): email tanpa @ harus ditolak sebelum notifikasi terkirim; assertion kedua memastikan nol panggilan.
  3. Baris 52-59 (TestDaftarGagalKirimMeneruskanGalat): FakeNotifier diatur mode gagal: true sehingga Kirim mengembalikan galat — test memastikan service meneruskan galat itu ke pemanggil, bukan menelannya.

Stub, Fake, dan Mock #

Istilah pengganti uji sering dipakai campur aduk; tabel berikut memisahkannya menurut definisi yang lazim:

IstilahDefinisiContoh pada chapter ini
StubPengganti yang selalu menjawab nilai tetap; interaksi tidak diperiksastruct dengan method yang langsung return nil
FakeImplementasi ringan yang benar-benar berfungsi tanpa infrastruktur beratEmailNotifier berbasis log memori menggantikan SMTP
MockPengganti yang merekam interaksi agar test bisa memverifikasi panggilanFakeNotifier yang menyimpan setiap panggilan Kirim

FakeNotifier pada prinsipnya berperan sebagai mock, sementara EmailNotifier versi memori adalah fake dari SMTP sungguhan. Komunitas Go cenderung longgar menyebut semua pengganti uji sebagai fake — nama pada contoh mengikuti kebiasaan itu; yang penting test Anda memeriksa perilaku yang benar, bukan debat label.

Kenapa Interface Harus Kecil #

Interface satu method memberi tiga keuntungan sekaligus. Pengganti uji cukup mengimplementasikan satu method sehingga biaya pembuatannya nyaris nol; service berganti implementasi (SMTP, SMS, antrean) tanpa kompilasi ulang logika; dan kontrak tetap tajam karena tidak memuat method yang tidak dipakai. Prinsip ini sejalan dengan pembahasan Dependency Inversion : bergantung pada abstraksi, bukan implementasi konkret.

Pola yang sama berskala besar pada proyek nyata. Chapter REST API dengan PostgreSQL menyuntikkan repository basis data sebagai interface ke service, dan package httptest yang dibahas pada chapter httptest menyediakan server HTTP tiruan untuk kebutuhan yang sama. Setiap dependency eksternal yang terkungkung interface kecil membuka pintu bagi test yang cepat dan deterministik.

Referensi #

Dengan dependency yang terkungkung interface kecil, test Anda menjangkau logika bisnis tanpa biaya jaringan atau basis data — dan seluruh perangkat section Testing kini lengkap: dari unit test, benchmark, fuzzing, contoh terverifikasi, coverage, race detector, hingga desain yang bisa diuji. Ketika kode yang sudah teruji tetap gagal di lapangan, saatnya berpindah ke section Debugging dan Profiling untuk membedah penyebabnya.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

notifikasi_test.go

Unduh berkas
go
package main

import (
	"fmt"
	"testing"
)

// FakeNotifier merekam setiap panggilan Kirim sehingga test dapat
// memeriksa perilaku PendaftaranService tanpa jaringan sama sekali.
type FakeNotifier struct {
	panggilan []Pesan
	gagal     bool
}

// Kirim mencatat panggilan, atau mengembalikan galat bila mode gagal aktif.
func (f *FakeNotifier) Kirim(ke, subjek, isi string) error {
	if f.gagal {
		return fmt.Errorf("jaringan bermasalah")
	}
	f.panggilan = append(f.panggilan, Pesan{Ke: ke, Subjek: subjek, Isi: isi})
	return nil
}

func TestDaftarBerhasilMengirimNotifikasi(t *testing.T) {
	fake := &FakeNotifier{}
	layanan := NewPendaftaranService(fake)

	if err := layanan.Daftar("[email protected]"); err != nil {
		t.Fatalf("Daftar gagal: %v", err)
	}
	if len(fake.panggilan) != 1 {
		t.Fatalf("jumlah panggilan Kirim = %d, diharapkan 1", len(fake.panggilan))
	}
	p := fake.panggilan[0]
	if p.Ke != "[email protected]" || p.Subjek != "Selamat datang" {
		t.Errorf("isi pesan tidak sesuai: %+v", p)
	}
}

func TestDaftarEmailTidakValid(t *testing.T) {
	fake := &FakeNotifier{}
	layanan := NewPendaftaranService(fake)

	if err := layanan.Daftar("budi-tanpa-at"); err == nil {
		t.Fatal("Daftar seharusnya gagal untuk email tanpa karakter @")
	}
	if len(fake.panggilan) != 0 {
		t.Errorf("tidak boleh ada notifikasi terkirim, tercatat %d", len(fake.panggilan))
	}
}

func TestDaftarGagalKirimMeneruskanGalat(t *testing.T) {
	fake := &FakeNotifier{gagal: true}
	layanan := NewPendaftaranService(fake)

	if err := layanan.Daftar("[email protected]"); err == nil {
		t.Fatal("galat dari Notifier seharusnya diteruskan ke pemanggil")
	}
}

notifikasi.go

Unduh berkas
go
package main

import (
	"fmt"
	"strings"
	"sync"
)

// Pesan merekam satu notifikasi yang terkirim.
type Pesan struct {
	Ke     string
	Subjek string
	Isi    string
}

// Notifier adalah abstraksi cara mengirim pesan ke pengguna.
// Implementasi nyata bisa memakai SMTP, webhook, atau antrean pesan.
type Notifier interface {
	Kirim(ke, subjek, isi string) error
}

// EmailNotifier adalah implementasi untuk produksi. Pada contoh ini
// pengiriman disimulasikan ke log memori agar program dapat dijalankan
// tanpa jaringan; versi sungguhan memanggil server SMTP.
type EmailNotifier struct {
	mu       sync.Mutex
	terkirim []Pesan
}

// Kirim mencatat pesan ke log memori seolah terkirim lewat email.
func (e *EmailNotifier) Kirim(ke, subjek, isi string) error {
	if ke == "" {
		return fmt.Errorf("alamat tujuan kosong")
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	e.terkirim = append(e.terkirim, Pesan{Ke: ke, Subjek: subjek, Isi: isi})
	return nil
}

// Terkirim mengembalikan salinan daftar pesan yang sudah tercatat.
func (e *EmailNotifier) Terkirim() []Pesan {
	e.mu.Lock()
	defer e.mu.Unlock()
	return append([]Pesan(nil), e.terkirim...)
}

// PendaftaranService melayani pendaftaran pengguna baru lalu mengirim
// ucapan selamat datang lewat Notifier.
type PendaftaranService struct {
	notifier Notifier
}

// NewPendaftaranService menyuntikkan dependency lewat constructor,
// sehingga pemanggil yang menentukan implementasi Notifier mana yang dipakai.
func NewPendaftaranService(n Notifier) *PendaftaranService {
	return &PendaftaranService{notifier: n}
}

// Daftar mendaftarkan email yang valid, lalu memberi tahu pengguna.
func (s *PendaftaranService) Daftar(email string) error {
	if !strings.Contains(email, "@") || len(email) < 5 {
		return fmt.Errorf("email tidak valid: %q", email)
	}
	return s.notifier.Kirim(email, "Selamat datang", "Terima kasih sudah mendaftar di toko kami.")
}

func main() {
	email := &EmailNotifier{}
	layanan := NewPendaftaranService(email)
	_ = layanan.Daftar("[email protected]")
	_ = layanan.Daftar("[email protected]")
	for _, p := range email.Terkirim() {
		fmt.Printf("ke=%s subjek=%q\n", p.Ke, p.Subjek)
	}
}