DEV Community

Aviraj Khare
Aviraj Khare

Posted on

Joy of Test Driven Development(TDD) using Rspec in Ruby

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

  1. Create a new directory called Money.
  2. Create Gemfile file inside the directory and add only one line: https://rubygems.org.
  3. Run bundle add rspec to install Rspec gem. You will notice that Gemfile is modified and Gemfile.lock is created.
  4. Create two new directories called spec and lib

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Let's try to fix this error by typing following lines of code:

class Money

end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

In order to fix it, let's add following line of code:

class Money
  attr_reader :amount
  def initialize(amount)
    @amount = amount
  end
end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Run the tests:

rspec spec/money_spec.rb
...

Finished in 0.00312 seconds (files took 0.04221 seconds to load)
3 examples, 0 failures
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Running the tests:

rspec spec/money_spec.rb
........

Finished in 0.00445 seconds (files took 0.04567 seconds to load)
8 examples, 0 failures
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)