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)