How to Use ZenTest with Ruby
Refactoring and unit testing are a great pair of tools and should be a part of every programmer's workbench. Sadly, not every programmer knows how to use these tools. My first exposure to them came when I started using Ruby. Refactoring and unit testing are a big part of the landscape in the Ruby community.
Some time ago, I translated the refactoring example from the first chapter of Martin Fowler's excellent book Refactoring from Java to Ruby. I felt this would be a great way to learn more about refactoring and brush up on my Ruby while I was at it. Recently, I decided to update the translation for Ruby 1.8.X. One of the things I needed to do was convert the old unit tests to work with Test::Unit, the new unit testing framework for Ruby. I wasn't looking forward to building a new test suite, however. Fortunately, help was available.
Ryan Davis has written a great tool called ZenTest, which creates test suites for existing bodies of code. Because a lot of people are new to refactoring, unit testing and ZenTest, this article serves as an introduction to this trio of tools.
Martin's example code is built around a video store application. His original code includes three classes--Customer, Movie and Rental. I focus on only the Customer class in this article. Here's the original code:
class Customer attr_accessor :name def initialize(name) @name = name @rentals = Array.new end def addRental(aRental) @rentals.push(aRental) end def statement totalAmount = 0.0 frequentRenterPoints = 0 rentals = @rentals.length result = "\nRental Record for #{@name}\n" thisAmount = 0.0 @rentals.each do |rental| # determine amounts for each line case rental.aMovie.pricecode when Movie::REGULAR thisAmount += 2 if rental.daysRented > 2 thisAmount += (rental.daysRented - 2) * 1.5 end when Movie::NEW_RELEASE thisAmount += rental.daysRented * 3 when Movie::CHILDRENS thisAmount += 1.5 if each.daysRented > 3 thisAmount += (rental.daysRented - 3) * 1.5 end end # add frequent renter points frequentRenterPoints += 1 # add bonus for a two day new release rental if ( rental.daysRented > 1) && (Movie::NEW_RELEASE == rental.aMovie.pricecode) frequentRenterPoints += 1 end # show figures for this rental result +="\t#{rental.aMovie.title}\t#{thisAmount}\n" totalAmount += thisAmount end result += "Amount owed is #{totalAmount}\n" result += "You earned #{frequentRenterPoints} frequent renter points" end end
It's not the cleanest code in the world, but that's the point--this represents the code as you get it from the user. It contains no tests and is poorly laid out, but it works. Your your job is to make it work better without breaking it. So, where to start? With unit tests, of course. Time to grab ZenTest.
You can tell ZenTestit to do this:
$ zentest videostore.rb > test_videostore.rb
which produces a file full of tests. Running the test suite doesn't do exactly what we were hoping, however:
$ ruby testVideoStore.rb Loaded suite testVideoStore Started EEEEEEEEEEE Finished in 0.008974 seconds. 1) Error!!! test_addRental(TestCustomer): NotImplementedError: Need to write test_addRental testVideoStore.rb:11:in `test_addRental' testVideoStore.rb:54 2) Error!!! test_name=(TestCustomer): NotImplementedError: Need to write test_name= testVideoStore.rb:15:in `test_name=' testVideoStore.rb:54 3) Error!!! test_statement=(TestCustomer): NotImplementedError: Need to write test_statement testVideoStore.rb:19:in `test_statement' testVideoStore.rb:54 . . . 11 tests, 0 assertions, 0 failures, 11 errors $
What exactly did we get out of running ZenTest like this? Here's the portion of our new test suite that matters for the Customer class:
# Code Generated by ZenTest v. 2.1.2 # classname: asrt / meth = ratio% # Customer: 0 / 3 = 0.00% require 'test/unit' class TestCustomer < Test::Unit::TestCase def test_addRental raise NotImplementedError, 'Need to write test_addRental' end def test_name= raise NotImplementedError, 'Need to write test_name=' end def test_statement raise NotImplementedError, 'Need to write test_statement' end end
ZenTest built three test methods: one for the accessor method, one for the addRental method and one for the statement method. Why is there nothing for the initializer? This is skipped because initializers tend to be pretty bulletproof. If they're not, it's easy to add the test method yourself. Besides, we'll be testing it indirectly when we write test_name=, the tests for the accessor method. We need to add one other thing--the test suite doesn't load the code we're testing. Changing the beginning of the script to require the videostore.rb file should do the trick for us.
# Code Generated by ZenTest v. 2.1.2 # classname: asrt / meth = ratio% # Customer: 0 / 3 = 0.00% require 'test/unit' require 'videostore'
The little snippet of comments at the top lets us know we have three methods under test in the Customer class, zero assertions testing them and no coverage. Let's fix that. We start by writing some tests for test_name=. It doesn't matter what order we go in here, test_name= simply is a convenient place to start.
def test_name= aCustomer = Customer.new("Fred Jones") assert_equal("Fred Jones",aCustomer.name) aCustomer.name = "Freddy Jones" assert_equal("Freddy Jones",aCustomer.name end
Running testVideoStore.rb again gives us:
$ ruby testVideoStore.rb Loaded suite testVideoStore Started E.EEEEEEEEE Finished in 0.011233 seconds. 1) Error!!! test_addRental(TestCustomer): NotImplementedError: Need to write test_addRental testVideoStore.rb:13:in `test_addRental' testVideoStore.rb:58 2) Error!!! test_statement(TestCustomer): NotImplementedError: Need to write test_statement testVideoStore.rb:23:in `test_statement' testVideoStore.rb:58 . . . 11 tests, 2 assertions, 0 failures, 10 errors $
So far, so good. The line of Es, which shows errors in the test run, has been reduced by one, and the summary line at the bottom tells us roughly the same thing.
We don't have a way to test addRental directly, so we simply write a stub test for now.
def test_addRental assert(1) # stub test, since there is nothing in the method to test end
When we run the tests again, we get:
$ ruby testVideoStore.rb Loaded suite testVideoStore Started ..EEEEEEEEE Finished in 0.008682 seconds. 1) Error!!! test_statement(TestCustomer): NotImplementedError: Need to write test_statement testVideoStore.rb:22:in `test_statement' testVideoStore.rb:57 . . . 11 tests, 3 assertions, 0 failures, 9 errors $
We're doing better and better; there's only one error left in the TestCustomer class. Let's finish up with a test that clears our test_statement error and verifies that addRental works correctly:
def test_statement aMovie = Movie.new("Legacy",0) aRental = Rental.new(aMovie,2) aCustomer = Customer.new("Fred Jones") aCustomer.addRental(aRental) aStatement = "\nRental Record for Fred Jones\n\tLegacy\t2.0 Amount owed is 2.0\nYou earned 1 frequent renter points" assert_equal(aStatement,aCustomer.statement) end
We run the tests again and see:
$ ruby testVideoStore.rb Loaded suite testVideoStore Started ...EEEEEEEE Finished in 0.009378 seconds. . . . 11 tests, 4 assertions, 0 failures, 8 errors $
Great! The remaining errors are occurring on the Movie and Rental classes; the Customer class is clean.
We can continue along like this for the remaining classes, but I'm not going to bore you with those details. Instead, I'd like to look at how ZenTest can help when you've already got some tests in place. Later development allows us to do exactly. Say, for example, the video store owner wants a new Web-based statement that is accessible to customers on-line. After a bit of refactoring and new development, the code looks like this:
class Customer attr_accessor :name def initialize(name) @name = name @rentals = Array.new end def addRental(aRental) @rentals.push(aRental) end def statement result = "\nRental Record for #{@name}\n" @rentals.each do |each| # show figures for this rental result +="\t#{each.aMovie.title}\t#{each.getCharge}\n" end result += "Amount owed is #{getTotalCharge}\n" result += "You earned #{getFrequentRenterPoints} frequent renter points" end def htmlStatement result = "\n<H1>Rentals for <EM>#{name}</EM></H1><P>\n" @rentals.each do |each| result += "#{each.aMovie.title}: #{each.getCharge}<BR>\n" end result += "You owe <EM>#{getTotalCharge}</EM><P>\n" result += "On this rental you earned <EM>#{getFrequentRenterPoints}" + "</EM> frequent renter points<P>" end def getTotalCharge result = 0.0 @rentals.each do |each| result += each.getCharge() end result end def getFrequentRenterPoints result = 0 @rentals.each do |each| result += each.getFrequentRenterPoints end result end end
There's a lot of new stuff in the code now. If we run ZenTest again, it would pick up the methods on which we don't have any coverage. We should have written them as we wrote the new methods, but this method is a bit more illustrative. So this time, we invoke ZenTest a little bit differently:
$ zentest videostore.rb testVideoStore.rb > Missing_tests
and our (trimmed) output looks like this:
# Code Generated by ZenTest v. 2.1.2 # classname: asrt / meth = ratio% # Customer: 4 / 6 = 66.67% require 'test/unit' class TestCustomer < Test::Unit::TestCase def test_getFrequentRenterPoints raise NotImplementedError, 'Need to write test_getFrequentRenterPoints' end def test_getTotalCharge raise NotImplementedError, 'Need to write test_getTotalCharge' end def test_htmlStatement raise NotImplementedError, 'Need to write test_htmlStatement' end end
We need to fill in three more test methods to get our complete coverage. As we write these, we can migrate them to our existing testVideoStore.rb test suite. Then, we can keep moving ahead with refactoring and adding new features. In the future, of course, we simply will add tests as we go along. ZenTest can help here, too. We can write stubs for new development and then run ZenTest to create the new test stubs as well. After some refactorings, such as extract method, ZenTest can be used in the same way.
Refactoring and unit testing are powerful tools for programmers, and ZenTest provides an easy way to start using them in a Ruby environment. Hopefully, this introduction has whetted your appetite.
If you're interested in learning more about refactoring, grab a copy of Refactoring: Improving the Design of Existing Code and take a look at www.refactoring.com. For more information about unit testing, please see c2.com/cgi/wiki?UnitTest www.junit.org/index.htm and www.extremeprogramming.org/rules/unittests.html.
The latest information about Test::Unit and ZenTest can be found on theirs home pages, testunit.talbott.ws and www.zenspider.com/ZSS/Products/ZenTest.