Mocking #
Mocking adalah seni mengganti dependensi nyata dengan tiruan yang bisa dikendalikan selama pengujian. Tanpa mocking, test yang mengirim email sungguhan, memanggil API berbayar, atau menunggu database besar akan menjadi lambat, mahal, dan tidak bisa dijalankan offline. Dengan mocking, kamu bisa menguji bagaimana kode bereaksi terhadap berbagai respons dari dependensi — termasuk kasus-kasus yang sulit diprovokasi di dunia nyata seperti timeout jaringan, respons 500 dari server, atau database yang penuh — tanpa benar-benar membutuhkan kondisi tersebut. Artikel ini membahas semua teknik mocking di ekosistem Ruby: dari double dan allow di RSpec hingga WebMock untuk HTTP dan VCR untuk merekam-ulang request nyata.
Terminologi — Mock, Stub, Spy, Fake #
Istilah-istilah ini sering dipakai secara bergantian tapi punya makna yang berbeda:
Stub:
Mengganti method dengan implementasi yang mengembalikan nilai tertentu.
Tujuan: kendalikan input untuk kode yang sedang ditest.
Tidak memverifikasi apakah method dipanggil.
Contoh: allow(user).to receive(:email).and_return("[email protected]")
Mock:
Seperti stub, TAPI juga memverifikasi bahwa method dipanggil
dengan argumen tertentu, berapa kali, dalam urutan tertentu.
Tujuan: verifikasi interaksi antar objek.
Contoh: expect(mailer).to receive(:kirim).once.with("[email protected]")
Spy:
Objek yang merekam semua interaksi tanpa memblokir apapun.
Verifikasi dilakukan setelah kode dieksekusi (bukan sebelum).
Tujuan: observe tanpa mengubah perilaku.
Contoh: spy = spy("Logger"); ...; expect(spy).to have_received(:log)
Fake:
Implementasi alternatif yang berfungsi tapi disederhanakan.
Tujuan: pengganti ringan untuk dependensi berat.
Contoh: in-memory repository sebagai pengganti database nyata
flowchart LR
A[Test] --> B[Kode yang Ditest]
B --> C{Dependensi}
C --> D[Stub\nKontrol nilai kembalian]
C --> E[Mock\nVerifikasi interaksi]
C --> F[Spy\nObservasi pasif]
C --> G[Fake\nImplementasi alternatif]
D --> H[Layanan Email nyata\ndigantikan]
E --> H
F --> H
G --> HRSpec Mocks — double, allow, expect #
double — Objek Tiruan #
double membuat objek palsu yang tidak punya method apapun kecuali yang kamu definisikan:
RSpec.describe PengirimanEmail do
let(:mailer) { double("Mailer") }
it "mengirim email selamat datang" do
# Definisikan method yang boleh dipanggil pada double
allow(mailer).to receive(:kirim).and_return(true)
layanan = PengirimanEmail.new(mailer)
layanan.sambut("[email protected]")
# Verifikasi method dipanggil
expect(mailer).to have_received(:kirim)
.with("[email protected]", subjek: "Selamat Datang!")
end
end
# double dengan nilai kembalian langsung (shorthand)
mailer = double("Mailer", kirim: true, status: :aktif)
# double yang strict — melempar error jika method tidak didefini
mailer = double("Mailer")
mailer.kirim # => RSpec::Mocks::MockExpectationError: Method tidak diizinkan
instance_double — Verifikasi Kontrak #
instance_double adalah versi lebih ketat dari double — ia memverifikasi bahwa method yang di-stub benar-benar ada di kelas nyata dan signature-nya cocok:
class LayananEmail
def kirim(tujuan, subjek:, isi:)
# implementasi nyata
end
end
RSpec.describe PengirimanNotifikasi do
# instance_double memastikan :kirim benar-benar ada di LayananEmail
# dan menerima argumen yang sama
let(:mailer) { instance_double(LayananEmail) }
it "memanggil layanan email dengan benar" do
allow(mailer).to receive(:kirim).and_return(true)
notifikasi = PengirimanNotifikasi.new(mailer)
notifikasi.kirim_konfirmasi("[email protected]")
expect(mailer).to have_received(:kirim).with(
"[email protected]",
subjek: "Konfirmasi Pesanan",
isi: anything
)
end
end
# Jika LayananEmail tidak punya method :kirim, atau signature berbeda:
# RSpec langsung error — mencegah test lolos tapi kode produksi rusak!
# Padanannya untuk kelas (class method):
class_double(KelasNyata)
# Padanannya untuk object yang sudah dibuat:
object_double(objek_nyata)
allow vs expect — Stub vs Mock #
# allow — stub: definisikan nilai kembalian, tidak verifikasi apakah dipanggil
allow(objek).to receive(:method).and_return(nilai)
# Test lulus meskipun method tidak pernah dipanggil
# expect — mock: definisikan nilai kembalian DAN verifikasi dipanggil
expect(objek).to receive(:method).and_return(nilai)
# Test gagal jika method tidak dipanggil
# Urutan dalam test:
# ANTI-PATTERN: expect setelah kode dieksekusi (spy pattern lebih tepat)
objek.lakukan_sesuatu
expect(objek).to receive(:method) # sudah terlambat!
# BENAR: expect sebelum kode yang memicunya
expect(objek).to receive(:method).and_return(nilai)
objek.lakukan_sesuatu # ini yang memicu pemanggilan method
# atau gunakan spy + have_received untuk verifikasi setelah fakta
allow(objek).to receive(:method)
objek.lakukan_sesuatu
expect(objek).to have_received(:method) # verifikasi setelah eksekusi
Argument Matchers #
Argument matchers memungkinkan kamu menentukan seberapa ketat pencocokan argumen:
# Pencocokan nilai persis
expect(mailer).to receive(:kirim).with("[email protected]", "Halo")
# anything — argumen apapun di posisi itu
expect(db).to receive(:simpan).with(anything)
# any_args — semua argumen apapun
expect(logger).to receive(:log).with(any_args)
# no_args — dipanggil tanpa argumen
expect(cache).to receive(:hapus).with(no_args)
# Pencocokan tipe
expect(parser).to receive(:parse).with(instance_of(String))
expect(handler).to receive(:tangani).with(kind_of(StandardError))
# Hash matchers
expect(api).to receive(:post).with(hash_including(email: "[email protected]"))
expect(api).to receive(:post).with(hash_excluding(:password))
# Array matchers
expect(notif).to receive(:kirim_ke).with(array_including("user1", "user2"))
# Regex
expect(logger).to receive(:info).with(/berhasil/)
# Kombinasi dengan kondisi kustom
expect(validator).to receive(:cek).with(
satisfy { |val| val.length > 3 && val.include?("@") }
)
# Pencocokan nested
expect(api).to receive(:request).with(
hash_including(
headers: hash_including("Authorization" => /^Bearer /),
body: hash_including(user_id: instance_of(Integer))
)
)
Message Expectations — Frekuensi dan Urutan #
# Berapa kali method dipanggil
expect(mailer).to receive(:kirim).once # tepat 1x
expect(mailer).to receive(:kirim).twice # tepat 2x
expect(mailer).to receive(:kirim).exactly(3).times
expect(mailer).to receive(:kirim).at_least(:once)
expect(mailer).to receive(:kirim).at_least(2).times
expect(mailer).to receive(:kirim).at_most(3).times
expect(mailer).not_to receive(:kirim) # tidak boleh dipanggil sama sekali
# Urutan pemanggilan
expect(db).to receive(:mulai_transaksi).ordered
expect(db).to receive(:simpan).ordered
expect(db).to receive(:commit).ordered
# Jika dipanggil tidak dalam urutan ini, test gagal
# Nilai kembalian yang berbeda per pemanggilan
allow(random).to receive(:angka)
.and_return(1, 2, 3, 4)
# Pertama dipanggil → 1, kedua → 2, ketiga → 3, keempat → 4
# Setelah itu selalu → 4 (nilai terakhir)
# Lempar exception
allow(api).to receive(:fetch).and_raise(Net::TimeoutError, "Koneksi timeout")
allow(parser).to receive(:parse).and_raise(JSON::ParserError)
# Jalankan blok kustom
allow(cache).to receive(:get) do |key|
"cached_#{key}_value" # nilai berdasarkan argumen
end
# Panggil implementasi asli setelah intercept
allow(objek).to receive(:method).and_call_original
# Tidak lakukan apapun (void method)
allow(logger).to receive(:log) # implisit mengembalikan nil
Partial Mock — Stub Method di Objek Nyata #
Partial mock memungkinkan kamu men-stub satu atau beberapa method dari objek nyata, sementara method lain tetap menggunakan implementasi asli:
class Order
def total
items.sum(&:harga) - diskon
end
def diskon
member_premium? ? total_sebelum_diskon * 0.2 : 0
end
def proses!
kirim_notifikasi
potong_stok
buat_invoice
tandai_selesai
end
end
RSpec.describe Order do
let(:order) { create(:order) }
describe "#proses!" do
it "mengirim notifikasi setelah diproses" do
# Stub method yang punya efek samping eksternal
allow(order).to receive(:kirim_notifikasi)
allow(order).to receive(:potong_stok)
allow(order).to receive(:buat_invoice)
order.proses!
# Verifikasi notifikasi dikirim
expect(order).to have_received(:kirim_notifikasi).once
end
end
describe "#total" do
it "menerapkan diskon untuk member premium" do
# Stub hanya metode yang menentukan diskon,
# biarkan kalkulasi total tetap asli
allow(order).to receive(:member_premium?).and_return(true)
allow(order).to receive(:total_sebelum_diskon).and_return(100_000)
expect(order.diskon).to eq(20_000)
end
end
end
verify_partial_doubles = truedispec_helper.rbsangat disarankan — ia memastikan method yang di-stub benar-benar ada di objek nyata. Tanpanya, kamu bisa men-stub method yang tidak ada dan test tetap lolos, padahal kode produksi akan crash karenaNoMethodError.
Spy — Observasi Tanpa Memblokir #
Spy adalah pendekatan di mana kamu membiarkan kode berjalan normal, lalu memverifikasi interaksinya setelah fakta:
RSpec.describe NotifikasiService do
# spy membuat double yang menerima method apapun (tidak ada NoMethodError)
let(:logger) { spy("Logger") }
it "mencatat setiap notifikasi yang dikirim" do
service = NotifikasiService.new(logger: logger)
service.kirim_ke("[email protected]", "Pesan A")
service.kirim_ke("[email protected]", "Pesan B")
# Verifikasi setelah semua eksekusi selesai
expect(logger).to have_received(:info).twice
expect(logger).to have_received(:info).with(/Pesan A/)
expect(logger).to have_received(:info).with(/Pesan B/)
end
end
# spy vs double biasa:
# double — harus allow/expect semua method sebelum dipanggil
# spy — menerima method apapun, cocok untuk objek yang kamu observe tapi tidak kontrol
# instance_spy — spy dengan verifikasi interface
let(:mailer) { instance_spy(LayananEmail) }
Fake — Implementasi Alternatif #
Fake adalah implementasi yang lebih jujur dari mock — ia punya logika nyata tapi disederhanakan untuk keperluan test:
# ANTI-PATTERN: test yang bergantung database nyata — lambat!
RSpec.describe ProdukService do
it "menghitung total harga" do
Produk.create!(nama: "A", harga: 10_000)
Produk.create!(nama: "B", harga: 20_000)
expect(ProdukService.total_harga).to eq(30_000)
end
end
# BENAR: fake repository — cepat, tanpa database
class FakeProdukRepository
def initialize(produk = [])
@produk = produk
end
def semua
@produk
end
def tambah(produk)
@produk << produk
produk
end
def cari(id)
@produk.find { |p| p[:id] == id }
end
def hapus(id)
@produk.reject! { |p| p[:id] == id }
end
end
RSpec.describe ProdukService do
let(:repo) do
FakeProdukRepository.new([
{ id: 1, nama: "Laptop", harga: 15_000_000 },
{ id: 2, nama: "Mouse", harga: 350_000 },
{ id: 3, nama: "Keyboard", harga: 450_000 }
])
end
subject(:service) { ProdukService.new(repo) }
it "menghitung total harga semua produk" do
expect(service.total_harga).to eq(15_800_000)
end
it "mencari produk berdasarkan ID" do
expect(service.cari(2)[:nama]).to eq("Mouse")
end
end
WebMock — Mock HTTP Request #
WebMock memblokir semua HTTP request nyata selama test dan menggantinya dengan respons yang sudah ditentukan:
gem install webmock
# spec/spec_helper.rb
require 'webmock/rspec'
WebMock.disable_net_connect!(allow_localhost: true)
# allow_localhost: true agar Capybara masih bisa buka browser lokal
require 'webmock/rspec'
require 'net/http'
RSpec.describe GithubClient do
describe "#profil_user" do
context "ketika API mengembalikan data user" do
before do
stub_request(:get, "https://api.github.com/users/namikazebadri")
.with(
headers: {
"Accept" => "application/vnd.github.v3+json",
"Authorization" => /^Bearer /
}
)
.to_return(
status: 200,
body: JSON.generate({
login: "namikazebadri",
name: "Namikazebadri",
public_repos: 42
}),
headers: { "Content-Type" => "application/json" }
)
end
it "mengembalikan data profil yang diparse" do
client = GithubClient.new(token: "test-token")
profil = client.profil_user("namikazebadri")
expect(profil[:login]).to eq("namikazebadri")
expect(profil[:public_repos]).to eq(42)
end
end
context "ketika API mengembalikan 404" do
before do
stub_request(:get, /api.github.com\/users\//)
.to_return(status: 404, body: '{"message":"Not Found"}')
end
it "melempar UserNotFoundError" do
client = GithubClient.new(token: "test-token")
expect { client.profil_user("tidak_ada") }
.to raise_error(GithubClient::UserNotFoundError)
end
end
context "ketika jaringan timeout" do
before do
stub_request(:get, /api.github.com/)
.to_timeout
end
it "melempar TimeoutError" do
client = GithubClient.new(token: "test-token")
expect { client.profil_user("siapapun") }
.to raise_error(Net::OpenTimeout)
end
end
end
end
# Verifikasi request benar-benar dibuat
RSpec.describe TagihanService do
it "mengirim notifikasi ke payment gateway" do
stub = stub_request(:post, "https://api.midtrans.com/charge")
.to_return(status: 200, body: '{"transaction_id":"txn-123"}')
TagihanService.new.proses(order_id: 1, jumlah: 150_000)
expect(stub).to have_been_requested.once
# atau lebih spesifik:
expect(a_request(:post, "https://api.midtrans.com/charge")
.with(body: hash_including(order_id: "1")))
.to have_been_made.once
end
end
VCR — Rekam dan Putar Ulang HTTP Request #
VCR merekam interaksi HTTP nyata pertama kali dijalankan, lalu memutarnya kembali pada run berikutnya. Cocok untuk menguji klien API tanpa harus selalu terhubung ke internet:
gem install vcr
# spec/spec_helper.rb
require 'vcr'
VCR.configure do |config|
config.cassette_library_dir = "spec/cassettes" # folder penyimpanan rekaman
config.hook_into :webmock # gunakan WebMock sebagai backend
config.configure_rspec_metadata! # aktifkan :vcr tag di RSpec
# Sembunyikan data sensitif dari rekaman
config.filter_sensitive_data("<API_KEY>") { ENV["API_KEY"] }
config.filter_sensitive_data("<AUTH_TOKEN>") { ENV["AUTH_TOKEN"] }
config.default_cassette_options = {
record: :new_episodes # rekam hanya request baru, putar ulang yang sudah ada
}
end
# Gunakan dengan tag :vcr
RSpec.describe CuacaClient, :vcr do
it "mengambil data cuaca Jakarta" do
client = CuacaClient.new
data = client.cuaca("Jakarta")
# Request pertama kali: benar-benar memanggil API, merekam ke cassette
# Request berikutnya: memuat dari cassette, tidak ada request nyata
expect(data[:suhu]).to be_a(Numeric)
expect(data[:kota]).to eq("Jakarta")
end
end
# Atau dengan blok eksplisit
RSpec.describe CuacaClient do
it "mengambil data cuaca Bandung" do
VCR.use_cassette("cuaca/bandung") do
client = CuacaClient.new
data = client.cuaca("Bandung")
expect(data[:kota]).to eq("Bandung")
end
end
end
Timecop — Kontrol Waktu dalam Test #
Timecop memungkinkan test yang bergantung pada waktu berjalan secara deterministik:
gem install timecop
require 'timecop'
RSpec.describe PromoHandler do
describe "#promo_aktif?" do
it "mengembalikan true selama periode promo" do
Timecop.freeze(Time.new(2024, 8, 17, 12, 0, 0)) do
promo = PromoHandler.new(
mulai: Time.new(2024, 8, 1),
akhir: Time.new(2024, 8, 31)
)
expect(promo.aktif?).to be true
end
end
it "mengembalikan false setelah promo berakhir" do
Timecop.freeze(Time.new(2024, 9, 1)) do
promo = PromoHandler.new(
mulai: Time.new(2024, 8, 1),
akhir: Time.new(2024, 8, 31)
)
expect(promo.aktif?).to be false
end
end
end
describe "#durasi_berlangsung" do
it "menghitung berapa jam promo sudah berlangsung" do
Timecop.travel(Time.new(2024, 8, 17, 10, 0, 0)) do
promo = PromoHandler.new(mulai: Time.new(2024, 8, 17, 8, 0, 0))
expect(promo.durasi_berlangsung).to eq(2 * 3600) # 2 jam dalam detik
end
end
end
end
# Timecop.freeze — waktu berhenti sama sekali (Time.now tidak bergerak)
# Timecop.travel — waktu bergerak normal mulai dari titik tertentu
# Timecop.scale — percepat waktu N kali lipat
# Selalu pastikan Timecop di-reset setelah test:
after { Timecop.return }
# Atau gunakan blok yang auto-return:
Timecop.freeze(Time.now) { ... }
Anti-Pattern Mocking yang Harus Dihindari #
# ANTI-PATTERN 1: Over-mocking — mock semua dependensi
# Test ini lolos tapi tidak menguji apapun yang nyata
RSpec.describe UserService do
it "membuat user" do
allow(User).to receive(:new).and_return(double(valid?: true, save: true))
allow(Mailer).to receive(:kirim_sambutan)
allow(EventBus).to receive(:publish)
allow(Cache).to receive(:invalidate)
# Test ini hampir seluruhnya adalah mock — apa yang sebenarnya ditest?
hasil = UserService.buat(email: "[email protected]")
expect(hasil).to be true
end
end
# BENAR: test perilaku nyata, mock hanya efek samping eksternal
RSpec.describe UserService do
it "membuat user dan menyimpannya ke database" do
# Hanya mock I/O eksternal
allow(Mailer).to receive(:kirim_sambutan)
expect {
UserService.buat(email: "[email protected]", nama: "Rina")
}.to change(User, :count).by(1)
expect(User.last.email).to eq("[email protected]")
end
end
# ANTI-PATTERN 2: Mock yang menguji implementasi, bukan perilaku
it "menggunakan algoritma AES-256 untuk enkripsi" do
expect(OpenSSL::Cipher).to receive(:new).with("AES-256-CBC")
EncryptionService.enkripsi("data sensitif")
end
# Jika algoritma diganti ke AES-128 yang sama amannya, test pecah
# padahal perilaku (data terenkripsi) tidak berubah
# BENAR: test hasil (ciphertext bisa di-decrypt)
it "mengenkripsi dan mendekripsi data dengan benar" do
data_asli = "data sensitif"
encrypted = EncryptionService.enkripsi(data_asli)
decrypted = EncryptionService.dekripsi(encrypted)
expect(decrypted).to eq(data_asli)
end
# ANTI-PATTERN 3: Mock tanpa verify_partial_doubles
# Method yang di-mock tidak ada di kelas nyata
allow(user).to receive(:email_terverifikasi?) # method ini tidak ada!
# Test lolos, tapi di produksi: NoMethodError!
# BENAR: aktifkan verify_partial_doubles di spec_helper
config.mock_with :rspec do |mocks|
mocks.verify_partial_doubles = true # error jika method tidak ada
end
# ANTI-PATTERN 4: allow_any_instance_of — hindari sebisa mungkin
allow_any_instance_of(Mailer).to receive(:kirim)
# Sulit dilacak instance mana yang di-stub
# Membuat kode sulit di-refactor
# BENAR: inject dependensi sehingga bisa di-mock dengan tepat
class NotifikasiService
def initialize(mailer: Mailer.new) # dependency injection
@mailer = mailer
end
end
let(:mailer) { instance_double(Mailer) }
let(:service) { NotifikasiService.new(mailer: mailer) }
Kapan Mock, Kapan Tidak #
Gunakan mock/stub untuk:
✓ Layanan eksternal — API pihak ketiga, gateway pembayaran
✓ Email dan notifikasi — tidak mau benar-benar kirim email di test
✓ Clock/waktu — buat test deterministik dengan Timecop
✓ Random — kendalikan hasil random untuk test yang bisa direproduksi
✓ Logging — tidak perlu verifikasi output log di setiap test
✓ Job/queue — verifikasi job di-enqueue, bukan dieksekusi
Hindari mock untuk:
✗ Model dan kalkulasi bisnis inti — uji langsung, jangan di-stub
✗ Objek value sederhana — buat instance nyata
✗ Hanya karena setup-nya repot — perbaiki desain kode, bukan pakai mock
✗ Menghindari test yang lambat karena query kompleks — pertimbangkan
database test fixtures atau factory_bot + database_cleaner
Ringkasan #
- Stub (
allow) untuk kendalikan input, Mock (expect) untuk verifikasi interaksi — jangan gunakanexpectjika kamu tidak peduli apakah method dipanggil; gunakanallowsaja.instance_doublelebih aman daridouble— ia memverifikasi bahwa method benar-benar ada di kelas nyata dengan signature yang cocok, mencegah test lolos tapi kode produksi crash.spydanhave_receiveduntuk observasi setelah fakta — lebih natural dariexpect(...).to receiveyang harus ditulis sebelum kode dieksekusi.verify_partial_doubles = truewajib diaktifkan — tanpanya, partial mock pada method yang tidak ada tidak akan terdeteksi.- WebMock untuk HTTP test — blokir semua request nyata di test environment; definisikan respons secara eksplisit termasuk error dan timeout.
- VCR untuk klien API yang kompleks — rekam interaksi nyata sekali, putar ulang selamanya; sembunyikan API key dari cassette dengan
filter_sensitive_data.- Timecop untuk test yang bergantung waktu — gunakan
Timecop.freezeuntuk waktu yang berhenti danTimecop.traveluntuk waktu yang bergerak dari titik tertentu.- Hindari
allow_any_instance_of— sulit dilacak dan membuat refactoring berbahaya; gunakan dependency injection agar bisa mock instance tertentu.- Jangan over-mock — jika test dipenuhi stub dan tidak ada assertion nyata, kamu tidak menguji apapun; test yang baik hanya mock efek samping eksternal.
- Fake lebih baik dari mock untuk dependensi yang kompleks — in-memory repository lebih reliable dan lebih mudah dipahami daripada serangkaian
allowyang panjang.