πŸ§ͺ Ruby Testing

Ruby Testing dengan RSpec

Tutorial lengkap Ruby Testing dengan RSpec β€” describe, context, it, let, mocks, stubs, shared examples, matchers, dan quiz interaktif dengan contoh kode praktis

1. Pengenalan Testing dan RSpec

Testing adalah proses memverifikasi bahwa kode Anda berfungsi sebagaimana mestinya. Dalam pengembangan software, testing sangat penting untuk memastikan kualitas kode, mencegah bug, dan memudahkan refactoring. RSpec adalah framework testing paling populer di Ruby yang mengikuti pendekatan BDD (Behavior-Driven Development).

BDD menggabungkan prinsip TDD (Test-Driven Development) dengan sintaks yang lebih natural dan mudah dibaca. RSpec menulis test dalam bentuk "specification" β€” deskripsi perilaku yang diharapkan dari kode, bukan sekadar test teknis.

TDD vs BDD

Aspek TDD (Test-Driven) BDD (Behavior-Driven)
FokusUnit test individualPerilaku dan spesifikasi
Sintaksassert_equal 5, resultexpect(result).to eq(5)
BahasaTeknisMendekati bahasa manusia
FrameworkMinitestRSpec
SiklusRed β†’ Green β†’ RefactorGiven β†’ When β†’ Then
DokumentasiTidak otomatisBisa jadi dokumentasi hidup

Mengapa Menggunakan RSpec?

Keunggulan Penjelasan
Sintaks EleganMembaca test seperti membaca spesifikasi dalam bahasa Inggris
Matchers KayaRatusan matcher built-in dan kemampuan membuat custom matcher
Nested ContextBisa mengelompokkan test berdasarkan konteks dan kondisi
Shared ExamplesBagikan test patterns antar spec files
EcosystemFactoryBot, Capybara, Shoulda Matchers, SimpleCov
Output ReadableOutput yang berwarna dan terstruktur saat test dijalankan
Diagram: RSpec Ecosystem
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚               RSPEC ECOSYSTEM                         β”‚
β”‚                                                       β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚  RSpec   β”‚  β”‚ Factory  β”‚  β”‚   Matchers       β”‚    β”‚
β”‚  β”‚  Core    β”‚  β”‚   Bot    β”‚  β”‚   eq, include    β”‚    β”‚
β”‚  β”‚  describeβ”‚  β”‚  FactoryBotβ”‚  β”‚   be_, have_   β”‚    β”‚
β”‚  β”‚  context β”‚  β”‚  traits  β”‚  β”‚   raise_error    β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚                                                       β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚ Capybara β”‚  β”‚Coverage  β”‚  β”‚   Mocking        β”‚    β”‚
β”‚  β”‚  Feature β”‚  β”‚ SimpleCovβ”‚  β”‚   allow/expect   β”‚    β”‚
β”‚  β”‚  Scenariosβ”‚  β”‚  Reports β”‚  β”‚   double/instanceβ”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

2. Setup RSpec

Instalasi RSpec

Terminal
# Instal RSpec gem
gem install rspec

# Verifikasi instalasi
rspec --version
# rspec 3.13.0

# Untuk proyek Ruby biasa, buat file spec_helper.rb
rspec --init
# Menghasilkan:
# - .rspec (configurasi)
# - spec/spec_helper.rb

# Untuk Rails:
# Tambahkan ke Gemfile:
# group :development, :test do
#   gem 'rspec-rails'
#   gem 'factory_bot_rails'
#   gem 'faker'
# end

# Jalankan bundle install
bundle install

# Generate RSpec di Rails
rails generate rspec:install
# Menghasilkan:
# - .rspec
# - spec/spec_helper.rb
# - spec/rails_helper.rb

Konfigurasi .rspec

.rspec
# File: .rspec
--format documentation
--color
--require spec_helper

# Opsi yang tersedia:
# --format documentation  : Output detail dengan indentasi
# --format progress       : Output ringkas (titik-titik)
# --color                 : Output berwarna
# --require spec_helper   : Auto-require spec_helper
# --fail-fast             : Berhenti di test pertama yang gagal
# --bisect                : Cari kombinasi test yang menyebabkan kegagalan

Konfigurasi spec_helper.rb

spec/spec_helper.rb
# spec/spec_helper.rb
RSpec.configure do |config|
  # Output format
  config.formatter = :documentation

  # Filter
  config.filter_run_when_matching :focus

  # Random order (untuk mendeteksi test yang bergantung satu sama lain)
  config.order = :random
  Kernel.srand config.seed

  # Shared context
  config.shared_context_metadata_behavior = :apply_to_host_groups

  # Hooks
  config.before(:suite) do
    puts "=== Memulai test suite ==="
  end

  config.after(:suite) do
    puts "=== Test suite selesai ==="
  end

  # Disable monkey patching (recommended)
  config.disable_monkey_patching!

  # Expose DSL globally
  config.expose_dsl_globally = true
end

Menjalankan RSpec

Terminal β€” RSpec Commands
# Jalankan semua test
rspec

# Jalankan file tertentu
rspec spec/models/user_spec.rb

# Jalankan test tertentu berdasarkan baris
rspec spec/models/user_spec.rb:25

# Jalankan dengan format tertentu
rspec --format documentation

# Jalankan test yang mengandung tag
rspec --tag focus
rspec --tag ~slow  # Skip test bertanda :slow

# Jalankan dengan backtrace lengkap
rspec --backtrace

# Jalankan test secara acak
rspec --order random

# Seed untuk reproduce urutan random
rspec --seed 12345

3. RSpec Basics: describe, context, it

RSpec memiliki tiga blok utama yang membentuk struktur test: describe, context, dan it.

Struktur Dasar

spec/calculator_spec.rb
# Class yang akan di-test
# lib/calculator.rb
class Calculator
  def add(a, b)
    a + b
  end

  def subtract(a, b)
    a - b
  end

  def multiply(a, b)
    a * b
  end

  def divide(a, b)
    raise ZeroDivisionError, "Tidak bisa bagi dengan nol" if b == 0
    a.to_f / b
  end
end

# spec/calculator_spec.rb
require 'calculator'

# describe β€” mengelompokkan test untuk class/method tertentu
RSpec.describe Calculator do
  # describe untuk method tertentu
  describe '#add' do
    # it β€” satu test case individual
    it 'menjumlahkan dua angka positif' do
      calc = Calculator.new
      expect(calc.add(2, 3)).to eq(5)
    end

    it 'menjumlahkan angka negatif' do
      calc = Calculator.new
      expect(calc.add(-1, -3)).to eq(-4)
    end

    it 'menjumlahkan angka nol' do
      calc = Calculator.new
      expect(calc.add(0, 5)).to eq(5)
    end
  end

  describe '#subtract' do
    it 'mengurangi dua angka' do
      calc = Calculator.new
      expect(calc.subtract(10, 3)).to eq(7)
    end
  end

  describe '#multiply' do
    it 'mengalikan dua angka' do
      calc = Calculator.new
      expect(calc.multiply(4, 5)).to eq(20)
    end
  end

  describe '#divide' do
    # context β€” mengelompokkan test berdasarkan kondisi
    context 'dengan pembagi valid' do
      it 'membagi dua angka' do
        calc = Calculator.new
        expect(calc.divide(10, 2)).to eq(5.0)
      end

      it 'mengembalikan float' do
        calc = Calculator.new
        expect(calc.divide(7, 2)).to eq(3.5)
      end
    end

    context 'dengan pembagi nol' do
      it 'melempar ZeroDivisionError' do
        calc = Calculator.new
        expect { calc.divide(10, 0) }.to raise_error(ZeroDivisionError)
      end

      it 'melempar error dengan pesan yang benar' do
        calc = Calculator.new
        expect { calc.divide(10, 0) }.to raise_error("Tidak bisa bagi dengan nol")
      end
    end
  end
end

Output RSpec

Terminal β€” Output
# Format documentation:
# Calculator
#   #add
#     menambahkan dua angka positif
#     menambahkan angka negatif
#     menambahkan angka nol
#   #subtract
#     mengurangi dua angka
#   #multiply
#     mengalikan dua angka
#   #divide
#     dengan pembagi valid
#       membagi dua angka
#       mengembalikan float
#     dengan pembagi nol
#       melempar ZeroDivisionError
#       melempar error dengan pesan yang benar
#
# Finished in 0.00234 seconds (files took 0.15678 seconds to load)
# 9 examples, 0 failures

# Format progress (default):
# .........
#
# Finished in 0.00234 seconds
# 9 examples, 0 failures

Pending Test

Pending Tests
# Pending test β€” test yang belum selesai ditulis
RSpec.describe Calculator do
  # Menggunakan xit β€” test yang di-skip
  xit 'fitur masa depan' do
    # Test ini tidak dijalankan
  end

  # Menggunakan pending β€” test yang belum selesai
  it 'fitur yang belum selesai' do
    pending "Belum diimplementasikan"
    expect(Calculator.new.add(1, 1)).to eq(3)  # Akan gagal, tapi dianggap pending
  end

  # Menggunakan skip β€” test yang di-skip
  it 'test yang di-skip', skip: 'Untuk nanti' do
    expect(true).to be true
  end

  # Focus β€” menjalankan hanya test yang difokuskan
  it 'test fokus', :focus do
    expect(Calculator.new.add(1, 1)).to eq(2)
  end
end

4. Matchers

Matchers adalah inti dari RSpec. Matcher menentukan apa yang diharapkan dari hasil test. RSpec memiliki ratusan matcher built-in.

Equality Matchers

RSpec β€” Equality Matchers
RSpec.describe 'Equality Matchers' do
  # eq β€” equality (==)
  it 'eq: equality' do
    expect(2 + 2).to eq(4)
    expect("hello").to eq("hello")
  end

  # eql β€” strict equality (eql?)
  it 'eql: strict equality' do
    expect(2 + 2).to eql(4)
    expect(2.0).not_to eql(2)  # Float != Integer
  end

  # equal β€” identity (equal?, same object)
  it 'equal: identity check' do
    a = "hello"
    b = a
    expect(a).to equal(b)      # Same object
    expect(a).not_to equal("hello")  # Different object
  end

  # be β€” alias for equal
  it 'be: identity' do
    x = [1, 2, 3]
    y = x
    expect(x).to be(y)
    expect(x).not_to be([1, 2, 3])
  end

  # be_nil, be_truthy, be_falsey, be_falsy
  it 'be_nil' do
    expect(nil).to be_nil
    expect(0).not_to be_nil
  end

  it 'be_truthy dan be_falsey' do
    expect(true).to be_truthy
    expect(42).to be_truthy
    expect(false).to be_falsey
    expect(nil).to be_falsey
  end
end

Comparison Matchers

RSpec β€” Comparison Matchers
RSpec.describe 'Comparison Matchers' do
  # be_ comparison matchers
  it 'be_, be_, be_, be_' do
    expect(10).to be > 5
    expect(3).to be < 10
    expect(7).to be >= 7
    expect(5).to be <= 10
    expect(5).to be_between(1, 10)
  end

  # be_within
  it 'be_within' do
    expect(3.14159).to be_within(0.01).of(3.14)
    expect(100).to be_within(5).of(98)
  end

  # be_instance_of dan be_kind_of
  it 'be_instance_of dan be_kind_of' do
    expect("hello").to be_instance_of(String)
    expect(42).to be_instance_of(Integer)
    expect(42).to be_kind_of(Numeric)  # Integer is a kind of Numeric
  end

  # respond_to
  it 'respond_to' do
    expect("hello").to respond_to(:length)
    expect("hello").to respond_to(:upcase, :downcase)
  end

  # have_attributes
  it 'have_attributes' do
    user = OpenStruct.new(name: "Budi", age: 25, email: "budi@mail.com")
    expect(user).to have_attributes(name: "Budi", age: 25)
  end
end

Collection Matchers

RSpec β€” Collection Matchers
RSpec.describe 'Collection Matchers' do
  # include
  it 'include' do
    expect([1, 2, 3]).to include(2)
    expect([1, 2, 3]).to include(1, 3)
    expect("hello world").to include("world")
    expect({ a: 1, b: 2 }).to include(a: 1)
  end

  # all
  it 'all' do
    expect([2, 4, 6]).to all(be_even)
    expect([1, 3, 5]).to all(be_odd)
    expect(["hello", "world"]).to all(be_a(String))
  end

  # contain_exactly β€” same elements regardless of order
  it 'contain_exactly' do
    expect([3, 1, 2]).to contain_exactly(1, 2, 3)
    expect([3, 1, 2]).not_to contain_exactly(1, 2, 4)
  end

  # match_array β€” alias for contain_exactly
  it 'match_array' do
    expect([3, 1, 2]).to match_array([1, 2, 3])
  end

  # start_with dan end_with
  it 'start_with dan end_with' do
    expect([1, 2, 3]).to start_with(1)
    expect([1, 2, 3]).to end_with(3)
    expect("hello world").to start_with("hello")
    expect("hello world").to end_with("world")
  end
end

String dan Regex Matchers

RSpec β€” String Matchers
RSpec.describe 'String Matchers' do
  # match β€” regex matcher
  it 'match' do
    expect("hello123").to match(/\w+\d+/)
    expect("2024-01-15").to match(/\d{4}-\d{2}-\d{2}/)
  end

  # match? (Ruby 2.4+)
  it 'match?' do
    expect("test@mail.com").to match?(/\A[\w.]+@[\w.]+\z/)
  end
end

5. let, before, after, subject

RSpec menyediakan helper let, before, after, dan subject untuk setup test yang lebih efisien dan bersih.

let dan let!

RSpec β€” let
RSpec.describe User do
  # let β€” lazy evaluation (dijalankan saat pertama kali dipanggil)
  let(:user) { User.new("Budi", 25) }
  let(:admin) { User.new("Admin", 30, role: "admin") }

  # let! β€” eager evaluation (dijalankan sebelum setiap test)
  let!(:saved_user) { User.create("Rina", 22) }

  # Perbedaan penting:
  # - let: objek dibuat saat pertama kali dipanggil di test
  # - let!: objek dibuat SEBELUM setiap test (selalu)

  it 'menggunakan lazy let' do
    # user dibuat di sini, saat pertama kali dipanggil
    expect(user.name).to eq("Budi")
    expect(user.age).to eq(25)
  end

  it 'mereferensi let yang sama di test yang sama' do
    # user adalah objek yang SAMA dalam test ini
    expect(user).to equal(user)
  end

  it 'memiliki saved_user yang sudah dibuat' do
    # saved_user sudah dibuat sebelum test ini
    expect(User.all).to include(saved_user)
  end

  # let di-share dalam satu test
  it 'berbeda antar test' do
    expect(user).not_to equal(nil)
    # Setiap test mendapat instance baru
  end
end

before dan after Hooks

RSpec β€” Hooks
RSpec.describe 'Database Tests' do
  # before(:each) β€” dijalankan sebelum setiap test
  before(:each) do
    @db = Database.new
    @db.connect
    puts "Setup: Database connected"
  end

  # after(:each) β€” dijalankan setelah setiap test
  after(:each) do
    @db.disconnect
    puts "Teardown: Database disconnected"
  end

  # before(:all) β€” dijalankan sekali sebelum semua test di blok ini
  before(:all) do
    puts "=== Memulai group test ==="
  end

  # after(:all) β€” dijalankan sekali setelah semua test selesai
  after(:all) do
    puts "=== Selesai group test ==="
  end

  # Contoh dengan database transaction
  context 'dengan data' do
    before(:each) do
      @db.begin_transaction
    end

    after(:each) do
      @db.rollback_transaction  # Bersihkan data setelah test
    end

    it 'menyimpan data' do
      @db.execute("INSERT INTO users (name) VALUES ('Budi')")
      expect(@db.query("SELECT * FROM users").length).to eq(1)
    end

    it 'tidak menyimpan data dari test sebelumnya' do
      # Karena rollback, data test sebelumnya sudah hilang
      expect(@db.query("SELECT * FROM users").length).to eq(0)
    end
  end
end

subject

RSpec β€” subject
RSpec.describe User do
  # subject β€” objek utama yang sedang di-test
  subject { User.new("Budi", 25) }

  # named subject
  subject(:user) { User.new("Budi", 25) }

  # subject! β€” eager evaluation
  subject!(:admin) { User.new("Admin", 30) }

  # is_expected β€” shorthand untuk expect(subject)
  it { is_expected.to be_a(User) }
  it { is_expected.to respond_to(:name) }
  it { is_expected.to respond_to(:age) }

  # Menggunakan named subject
  it 'memiliki nama' do
    expect(user.name).to eq("Budi")
  end

  # Implicit subject: class.new
  # Jika tidak didefinisikan, subject = User.new (tanpa argumen)
  RSpec.describe Array do
    # subject implicitly adalah Array.new (empty array)
    it { is_expected.to be_empty }
  end
end

6. Mocks dan Stubs

Mocks dan Stubs memungkinkan Anda mengisolasi unit test dari dependensi eksternal. Stub mengganti perilaku method, sedangkan Mock mengharapkan method dipanggil dengan cara tertentu.

Test Double

RSpec β€” Test Doubles
# Test Double β€” objek palsu untuk test
RSpec.describe 'Test Doubles' do
  # double β€” objek palsu dengan method yang diharapkan
  it 'double sederhana' do
    user = double("User")
    allow(user).to receive(:name).and_return("Budi")
    expect(user.name).to eq("Budi")
  end

  # double dengan multiple methods
  it 'double dengan banyak method' do
    user = double("User", name: "Budi", age: 25, email: "budi@mail.com")
    expect(user.name).to eq("Budi")
    expect(user.age).to eq(25)
    expect(user.email).to eq("budi@mail.com")
  end

  # instance_double β€” lebih ketat (cek method benar-benar ada)
  it 'instance_double' do
    # Ini hanya bisa menggunakan method yang benar-benar ada di User
    user = instance_double("User", name: "Budi")
    expect(user.name).to eq("Budi")
    # user.nonexistent  # Error! Method tidak ada di User
  end

  # class_double
  it 'class_double' do
    user_class = class_double("User")
    allow(user_class).to receive(:find).and_return(double("User", name: "Budi"))
    expect(user_class.find(1).name).to eq("Budi")
  end
end

Stubs (allow)

RSpec β€” Stubs
# Stub β€” mengganti perilaku method
RSpec.describe 'Stubs' do
  # allow().to receive().and_return()
  it 'stub method sederhana' do
    api = double("API")
    allow(api).to receive(:fetch_users).and_return(["Budi", "Rina"])

    expect(api.fetch_users).to eq(["Budi", "Rina"])
  end

  # Stub dengan multiple return values
  it 'stub dengan return values berbeda' do
    die = double("Die")
    allow(die).to receive(:roll).and_return(1, 5, 3)

    expect(die.roll).to eq(1)
    expect(die.roll).to eq(5)
    expect(die.roll).to eq(3)
  end

  # Stub dengan block (dynamic return)
  it 'stub dengan block' do
    counter = double("Counter")
    allow(counter).to receive(:count) { rand(1..100) }

    result = counter.count
    expect(result).to be_between(1, 100)
  end

  # Stub yang melempar error
  it 'stub yang melempar error' do
    api = double("API")
    allow(api).to receive(:fetch).and_raise(Timeout::Error, "Request timeout")

    expect { api.fetch }.to raise_error(Timeout::Error)
  end

  # Stub pada object yang sudah ada
  it 'stub pada object nyata' do
    user = User.new("Budi", 25)
    allow(user).to receive(:age).and_return(30)  # Override age

    expect(user.age).to eq(30)  # Bukan 25!
    expect(user.name).to eq("Budi")  # Tetap normal
  end

  # and_call_original β€” panggil method asli
  it 'and_call_original' do
    user = User.new("Budi", 25)
    allow(user).to receive(:age).and_call_original  # Method asli

    expect(user.age).to eq(25)  # Memanggil method asli
  end
end

Mocks (expect)

RSpec β€” Mocks
# Mock β€” mengharapkan method dipanggil
RSpec.describe 'Mocks' do
  # expect().to receive() β€” mock yang harus dipanggil
  it 'mock: method harus dipanggil' do
    mailer = double("Mailer")
    expect(mailer).to receive(:send_email).with("budi@mail.com", "Halo!")

    mailer.send_email("budi@mail.com", "Halo!")
    # Jika mailer.send_email tidak dipanggil, test gagal!
  end

  # Mock dengan arguments
  it 'mock: harus dipanggil dengan argumen tertentu' do
    logger = double("Logger")
    expect(logger).to receive(:log).with("User created").once

    logger.log("User created")
  end

  # Mock: tidak boleh dipanggil
  it 'mock: tidak boleh dipanggil' do
    mailer = double("Mailer")
    expect(mailer).not_to receive(:send_email)

    # mailer.send_email tidak boleh dipanggil
  end

  # Mock dengan jumlah panggilan
  it 'mock: jumlah panggilan' do
    counter = double("Counter")
    expect(counter).to receive(:increment).exactly(3).times

    3.times { counter.increment }
  end

  # Mock dengan ordered
  it 'mock: urutan panggilan' do
    api = double("API")
    expect(api).to receive(:connect).ordered
    expect(api).to receive(:fetch_data).ordered
    expect(api).to receive(:disconnect).ordered

    api.connect
    api.fetch_data
    api.disconnect
  end

  # Menggunakan expect dengan message object
  it 'mock: dengan message expectation' do
    user = double("User")
    expect(user).to receive(:name).at_least(:once).and_return("Budi")

    # Dipanggil di dalam kode yang sedang di-test
    result = user.name
    expect(result).to eq("Budi")
  end
end

7. Shared Examples dan Contexts

Shared examples memungkinkan Anda mendefinisikan sekumpulan test yang bisa digunakan di beberapa spec files. Ini sangat berguna untuk menguji behavior yang dimiliki bersama oleh beberapa class.

Shared Examples

RSpec β€” Shared Examples
# Definisikan shared example
RSpec.shared_examples 'a timestamped model' do
  it 'memiliki created_at' do
    expect(subject).to respond_to(:created_at)
  end

  it 'memiliki updated_at' do
    expect(subject).to respond_to(:updated_at)
  end

  it 'created_at terisi setelah save' do
    subject.save
    expect(subject.created_at).not_to be_nil
  end
end

RSpec.shared_examples 'paginatable' do
  describe '.paginated' do
    before { 20.times { |i| described_class.create(name: "Item #{i}") } }

    it 'mengembalikan 10 item per halaman' do
      result = described_class.paginated(page: 1, per_page: 10)
      expect(result.length).to eq(10)
    end

    it 'mengembalikan halaman kedua' do
      result = described_class.paginated(page: 2, per_page: 10)
      expect(result.length).to eq(10)
    end
  end
end

# Menggunakan shared examples
RSpec.describe User do
  it_behaves_like 'a timestamped model'
  it_behaves_like 'paginatable'
end

RSpec.describe Article do
  it_behaves_like 'a timestamped model'
  it_behaves_like 'paginatable'
end

# Shared context β€” untuk setup yang dibagikan
RSpec.shared_context 'authenticated user' do
  let(:current_user) { User.new("Budi", 25, role: "admin") }

  before do
    allow_any_instance_of(ApplicationController)
      .to receive(:current_user).and_return(current_user)
  end
end

RSpec.describe 'Dashboard' do
  include_context 'authenticated user'

  it 'menampilkan nama user' do
    expect(current_user.name).to eq("Budi")
  end
end

RSpec.describe 'Settings' do
  include_context 'authenticated user'

  it 'menampilkan halaman pengaturan' do
    expect(current_user.role).to eq("admin")
  end
end

Custom Matchers

RSpec β€” Custom Matchers
# Custom matcher β€” buat matcher Anda sendiri
RSpec::Matchers.define :be_valid_email do
  match do |actual|
    actual =~ /\A[\w.]+@[\w.]+\.[a-z]{2,}\z/
  end

  failure_message do |actual|
    "'#{actual}' bukan format email yang valid"
  end

  failure_message_when_negated do |actual|
    "'#{actual}' seharusnya bukan email valid"
  end

  description do
    "memeriksa apakah string adalah email yang valid"
  end
end

# Menggunakan custom matcher
RSpec.describe 'Email Validation' do
  it 'memvalidasi email' do
    expect("user@mail.com").to be_valid_email
    expect("invalid-email").not_to be_valid_email
    expect("@mail.com").not_to be_valid_email
  end
end

# Custom matcher yang lebih kompleks
RSpec::Matchers.define :have_role do |expected_role|
  match do |user|
    user.role == expected_role
  end

  chain :in_group do |group|
    @group = group
  end

  description do
    "memiliki role '#{expected_role}'"
  end

  failure_message do |user|
    "expected #{user.name} memiliki role '#{expected_role}', tapi role-nya '#{user.role}'"
  end
end

RSpec.describe 'User Roles' do
  let(:admin) { double("User", name: "Budi", role: "admin") }
  let(:user) { double("User", name: "Rina", role: "member") }

  it 'admin memiliki role admin' do
    expect(admin).to have_role("admin")
  end

  it 'member memiliki role member' do
    expect(user).to have_role("member")
  end
end

8. FactoryBot

FactoryBot (sebelumnya FactoryGirl) adalah library untuk membuat test data secara efisien. FactoryBot sangat umum digunakan bersama RSpec dalam pengembangan Rails.

Mendefinisikan Factory

spec/factories/users.rb
# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    name { "Budi Santoso" }
    email { "budi#{rand(1000)}@mail.com" }
    age { 25 }
    role { "member" }
    active { true }

    # Traits β€” variasi dari factory
    trait :admin do
      role { "admin" }
      name { "Admin User" }
    end

    trait :inactive do
      active { false }
    end

    trait :young do
      age { 18 }
    end

    trait :with_posts do
      after(:create) do |user|
        create_list(:post, 3, user: user)
      end
    end

    # Nested factory
    factory :admin_user, traits: [:admin]
    factory :inactive_user, traits: [:inactive]
  end
end

# spec/factories/posts.rb
FactoryBot.define do
  factory :post do
    title { Faker::Lorem.sentence(word_count: 5) }
    body { Faker::Lorem.paragraph(sentence_count: 10) }
    status { "draft" }
    association :user

    trait :published do
      status { "published" }
      published_at { Time.current }
    end

    trait :with_comments do
      after(:create) do |post|
        create_list(:comment, 5, post: post)
      end
    end
  end
end

Menggunakan FactoryBot

RSpec β€” FactoryBot Usage
# spec/models/user_spec.rb
RSpec.describe User, type: :model do
  # build β€” buat objek tanpa menyimpan ke database
  describe 'attributes' do
    it 'memiliki atribut yang benar' do
      user = build(:user)
      expect(user.name).to be_present
      expect(user.email).to be_present
    end
  end

  # create β€” buat objek dan simpan ke database
  describe 'database' do
    it 'menyimpan user ke database' do
      user = create(:user)
      expect(User.find(user.id)).to eq(user)
    end
  end

  # build_stubbed β€” buat objek palsu dengan ID
  describe 'fast test' do
    it 'tidak perlu database' do
      user = build_stubbed(:user)
      expect(user.id).not_to be_nil  # Ada ID palsu
      expect(user).to be_persisted    # Mengaku persisted
    end
  end

  # Menggunakan traits
  describe 'traits' do
    it 'admin user' do
      admin = create(:user, :admin)
      expect(admin.role).to eq("admin")
    end

    it 'inactive user' do
      inactive = create(:user, :inactive)
      expect(inactive).not_to be_active
    end

    it 'admin dengan posts' do
      admin = create(:user, :admin, :with_posts)
      expect(admin.posts.count).to eq(3)
    end
  end

  # attributes_for β€” buat hash atribut
  describe 'attributes_for' do
    it 'mengembalikan hash atribut' do
      attrs = attributes_for(:user)
      expect(attrs).to include(:name, :email, :age)
      expect(attrs[:name]).to eq("Budi Santoso")
    end
  end

  # build_list dan create_list
  describe 'list' do
    it 'membuat banyak user' do
      users = create_list(:user, 5)
      expect(User.count).to eq(5)
    end

    it 'build banyak user tanpa simpan' do
      users = build_list(:user, 10)
      expect(users.length).to eq(10)
    end
  end
end

9. RSpec dengan Rails

Rails menggunakan RSpec melalui gem rspec-rails yang menyediakan type-specific helpers untuk model, controller, request, dan feature specs.

Model Spec

spec/models/user_spec.rb
require 'rails_helper'

RSpec.describe User, type: :model do
  describe 'validations' do
    it 'valid dengan atribut lengkap' do
      user = build(:user)
      expect(user).to be_valid
    end

    it 'tidak valid tanpa nama' do
      user = build(:user, name: nil)
      expect(user).not_to be_valid
      expect(user.errors[:name]).to include("can't be blank")
    end

    it 'tidak valid dengan email duplikat' do
      create(:user, email: "test@mail.com")
      user = build(:user, email: "test@mail.com")
      expect(user).not_to be_valid
      expect(user.errors[:email]).to include("has already been taken")
    end

    it 'tidak valid dengan email format salah' do
      user = build(:user, email: "not-an-email")
      expect(user).not_to be_valid
      expect(user.errors[:email]).to be_present
    end

    it 'tidak valid dengan umur negatif' do
      user = build(:user, age: -1)
      expect(user).not_to be_valid
    end
  end

  describe 'associations' do
    it 'memiliki banyak posts' do
      user = create(:user)
      expect(user).to respond_to(:posts)
    end

    it 'menghapus posts saat user dihapus' do
      user = create(:user, :with_posts)
      expect { user.destroy }.to change(Post, :count).by(-3)
    end
  end

  describe 'scopes' do
    it '.active mengembalikan user aktif' do
      active = create(:user, active: true)
      inactive = create(:user, :inactive)
      expect(User.active).to include(active)
      expect(User.active).not_to include(inactive)
    end
  end

  describe '#full_name' do
    it 'mengembalikan nama lengkap' do
      user = build(:user, name: "Budi Santoso")
      expect(user.full_name).to eq("Budi Santoso")
    end
  end
end

Request Spec (Controller)

spec/requests/users_spec.rb
require 'rails_helper'

RSpec.describe 'Users API', type: :request do
  describe 'GET /users' do
    before { create_list(:user, 3) }

    it 'mengembalikan semua user' do
      get users_path
      expect(response).to have_http_status(:ok)
      expect(response.body).to include(User.first.name)
    end
  end

  describe 'GET /users/:id' do
    let(:user) { create(:user) }

    it 'mengembalikan user tertentu' do
      get user_path(user)
      expect(response).to have_http_status(:ok)
      expect(response.body).to include(user.name)
    end
  end

  describe 'POST /users' do
    context 'dengan data valid' do
      let(:valid_params) { { user: attributes_for(:user) } }

      it 'membuat user baru' do
        expect {
          post users_path, params: valid_params
        }.to change(User, :count).by(1)

        expect(response).to have_http_status(:redirect)
        expect(flash[:notice]).to eq("User berhasil dibuat!")
      end
    end

    context 'dengan data tidak valid' do
      let(:invalid_params) { { user: attributes_for(:user, name: nil) } }

      it 'tidak membuat user' do
        expect {
          post users_path, params: invalid_params
        }.not_to change(User, :count)

        expect(response).to have_http_status(:unprocessable_entity)
      end
    end
  end

  describe 'DELETE /users/:id' do
    let!(:user) { create(:user) }

    it 'menghapus user' do
      expect {
        delete user_path(user)
      }.to change(User, :count).by(-1)

      expect(response).to redirect_to(users_path)
    end
  end
end

10. Best Practices

Conventions dan Tips

RSpec Best Practices
# βœ… BEST PRACTICES:

# 1. One expectation per test (kecuali integration test)
# BAD
it 'memvalidasi semua field' do
  expect(user.name).to eq("Budi")
  expect(user.email).to eq("budi@mail.com")
  expect(user.age).to eq(25)
end

# GOOD
it 'memiliki nama yang benar' do
  expect(user.name).to eq("Budi")
end

# 2. Gunakan described_class untuk referensi class yang sedang di-test
RSpec.describe User do
  it 'membuat user baru' do
    user = described_class.new  # Sama dengan User.new
    expect(user).to be_a(described_class)
  end
end

# 3. Jangan test implementation, test behavior
# BAD β€” test implementation
it 'memanggil method validate_name' do
  expect(user).to receive(:validate_name)
  user.save
end

# GOOD β€” test behavior
it 'tidak valid tanpa nama' do
  user.name = nil
  expect(user).not_to be_valid
end

# 4. Gunakan let untuk setup, bukan instance variables
# BAD
before(:each) do
  @user = User.new("Budi", 25)
end

# GOOD
let(:user) { User.new("Budi", 25) }

# 5. Gunakan context untuk mengelompokkan skenario
RSpec.describe User do
  describe '#full_name' do
    context 'ketika semua field terisi' do
      it 'mengembalikan nama lengkap' do
        # ...
      end
    end

    context 'ketika nama belakang kosong' do
      it 'mengembalikan nama depan saja' do
        # ...
      end
    end
  end
end

# 6. Test nama yang deskriptif
# BAD
it 'berhasil' do
  # ...
end

# GOOD
it 'mengembalikan daftar user yang aktif' do
  # ...
end

# 7. Jalankan test secara acak untuk mendeteksi dependencies
# .rspec: --order random

Coverage dengan SimpleCov

SimpleCov
# Gemfile
# gem 'simplecov', require: false, group: :test

# spec/spec_helper.rb β€” TAMBAHKAN DI PALING ATAS
require 'simplecov'
SimpleCov.start do
  add_filter '/spec/'
  add_filter '/config/'
  add_filter '/vendor/'

  add_group 'Models', 'app/models'
  add_group 'Controllers', 'app/controllers'
  add_group 'Services', 'app/services'

  minimum_coverage 80  # Minimal 80% coverage
end

# Jalankan test:
# bundle exec rspec
# Buka coverage/index.html untuk melihat laporan coverage

# Target coverage:
# - Line coverage: minimal 90%
# - Branch coverage: minimal 80%
# - Critical models/controllers: 100%

11. Quiz: Uji Pemahamanmu!

Setelah membaca tutorial di atas, jawablah 5 pertanyaan berikut untuk menguji pemahamanmu tentang Ruby Testing dengan RSpec:

Pertanyaan 1: Apa perbedaan antara describe dan context di RSpec?

a) describe untuk test, context untuk setup
b) Keduanya identik secara teknis, tapi context digunakan untuk mengelompokkan skenario/conditions
c) describe hanya untuk class, context untuk method
d) context harus selalu berisi let

Pertanyaan 2: Apa perbedaan antara let dan let!?

a) let untuk string, let! untuk angka
b) let lazy evaluation (dijalankan saat dipanggil), let! eager evaluation (dijalankan sebelum setiap test)
c) let hanya untuk class method, let! untuk instance method
d) Tidak ada perbedaan

Pertanyaan 3: Apa fungsi dari allow(obj).to receive(:method).and_return(value)?

a) Memanggil method yang sebenarnya
b) Mengganti perilaku method dengan nilai yang ditentukan (stub)
c) Menghapus method dari objek
d) Membuat method baru pada class

Pertanyaan 4: Manakah yang merupakan cara yang benar untuk mengekspektasi error di RSpec?

a) expect { code }.to raise_error(ErrorClass)
b) expect(code).to throw_error(ErrorClass)
c) expect(code).to have_error(ErrorClass)
d) expect { code }.to catch_error(ErrorClass)

Pertanyaan 5: Apa kepanjangan dari BDD dalam konteks RSpec?

a) Basic Design Development
b) Behavior-Driven Development
c) Bug-Driven Debugging
d) Block-Driven Design
πŸ” Zoom
100%
🎨 Tema