Mocking

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 --> H

RSpec 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 = true di spec_helper.rb sangat 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 karena NoMethodError.

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 gunakan expect jika kamu tidak peduli apakah method dipanggil; gunakan allow saja.
  • instance_double lebih aman dari double — ia memverifikasi bahwa method benar-benar ada di kelas nyata dengan signature yang cocok, mencegah test lolos tapi kode produksi crash.
  • spy dan have_received untuk observasi setelah fakta — lebih natural dari expect(...).to receive yang harus ditulis sebelum kode dieksekusi.
  • verify_partial_doubles = true wajib 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.freeze untuk waktu yang berhenti dan Timecop.travel untuk 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 allow yang panjang.

← Sebelumnya: Unit Test   Berikutnya: JSON →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact