I had just finished reading Programming Ruby 1.9, and was ready to sink my teeth into actually writing some Ruby. Being a long-time proponent of Test Driven Development, an interesting idea occurred to me. In Kent Beck's Test Driven Development: by Example book, he uses the example of adding different currencies together. This is a pretty easy problem to understand, and relatively easy to code up, but has just enough complexity in design to offer me some interesting practice with Ruby. In the book, Beck writes all of his unit tests and code in Java--I wrote the same tests and code in Ruby.
For readers who do not have their copy of Test Driven Development: by Example handy, the problem in a nutshell can be stated by one test:
- $5 + 10 CHF = $10 if rate is 2:1
Or, in plain English: adding five US dollars to 10 Swiss francs should yield 10 US dollars if the exchange rate is 2 Swiss francs to 1 US dollar. Users of Test Driven Development (hereafter TDD) will not be surprised to learn that writing this test required writing many smaller tests first, such as adding two dollar amounts, converting a dollar amount into a franc amount, etc.
I was able to work through the 15 chapters of the example in two days (maybe six hours of coding time). Ruby's MiniTest unit testing framework is an xUnit variant, just like the JUnit framework that Beck uses in the book, so my Ruby tests are virtually the same as his Java tests. The class design is also the same, with the one exception that I did not need the Expression interface that his Java design requires, thanks to duck typing. It felt to me like duck typing simplified the Ruby code quite a bit, as compared to the Java code. There were certainly several times when Beck needed a page to present some refactorings to clean up the design that took me one line in Ruby. All told, my Ruby version was 62 lines of functional code and 78 lines of test code, as opposed to 91 and 89, respectively, in the Java version.
So, with no further ado, here is the example. I'll show you the tests first, then the functional code, then finally the log of all of my commits while working on this problem (my Git repository is available for anyone who wants it: http://www.jmglov.net/opensource/src/tdd-by-example_ruby_git.tar.gz).
Test code: test_Money.rb
#!/usr/bin/env ruby1.9.1
require 'money'
require 'test/unit'
class TestMoney < MiniTest::Unit::TestCase
def test_multiplication
five = Money.dollar(5)
assert_equal(Money.dollar(10), five * 2)
assert_equal(Money.dollar(15), five * 3)
end
def test_equality
assert(Money.dollar(5) == Money.dollar(5))
refute(Money.dollar(5) == Money.dollar(6))
refute(Money.dollar(5) == Money.franc(5))
end
def test_currency
assert_equal(:USD, Money.dollar(1).currency)
assert_equal(:CHF, Money.franc(1).currency)
end
def test_simple_addition
five = Money.dollar(5)
sum = five + five
bank = Bank.new
reduced = bank.reduce(sum, :USD)
assert_equal(Money.dollar(10), reduced)
end
def test_plus_returns_sum
five = Money.dollar(5)
sum = five + five
assert_equal(five, sum.augend)
assert_equal(five, sum.addend)
end
def test_reduce_sum
sum = Sum.new(Money.dollar(3), Money.dollar(4))
bank = Bank.new
result = bank.reduce(sum, :USD)
assert_equal(Money.dollar(7), result)
end
def test_reduce_money
bank = Bank.new
result = bank.reduce(Money.dollar(1), :USD)
assert_equal(Money.dollar(1), result)
end
def test_reduce_money_different_currency
bank = Bank.new
bank.add_rate(:CHF, :USD, 2)
result = bank.reduce(Money.franc(2), :USD)
assert_equal(Money.dollar(1), result)
end
def test_identity_rate
assert_equal(1, Bank.new.rate(:USD, :USD))
end
def test_mixed_addition
five_bucks = Money.dollar(5)
ten_francs = Money.franc(10)
bank = Bank.new
bank.add_rate(:CHF, :USD, 2)
result = bank.reduce(five_bucks + ten_francs, :USD)
assert_equal(Money.dollar(10), result)
end
def test_sum_plus_money
five_bucks = Money.dollar(5)
ten_francs = Money.franc(10)
bank = Bank.new
bank.add_rate(:CHF, :USD, 2)
sum = Sum.new(five_bucks, ten_francs) + five_bucks
result = bank.reduce(sum, :USD)
assert_equal(Money.dollar(15), result)
end
def test_sum_times
five_bucks = Money.dollar(5)
ten_francs = Money.franc(10)
bank = Bank.new
bank.add_rate(:CHF, :USD, 2)
sum = Sum.new(five_bucks, ten_francs) * 2
result = bank.reduce(sum, :USD)
assert_equal(Money.dollar(20), result)
end
end
Functional code: money.rb
class Money
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = amount
@currency = currency
end
def self.dollar(amount)
Money.new(amount, :USD)
end
def self.franc(amount)
Money.new(amount, :CHF)
end
def ==(other)
@amount == other.amount &&
@currency == other.currency
end
def *(multiplier)
Money.new(@amount * multiplier, @currency)
end
def +(addend)
Sum.new(self, addend)
end
def reduce(bank, to_currency)
rate = bank.rate(@currency, to_currency)
Money.new(@amount / rate, to_currency)
end
end
class Bank
def initialize
@rates = {}
end
def reduce(expression, to_currency)
expression.reduce(self, to_currency)
end
def add_rate(from, to, rate)
@rates[[from, to]] = rate
end
def rate(from, to)
return 1 if from == to
@rates[[from, to]]
end
end
class Expression
end
class Sum
attr_reader :augend, :addend
def initialize(augend, addend)
@augend = augend
@addend = addend
end
def reduce(bank, to_currency)
amount = @augend.reduce(bank, to_currency).amount +
@addend.reduce(bank, to_currency).amount
Money.new(amount, to_currency)
end
def +(addend)
Sum.new(self, addend)
end
def *(multiplier)
Sum.new(@augend * multiplier, @addend * multiplier)
end
end
Commit log
- TestMoney#test_multiplication fails to compile (C1 : Multi-Currency Money)
- test_Dollar: TestDollar should subclass Test::Unit::Testcase instead of MiniTest::... (C1 - Multi-Currency Money)
- test_Dollar: should be Dollar.new(...) (C1 - Multi-Currency Money)
- test_Dollar#test_multiplication: red bar! (C1 - Multi-Currency Money)
- TestDollar#test_multiplication: green bar! (C1 - Multi-Currency Money)
- Dollar#times sets @amount (green bar) (C1 - Multi-Currency Money)
- Dollar#times real implementation (green bar) (C1 - Multi-Currency Money)
- TestDollar#test_multiplication object modified (RB) (C2 - Degenerate Objects)
- TestDollar expects new obj from Dollar#times (ERR) (C2 - Degenerate Objects)
- Dollar#times returns new obj (GB) (C2 - Degenerate Objects)
- TestDollar#test_equality (RB) (C3 - Equality for All)
- Dollar#== return true (GB) (C3 - Equality for All)
- TestDollar#test_equality tests inequality (RB) (C3 - Equality for All)
- Dollar#== actual implementation (GB) (C3 - Equality for All)
- test_Dollar: switched to ruby1.9.1 and MiniTest
- TestDollar#test_multiplication asserts equality(GB) (C4 - Privacy)
- Dollar#amount is protected (GB) (C4 - Privacy)
- TestFranc#test_multiplication (ERR) (C5 - Franc-ly Speaking)
- Copied Dollar to Franc (GB) (C5 - Franc-ly Speaking)
- Added Money class (GB) (C6 - Equality for All, Redux)
- Dollar subclasses Money (GB) (C6 - Equality for All, Redux)
- Pushed Dollar#== up to Money (GB) (C6 - Equality for All, Redux) (Ruby eliminates the need to fool around with casts and instance variables!)
- Added TestFranc#test_equality (GB) (C6 - Equality for All, Redux)
- Franc subclasses Money (GB) (C6 - Equality for All, Redux)
- Removed Franc#== (GB) (C6 - Equality for All, Redux)
- TestMoney#test_equality exposes a bug (ERR): we should have moved the protected attr_reader :amount up to Money
- Pushed protected @amount up to Money (GB) (C7 - Apples and Oranges)
- Money#== compares classes (GB) (C7 - Apples and Oranges)
- TestDollar#test_multiplication use Money#dollar(GB) (C8 - Makin' Objects) (Ruby's duck typing eliminates the Money#times compiler error from the book!)
- Using Money#dollar in all tests (GB) (C8 - Makin' Objects)
- Added Money#franc factory (GB) (C8 - Makin' Objects)
- test_Money requiring 'money' breaks tests (ERR) (C8 - Makin' Objects) (This is a Ruby-only problem, since the Java tests probably do an 'import com.wycash.money.*')
- All classes declared in money.rb (GB) (C8 - Makin' Objects)
- Deleted dollar.rb and franc.rb (GB) (C8 - Makin' Objects)
- Money#currency and unit tests for same (GB) (C9 - Times We're Livin' In)
- Currency becomes an instance variable (GB) (C9 - Times We're Livin' In)
- Pulled up Money#currency (GB) (C9 - Times We're Livin' In)
- Added currency param to Franc#new (ERR) (C9 - Times We're Livin' In)
- Money#franc and Franc#times pass nil currency (RB) (C9 - Times We're Livin' In)
- Franc#times calls Money#franc which passes :CHF(GB) (C9 - The Times We're Livin' In)
- Money#dollar passes :USD (GB) (C9 - The Times We're Livin' In)
- Pushed up Dollar#new and Franc#new to Money (GB) (C9 - The Times We're Livin' In)
- Inlined factory in Dollar and Franc #times (GB) (C10 - Interesting Times)
- Dollar and Franc#times pass @currency to ctor (GB) (C10 - Interesting times)
- Dollar and Franc#times return a Money obj (RB) (C10 - Interesting Times)
- Backed out #times returning Money (GB) (C10 - Interesting Times)
- TestMoney#test_different_class_equality (RB) (C10 - Interesting Times)
- Money#== checks currency rather than class (GB) (C10 - Interesting Times) (This change feels more natural in Ruby, where duck typing means that testing class is rarely the best way forward)
- Franc#times returns a Money obj (GB) (C10 - Interesting Times)
- Dollar#times returns a Money obj (GB) (C10 - Interesting Times)
- Pulled up Money#times (GB) (C10 - Interesting Times)
- Removed explicit Money#currency (GB) (we already have attr_reader :currency, so this is unnecessary; the fact that I stuck it in there in the beginning means I still have some way to go to think natively in Ruby)
- Moved attr_reader :currency to top of money.rb (GB) (just to improve readability, in my opinion)
- Money#dollar and #franc return Money obj (GB) (C11 - The Root of All Evil)
- Removed Dollar class (GB) (C11 - The Root of All Evil)
- Removed Franc class (RB) (C11 - The Root of All Evil)
- Removed TestMoney#test_dft_class_inequality (GB) (and TestFranc#test_equality, which is equivalent to removing the third and forth assertions in testEquality() from TDDbE p.52) (C11 - The Root of All Evil)
- Removed TestFranc#test_multiplication (GB) (and test_Franc.rb, since there are no tests left in the file) (C11 - The Root of All Evil)
- Moved tests in test_Dollar.rb to test_Money.rb (GB) (tests are easier to keep track of all in one file, and since we don't have a Dollar subclass any more, the test naming convention is broken anyway)
- Added TestMoney#test_simple_addition (ERR) (C12 - Addition, Finally)
- Implemented Money#+ (GB) (C12 - Addition, Finally) (obvious implementation)
- TestMoney#t_smpl#add uses Expressions, Banks (ERR) (C12 - Addition, Finally)
- Fake implementation of Bank#reduce (GB) (C12 - Addition, Finally) (there should have been a red bar step before this one, but returning nil from Bank#reduce causes a compilation error in Ruby due to the lack of a formal return specification, i.e. def [Money] reduce...)
- TestMoney#t_smpl_add should add five + five (GB) (oversight on my part)
- TestMoney#test_plus_returns_sum (ERR) (C13 - Make It)
- Money#+ returns a Sum obj (GB) (C13 - Make It)
- Added TestMoney#test_reduce_sum (RB) (C13 - Make It)
- Bank#reduce actually adds (ERR) (C13 - Make It) (this seems like a bug in the TDDbE book to me; as we made the amount field protected in a previous chapter)
- Implemented Money#reduce (ERR) (C13 - Make It) (this should be a green bar, according to TDDbE, but the protected Money#amount accessor is called again)
- Made Money#amount public to stop errors (GB) (we want to be able to follow TDDbE closely, despite the seeming bug--I should do this exercise in Java as well to see if there really is a bug)
- Bank#reduce special case for Money (GB) (C13 - Make It)
- Implemented Money#reduce (GB) (C13 - Make It)
- Removed "cast" and class check from Bank#reduce(GB) (C13 - Make It)
- TestMoney#test_reduce_money_different_currency (RB) (C14 - Change)
- Hard-coded CHF -> USD in Money#reduce (GB) (C14 - Change)
- Added bank parameter to all #reduce methods (GB) (C14 - Change)
- Moved rate calculation to Bank#rate (GB) (C14 - Change)
- Bank stores rates in hashtable (ERR) (C14 - Change) (because Ruby can use an array--or any other object, for that matter--as a hash key, we don't need the Pair class from TDDbEx; we can simply use a two-element array ([from_currency, to_currency]) as our rate hash key)
- Bank#rate returns 1 when from and to are equal (GB) (C14 - Change)
- Added TestMoney#test_mixed_addition (RB) (C15 - Mixed Currencies) (this would throw "a host of compile errors" in Java, according to TDDbE, but duck typing means we compile just fine and simply fail a test)
- Sum#reduce reduces both arguments (GB) (C15 - Mixed Currencies)
- Stubbed out Sum#+ (GB) (C15 - Mixed Currencies) (we just did this to stay in sync with TDDbE--Java's type system required the stub, but Ruby's duck types care not)
- Added TestMoney#test_sum_plus_money (ERR) (C16 - Abstration, Finally)
- Real implementation of Sum#+ (GB) (C16 - Abstration, Finally)
- Added TestMoney#test_sum_times (ERR) (C16 - Abstration, Finally)
- Implemented Sum#times (GB) (C16 - Abstration, Finally)
- Redefined Money and Sum#times as #* (GB) (this seems more Ruby-native to me)
- TestMoney#test_plus_same_currency_returns_money(RB) (C16 - Abstraction, Finally)
- Removed TestMoney#t_plus_same_curr_ret_mny (GB) (C16 - Abstraction, Finally) (we removed this test because a clean way to have Money#+ return a Money object instead of a Sum was not obvious)