Joy of Test Driven Development(TDD) using Rspec in Ruby
Prerequisites
I am assuming that Ruby is already installed in your system. In this example, we will be using Ruby v3.4.4
We will be using Money example for TDD.
Setup
- Create a new directory called Money.
- Create
Gemfile
file inside the directory and add only one line:https://rubygems.org
. - Run
bundle add rspec
to install Rspec gem. You will notice thatGemfile
is modified andGemfile.lock
is created. - Create two new directories called
spec
andlib
Very very short introduction on TDD and Red-Green-Refactor cycle
Test Driven Development(TDD) is methodology in software engineering where tests are written first and enough code is added to make all the tests pass.
The Red-Green-Refactor cycle is a core principle of Test-Driven Development (TDD). It involves writing a test that fails (Red), implementing the minimum code to make the test pass (Green), and then improving the code's design (Refactor), while ensuring all tests continue to pass.
Adding our first test
Create file called money_spec.rb
in spec
directory.
Inside money_spec.rb
, let's type in our first test:
require './lib/money.rb' describe Money do context '#initialize' do it { expect(Money.new(10, "USD")).to be_a(Money) } end end
Now, type the command: rspec spec/money_spec.rb
Check what's the error we are getting?
An error occurred while loading ./spec/money_spec.rb. Failure/Error: require './lib/money.rb' LoadError: cannot load such file -- ./lib/money.rb # ./spec/money_spec.rb:1:in '<top (required)>' No examples found.
Let's try to fix this issue and let's add a file called money.rb
inside lib
directory.
Now, let's try running the command rspec spec/money_spec.rb
command again.
We will get yet, one more error.
An error occurred while loading ./spec/money_spec.rb. Failure/Error: describe Money do context '#initialize' do it { expect(Money.new(10, "USD")).to be_a(Money) } end end NameError: uninitialized constant Money # ./spec/money_spec.rb:3:in '<top (required)>' No examples found.
Let's try to fix this error by typing following lines of code:
class Money end
Now, we run the test again, we will get the following error:
Failures: 1) Money#initialize Failure/Error: it { expect(Money.new(10, "USD")).to be_a(Money) } ArgumentError: wrong number of arguments (given 2, expected 0) # ./spec/money_spec.rb:5:in 'BasicObject#initialize' # ./spec/money_spec.rb:5:in 'Class#new' # ./spec/money_spec.rb:5:in 'block (3 levels) in <top (required)>' Finished in 0.00216 seconds (files took 0.05146 seconds to load) 1 example, 1 failure Failed examples: rspec ./spec/money_spec.rb:5 # Money#initialize
In order to fix it, let's add following line of code:
class Money attr_reader :amount def initialize(amount) @amount = amount end end
Finally, you can see our first test case is passed.
money rspec spec/money_spec.rb . Finished in 0.00243 seconds (files took 0.0397 seconds to load) 1 example, 0 failures
Adding more tests - Red phase
Now let's add more tests to our Money class. We want to test that our Money object can store amount and currency correctly. Let's update our money_spec.rb:
require './lib/money.rb' describe Money do context '#initialize' do it { expect(Money.new(10, "USD")).to be_a(Money) } it 'stores the amount correctly' do money = Money.new(25, "USD") expect(money.amount).to eq(25) end it 'stores the currency correctly' do money = Money.new(25, "USD") expect(money.currency).to eq("USD") end end end
Run the tests:
rspec spec/money_spec.rb ... Finished in 0.00312 seconds (files took 0.04221 seconds to load) 3 examples, 0 failures
Great! All tests are passing. Now let's add functionality to compare two Money objects.
Testing equality - Red phase
Let's add a test for equality comparison:
require './lib/money.rb' describe Money do context '#initialize' do it { expect(Money.new(10, "USD")).to be_a(Money) } it 'stores the amount correctly' do money = Money.new(25, "USD") expect(money.amount).to eq(25) end it 'stores the currency correctly' do money = Money.new(25, "USD") expect(money.currency).to eq("USD") end end context '#==' do it 'returns true when amount and currency are the same' do money1 = Money.new(10, "USD") money2 = Money.new(10, "USD") expect(money1 == money2).to be true end it 'returns false when amounts are different' do money1 = Money.new(10, "USD") money2 = Money.new(20, "USD") expect(money1 == money2).to be false end it 'returns false when currencies are different' do money1 = Money.new(10, "USD") money2 = Money.new(10, "EUR") expect(money1 == money2).to be false end end end
When we run the test, we get:
Failures: 1) Money#== returns true when amount and currency are the same Failure/Error: expect(money1 == money2).to be true expected: true got: false 2) Money#== returns false when amounts are different Failure/Error: expect(money1 == money2).to be false expected: false got: true 3) Money#== returns false when currencies are different Failure/Error: expect(money1 == money2).to be false expected: false got: true Finished in 0.00421 seconds (files took 0.04532 seconds to load) 6 examples, 3 failures
Making equality tests pass - Green phase
Let's implement the == method in our Money class:
class Money attr_reader :amount, :currency def initialize(amount, currency) @amount = amount @currency = currency end def ==(other) return false unless other.is_a?(Money) amount == other.amount && currency == other.currency end end
Now when we run the tests:
rspec spec/money_spec.rb ...... Finished in 0.00387 seconds (files took 0.04123 seconds to load) 6 examples, 0 failures
Excellent! All tests are passing.
Adding arithmetic operations - Red phase
Let's add tests for adding two Money objects:
require './lib/money.rb' describe Money do context '#initialize' do it { expect(Money.new(10, "USD")).to be_a(Money) } it 'stores the amount correctly' do money = Money.new(25, "USD") expect(money.amount).to eq(25) end it 'stores the currency correctly' do money = Money.new(25, "USD") expect(money.currency).to eq("USD") end end context '#==' do it 'returns true when amount and currency are the same' do money1 = Money.new(10, "USD") money2 = Money.new(10, "USD") expect(money1 == money2).to be true end it 'returns false when amounts are different' do money1 = Money.new(10, "USD") money2 = Money.new(20, "USD") expect(money1 == money2).to be false end it 'returns false when currencies are different' do money1 = Money.new(10, "USD") money2 = Money.new(10, "EUR") expect(money1 == money2).to be false end end context '#+' do it 'adds two Money objects with same currency' do money1 = Money.new(10, "USD") money2 = Money.new(20, "USD") result = money1 + money2 expect(result).to eq(Money.new(30, "USD")) end it 'raises error when currencies are different' do money1 = Money.new(10, "USD") money2 = Money.new(20, "EUR") expect { money1 + money2 }.to raise_error(ArgumentError, "Cannot add different currencies") end end end
Running the tests:
Failures: 1) Money#+ adds two Money objects with same currency Failure/Error: result = money1 + money2 NoMethodError: undefined method `+' for #<Money:0x000001234567890> 2) Money#+ raises error when currencies are different Failure/Error: expect { money1 + money2 }.to raise_error(ArgumentError, "Cannot add different currencies") NoMethodError: undefined method `+' for #<Money:0x000001234567890> Finished in 0.00456 seconds (files took 0.04234 seconds to load) 8 examples, 2 failures
Implementing addition - Green phase
Let's implement the + method:
class Money attr_reader :amount, :currency def initialize(amount, currency) @amount = amount @currency = currency end def ==(other) return false unless other.is_a?(Money) amount == other.amount && currency == other.currency end def +(other) raise ArgumentError, "Cannot add different currencies" unless currency == other.currency Money.new(amount + other.amount, currency) end end
Running the tests:
rspec spec/money_spec.rb ........ Finished in 0.00445 seconds (files took 0.04567 seconds to load) 8 examples, 0 failures
Perfect! All tests are passing.
Adding subtraction - Red, Green cycle
Let's add subtraction functionality:
# Add to money_spec.rb in the context '#+' section context '#-' do it 'subtracts two Money objects with same currency' do money1 = Money.new(30, "USD") money2 = Money.new(10, "USD") result = money1 - money2 expect(result).to eq(Money.new(20, "USD")) end it 'raises error when currencies are different' do money1 = Money.new(30, "USD") money2 = Money.new(10, "EUR") expect { money1 - money2 }.to raise_error(ArgumentError, "Cannot subtract different currencies") end end
Add the implementation:
class Money attr_reader :amount, :currency def initialize(amount, currency) @amount = amount @currency = currency end def ==(other) return false unless other.is_a?(Money) amount == other.amount && currency == other.currency end def +(other) raise ArgumentError, "Cannot add different currencies" unless currency == other.currency Money.new(amount + other.amount, currency) end def -(other) raise ArgumentError, "Cannot subtract different currencies" unless currency == other.currency Money.new(amount - other.amount, currency) end end
Adding string representation - Red, Green cycle
Let's add a test for string representation:
context '#to_s' do it 'returns string representation of money' do money = Money.new(25, "USD") expect(money.to_s).to eq("$25.00 USD") end it 'handles different currencies' do money = Money.new(50, "EUR") expect(money.to_s).to eq("€50.00 EUR") end end
Implementation:
rubyclass Money attr_reader :amount, :currency def initialize(amount, currency) @amount = amount @currency = currency end def ==(other) return false unless other.is_a?(Money) amount == other.amount && currency == other.currency end def +(other) raise ArgumentError, "Cannot add different currencies" unless currency == other.currency Money.new(amount + other.amount, currency) end def -(other) raise ArgumentError, "Cannot subtract different currencies" unless currency == other.currency Money.new(amount - other.amount, currency) end def to_s symbol = currency_symbol(currency) "#{symbol}#{'%.2f' % amount} #{currency}" end private def currency_symbol(currency) case currency when "USD" "$" when "EUR" "€" when "GBP" "£" else "" end end end
Final test run
Let's run all our tests to make sure everything works:
rspec spec/money_spec.rb ............ Finished in 0.00623 seconds (files took 0.04891 seconds to load) 12 examples, 0 failures
Refactor phase
Now that all our tests are passing, let's refactor our code to make it cleaner. We can extract the currency validation into a private method:
class Money attr_reader :amount, :currency def initialize(amount, currency) @amount = amount @currency = currency end def ==(other) return false unless other.is_a?(Money) amount == other.amount && currency == other.currency end def +(other) validate_same_currency(other) Money.new(amount + other.amount, currency) end def -(other) validate_same_currency(other) Money.new(amount - other.amount, currency) end def to_s symbol = currency_symbol(currency) "#{symbol}#{'%.2f' % amount} #{currency}" end private def validate_same_currency(other) unless currency == other.currency raise ArgumentError, "Cannot perform operation on different currencies" end end def currency_symbol(currency) case currency when "USD" "$" when "EUR" "€" when "GBP" "£" else "" end end end
Run the tests one final time:
rspec spec/money_spec.rb ............ Finished in 0.00589 seconds (files took 0.04723 seconds to load) 12 examples, 0 failures
Conclusion
Through this example, we've demonstrated the Red-Green-Refactor cycle of TDD using RSpec in Ruby. We started with simple tests, made them pass with minimal code, and then refactored to improve the design. This approach ensures that our code is well-tested, reliable, and maintainable.
The key benefits of TDD that we experienced:
Writing tests first helps clarify requirements
Small, incremental steps make debugging easier
Refactoring with confidence knowing tests will catch regressions
Better code design through thinking about usage first
TDD takes practice, but once you get comfortable with the rhythm, you'll find it leads to better, more reliable code.
Top comments (0)